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 }