feat(auth): 验证码/注册/登录/JWT 鉴权模块 (tsk_2PFfyviECIXh)
实现 server/internal/auth 控制面鉴权底座,对应 doc/02 §4.1、doc/06 §3:
- POST /v1/auth/code:IP+邮箱双维度 Redis 滑窗限频(同邮箱 1/min、同 IP 10/h)
+ 一次性邮箱域黑名单(embed 词表)→ 6 位数字码写 auth:code:{email} TTL 10min
→ 异步发信(SMTP / 开发态 log mailer)。
- POST /v1/auth/register:验码(一次性,删 key;超次数烧码)→ 单事务建号
(uuid + dp_uuid 应用层生成、argon2id)+ 7 天 PRO 试用(source='trial')→ 签发 JWT。
- POST /v1/auth/login:argon2id 校验(失败恒定时、未知用户走 dummy hash);
失败计数限流 rl:login:{email} + 锁定;banned 拒绝。
- POST /v1/auth/refresh:RS256,access 15min + refresh 30d 落 Redis 白名单
jwt:refresh:{jti},旋转时删旧写新;JWT 头带 kid,验证端接受新旧公钥支持轮换。
- RequireAuth 中间件:解析 Bearer access,注入 user id(复用 codes.CtxKeyUserID
避免循环依赖)+ uuid + claims 到 context。
- 错误体统一走 internal/apierr 的 {code, message_zh, message_en},文案双语脱敏。
- 文件拆分:handler/service/password/token/ratelimit/emailcheck/store/mailer/
middleware/errors/keyloader;config 增加可选 JWT PEM 路径/kid 加载。
测试:password/token/ratelimit/emailcheck/service/handler/middleware 单测
(miniredis,含注册全流程、重复邮箱 409、验证码错误/过期/复用/烧码、限频 429
带 Retry-After、登录失败锁定、banned 拒绝、refresh 旋转后旧 token 失效),
go test -race 通过;集成测试 integration_test.go(testcontainers MySQL8+Redis,
register→login→refresh→受保护接口全链路,构建 tag integration)与 codes 模块同构。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// Redis key helpers for verification codes (doc/03 §4: auth:code:{email}).
|
||||
func codeKey(email string) string { return "auth:code:" + email }
|
||||
func codeAttemptsKey(email string) string { return "auth:code:attempts:" + email }
|
||||
|
||||
// Rate-limit scopes.
|
||||
const (
|
||||
scopeCodeEmail = "code:email" // per-email send limit
|
||||
scopeCodeIP = "code:ip" // per-IP send limit
|
||||
scopeLogin = "login" // per-email login-failure counter
|
||||
)
|
||||
|
||||
// ServiceConfig tunes the auth service. Zero values fall back to the documented
|
||||
// defaults (doc/02 §4.1, doc/06 §3).
|
||||
type ServiceConfig struct {
|
||||
CodeTTL time.Duration // verification code lifetime (default 10m)
|
||||
CodeMaxAttempts int // max verify attempts before code is burned (default 5)
|
||||
TrialDays int // auto PRO trial length (default 7)
|
||||
|
||||
EmailPerMinute int // code sends per email per minute (default 1)
|
||||
IPPerHour int // code sends per IP per hour (default 10)
|
||||
LoginFailMax int // failed logins before lock (default 5)
|
||||
LoginLockWindow time.Duration // lock / failure-window length (default 15m)
|
||||
}
|
||||
|
||||
func (c *ServiceConfig) withDefaults() {
|
||||
if c.CodeTTL <= 0 {
|
||||
c.CodeTTL = 10 * time.Minute
|
||||
}
|
||||
if c.CodeMaxAttempts <= 0 {
|
||||
c.CodeMaxAttempts = 5
|
||||
}
|
||||
if c.TrialDays <= 0 {
|
||||
c.TrialDays = 7
|
||||
}
|
||||
if c.EmailPerMinute <= 0 {
|
||||
c.EmailPerMinute = 1
|
||||
}
|
||||
if c.IPPerHour <= 0 {
|
||||
c.IPPerHour = 10
|
||||
}
|
||||
if c.LoginFailMax <= 0 {
|
||||
c.LoginFailMax = 5
|
||||
}
|
||||
if c.LoginLockWindow <= 0 {
|
||||
c.LoginLockWindow = 15 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
// Service is the auth business layer: code issuance, registration, login, and
|
||||
// token refresh. It is safe for concurrent use.
|
||||
type Service struct {
|
||||
store UserStore
|
||||
rdb *redis.Client
|
||||
rl *RateLimiter
|
||||
tokens *TokenManager
|
||||
mailer Mailer
|
||||
cfg ServiceConfig
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService wires the auth service. now may be nil (defaults to time.Now).
|
||||
func NewService(store UserStore, rdb *redis.Client, rl *RateLimiter, tokens *TokenManager, mailer Mailer, cfg ServiceConfig, now func() time.Time) *Service {
|
||||
cfg.withDefaults()
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Service{
|
||||
store: store,
|
||||
rdb: rdb,
|
||||
rl: rl,
|
||||
tokens: tokens,
|
||||
mailer: mailer,
|
||||
cfg: cfg,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// SendCode applies rate-limiting and disposable-domain checks, generates a
|
||||
// 6-digit code, stores it in Redis (TTL CodeTTL), and dispatches it
|
||||
// asynchronously. retryAfter is non-zero only when a rate limit was hit.
|
||||
func (s *Service) SendCode(ctx context.Context, rawEmail, ip string) (retryAfter time.Duration, apiErr *apierr.Error) {
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if !ValidEmail(email) {
|
||||
return 0, ErrInvalidRequest
|
||||
}
|
||||
if IsDisposable(email) {
|
||||
return 0, ErrEmailDisposable
|
||||
}
|
||||
|
||||
// Per-email limit: 1/min by default.
|
||||
ok, ra, err := s.rl.Allow(ctx, scopeCodeEmail, email, s.cfg.EmailPerMinute, time.Minute)
|
||||
if err != nil {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
if !ok {
|
||||
return ra, ErrRateLimited
|
||||
}
|
||||
// Per-IP limit: e.g. 10/h. Skipped when IP is unknown.
|
||||
if ip != "" {
|
||||
ok, ra, err = s.rl.Allow(ctx, scopeCodeIP, ip, s.cfg.IPPerHour, time.Hour)
|
||||
if err != nil {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
if !ok {
|
||||
return ra, ErrRateLimited
|
||||
}
|
||||
}
|
||||
|
||||
code, err := genNumericCode(6)
|
||||
if err != nil {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
|
||||
pipe := s.rdb.Pipeline()
|
||||
pipe.Set(ctx, codeKey(email), code, s.cfg.CodeTTL)
|
||||
pipe.Del(ctx, codeAttemptsKey(email)) // reset attempt counter for the new code
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
|
||||
// Dispatch asynchronously; the request must not block on SMTP. A detached
|
||||
// context is used so request cancellation doesn't abort delivery.
|
||||
go func(to, c string) {
|
||||
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = s.mailer.SendCode(sendCtx, to, c)
|
||||
}(email, code)
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Register verifies the code (one-time), creates the account plus a 7-day PRO
|
||||
// trial in a single transaction, and returns a fresh token pair.
|
||||
func (s *Service) Register(ctx context.Context, rawEmail, code, password string) (*TokenPair, *apierr.Error) {
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if !ValidEmail(email) || len(password) < 8 || len(code) != 6 {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
|
||||
// Verify the code with brute-force protection.
|
||||
if apiErr := s.verifyCode(ctx, email, code); apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
pwHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, ErrInternal
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUserWithTrial(ctx, email, pwHash, s.cfg.TrialDays)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
return nil, ErrInternal
|
||||
}
|
||||
|
||||
pair, err := s.tokens.Issue(ctx, user.ID, user.UUID)
|
||||
if err != nil {
|
||||
return nil, ErrInternal
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// verifyCode checks the supplied code against Redis. The code is consumed
|
||||
// (deleted) on success, and after CodeMaxAttempts failed tries it is burned to
|
||||
// stop brute forcing the 6-digit space. All failure modes return ErrCodeInvalid
|
||||
// to avoid distinguishing wrong / expired / used.
|
||||
func (s *Service) verifyCode(ctx context.Context, email, code string) *apierr.Error {
|
||||
stored, err := s.rdb.Get(ctx, codeKey(email)).Result()
|
||||
if err == redis.Nil {
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return ErrInternal
|
||||
}
|
||||
|
||||
// Count this attempt (TTL bounded by the code lifetime).
|
||||
attempts, err := s.rdb.Incr(ctx, codeAttemptsKey(email)).Result()
|
||||
if err != nil {
|
||||
return ErrInternal
|
||||
}
|
||||
if attempts == 1 {
|
||||
_ = s.rdb.Expire(ctx, codeAttemptsKey(email), s.cfg.CodeTTL).Err()
|
||||
}
|
||||
if attempts > int64(s.cfg.CodeMaxAttempts) {
|
||||
// Burn the code and the counter.
|
||||
_ = s.rdb.Del(ctx, codeKey(email), codeAttemptsKey(email)).Err()
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(stored), []byte(code)) != 1 {
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
|
||||
// Success: consume the code (one-time use).
|
||||
_ = s.rdb.Del(ctx, codeKey(email), codeAttemptsKey(email)).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login authenticates email+password with constant-time behaviour and a
|
||||
// failure-count lock. retryAfter is non-zero only when the account is locked.
|
||||
func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*TokenPair, time.Duration, *apierr.Error) {
|
||||
_ = ip // IP reserved for future per-IP login throttling; not logged.
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if email == "" || password == "" {
|
||||
return nil, 0, ErrInvalidRequest
|
||||
}
|
||||
|
||||
// Lock check.
|
||||
count, ttl, err := s.rl.FailureCount(ctx, scopeLogin, email)
|
||||
if err != nil {
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
if count >= int64(s.cfg.LoginFailMax) && ttl > 0 {
|
||||
return nil, ttl, ErrAccountLocked
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Spend equivalent CPU so timing doesn't reveal account existence.
|
||||
ConstantTimeReject(password)
|
||||
_, _ = s.rl.RecordFailure(ctx, scopeLogin, email, s.cfg.LoginLockWindow)
|
||||
return nil, 0, ErrInvalidCredentials
|
||||
}
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
|
||||
valid, verr := VerifyPassword(user.PwHash, password)
|
||||
if verr != nil || !valid {
|
||||
_, _ = s.rl.RecordFailure(ctx, scopeLogin, email, s.cfg.LoginLockWindow)
|
||||
return nil, 0, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if user.Status == "banned" {
|
||||
return nil, 0, ErrAccountBanned
|
||||
}
|
||||
|
||||
// Success: clear the failure counter and issue tokens.
|
||||
_ = s.rl.ClearFailures(ctx, scopeLogin, email)
|
||||
pair, err := s.tokens.Issue(ctx, user.ID, user.UUID)
|
||||
if err != nil {
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
return pair, 0, nil
|
||||
}
|
||||
|
||||
// Refresh validates and rotates a refresh token.
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, *apierr.Error) {
|
||||
if refreshToken == "" {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
pair, err := s.tokens.Refresh(ctx, refreshToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidTokenSentinel) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
// Parse / signature / expiry failures all map to an opaque invalid-token.
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// genNumericCode returns an n-digit numeric string drawn from crypto/rand.
|
||||
func genNumericCode(n int) (string, error) {
|
||||
const digits = "0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: gen code: %w", err)
|
||||
}
|
||||
b[i] = digits[idx.Int64()]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
Reference in New Issue
Block a user