7434e5195e
Anonymous callers get a free cumulative quota (1000 calls per IP); once exhausted they get 403 quota_exhausted and must register. Public users can self-register (email+password) to obtain a higher-quota API key, view usage, and regenerate the key. Quota counters live in Redis; the public API stays read-only except for the registration writes. - migration 0011: app_user table + api_key.quota_total + 'registered' tier - ratelimit: IncrTotal/TotalUsed/CopyTotal lifetime counters - middleware: enforce cumulative quota + X-Quota-* headers - store: RegisterUser/Authenticate/RegenerateKey (bcrypt) - handlers: POST /api/v1/register, /account, /account/regenerate - admin: quota_total column + registered tier - public: 'API 密钥' account page + API docs quota section Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
170 lines
5.5 KiB
Go
170 lines
5.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/baicai2026-baicai/goods/api/internal/apikey"
|
|
)
|
|
|
|
// ErrEmailTaken is returned when registering an email that already exists.
|
|
var ErrEmailTaken = errors.New("email already registered")
|
|
|
|
// Account is a self-registered public-API user and its current key metadata.
|
|
type Account struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
KeyID string `json:"-"`
|
|
KeyPrefix string `json:"key_prefix"`
|
|
RateLimitPerMin int `json:"rate_limit_per_min"`
|
|
QuotaTotal int64 `json:"quota_total"`
|
|
}
|
|
|
|
// bcryptDummyHash is compared against on unknown-email logins to keep timing
|
|
// roughly constant and avoid leaking which emails are registered.
|
|
const bcryptDummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
|
}
|
|
|
|
// RegisterUser creates an account plus a self-issued API key with the given
|
|
// per-minute rate and cumulative quota, returning the plaintext key (shown
|
|
// once). Email uniqueness is case-insensitive; ErrEmailTaken signals a dupe.
|
|
func (s *Store) RegisterUser(ctx context.Context, email, password string, ratePerMin int, quotaTotal int64) (plaintext string, acct Account, err error) {
|
|
email = strings.TrimSpace(email)
|
|
pwHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", Account{}, err
|
|
}
|
|
key, keyHash, prefix, err := apikey.Generate()
|
|
if err != nil {
|
|
return "", Account{}, err
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return "", Account{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var keyID string
|
|
if err = tx.QueryRow(ctx,
|
|
`INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, quota_total, created_by)
|
|
VALUES ($1,$2,$3,$4,'registered',$5,$6,'self-register') RETURNING id`,
|
|
"user:"+strings.ToLower(email), prefix, keyHash, email, ratePerMin, quotaTotal,
|
|
).Scan(&keyID); err != nil {
|
|
return "", Account{}, err
|
|
}
|
|
|
|
var userID string
|
|
if err = tx.QueryRow(ctx,
|
|
`INSERT INTO app_user (email, password_hash, api_key_id) VALUES ($1,$2,$3) RETURNING id`,
|
|
email, string(pwHash), keyID,
|
|
).Scan(&userID); err != nil {
|
|
if isUniqueViolation(err) {
|
|
return "", Account{}, ErrEmailTaken
|
|
}
|
|
return "", Account{}, err
|
|
}
|
|
|
|
if err = tx.Commit(ctx); err != nil {
|
|
return "", Account{}, err
|
|
}
|
|
return key, Account{
|
|
ID: userID, Email: email, KeyID: keyID, KeyPrefix: prefix,
|
|
RateLimitPerMin: ratePerMin, QuotaTotal: quotaTotal,
|
|
}, nil
|
|
}
|
|
|
|
// Authenticate verifies an email/password pair and returns the account with its
|
|
// current (non-revoked) key metadata. Returns ErrNotFound on unknown email or
|
|
// wrong password.
|
|
func (s *Store) Authenticate(ctx context.Context, email, password string) (Account, error) {
|
|
email = strings.TrimSpace(email)
|
|
var (
|
|
userID, pwHash string
|
|
keyID *string
|
|
)
|
|
err := s.pool.QueryRow(ctx,
|
|
`SELECT id, password_hash, api_key_id FROM app_user WHERE lower(email) = lower($1)`, email,
|
|
).Scan(&userID, &pwHash, &keyID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
_ = bcrypt.CompareHashAndPassword([]byte(bcryptDummyHash), []byte(password))
|
|
return Account{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(password)); err != nil {
|
|
return Account{}, ErrNotFound
|
|
}
|
|
|
|
acct := Account{ID: userID, Email: email}
|
|
if keyID != nil {
|
|
acct.KeyID = *keyID
|
|
_ = s.pool.QueryRow(ctx,
|
|
`SELECT key_prefix, rate_limit_per_min, quota_total
|
|
FROM api_key WHERE id = $1 AND revoked_at IS NULL`, *keyID,
|
|
).Scan(&acct.KeyPrefix, &acct.RateLimitPerMin, &acct.QuotaTotal)
|
|
}
|
|
return acct, nil
|
|
}
|
|
|
|
// RegenerateKey verifies credentials, revokes the account's current key, and
|
|
// issues a fresh one with the same rate/quota, returning the plaintext key and
|
|
// the previous key id (so cumulative usage can be carried over). Returns
|
|
// ErrNotFound on bad credentials.
|
|
func (s *Store) RegenerateKey(ctx context.Context, email, password string, ratePerMin int, quotaTotal int64) (plaintext string, acct Account, oldKeyID string, err error) {
|
|
cur, err := s.Authenticate(ctx, email, password)
|
|
if err != nil {
|
|
return "", Account{}, "", err
|
|
}
|
|
key, keyHash, prefix, err := apikey.Generate()
|
|
if err != nil {
|
|
return "", Account{}, "", err
|
|
}
|
|
|
|
oldKeyID = cur.KeyID
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return "", Account{}, "", err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
if oldKeyID != "" {
|
|
if _, err = tx.Exec(ctx,
|
|
`UPDATE api_key SET revoked_at = now() WHERE id = $1`, oldKeyID); err != nil {
|
|
return "", Account{}, "", err
|
|
}
|
|
}
|
|
var newKeyID string
|
|
if err = tx.QueryRow(ctx,
|
|
`INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, quota_total, created_by)
|
|
VALUES ($1,$2,$3,$4,'registered',$5,$6,'self-register') RETURNING id`,
|
|
"user:"+strings.ToLower(cur.Email), prefix, keyHash, cur.Email, ratePerMin, quotaTotal,
|
|
).Scan(&newKeyID); err != nil {
|
|
return "", Account{}, "", err
|
|
}
|
|
if _, err = tx.Exec(ctx,
|
|
`UPDATE app_user SET api_key_id = $1 WHERE id = $2`, newKeyID, cur.ID); err != nil {
|
|
return "", Account{}, "", err
|
|
}
|
|
if err = tx.Commit(ctx); err != nil {
|
|
return "", Account{}, "", err
|
|
}
|
|
|
|
cur.KeyID = newKeyID
|
|
cur.KeyPrefix = prefix
|
|
cur.RateLimitPerMin = ratePerMin
|
|
cur.QuotaTotal = quotaTotal
|
|
return key, cur, oldKeyID, nil
|
|
}
|