Files
goods/api/internal/store/account.go
T
sulaimaannaasif6866 7434e5195e
CI / Go (api) (pull_request) Successful in 15s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 16s
feat(api): tiered cumulative quota + self-service registration
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>
2026-06-21 07:26:41 +00:00

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
}