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>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user