a5e25b444f
实现 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>
272 lines
8.3 KiB
Go
272 lines
8.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newService wires a Service over a fake store + miniredis for unit tests.
|
|
// It returns the service, the redis client (to read codes directly), the fake
|
|
// store, and the miniredis handle (to fast-forward TTLs).
|
|
func newService(t *testing.T, cfg ServiceConfig) (*Service, *fakeStore, *captureMailer) {
|
|
t.Helper()
|
|
rdb, _ := newMiniRedis(t)
|
|
rl := NewRateLimiter(rdb, nil)
|
|
tm := newTokenManager(t, rdb, time.Now)
|
|
store := newFakeStore()
|
|
mailer := newCaptureMailer()
|
|
svc := NewService(store, rdb, rl, tm, mailer, cfg, nil)
|
|
return svc, store, mailer
|
|
}
|
|
|
|
// codeInRedis reads the active verification code straight from Redis.
|
|
func codeInRedis(t *testing.T, svc *Service, email string) string {
|
|
t.Helper()
|
|
c, err := svc.rdb.Get(context.Background(), codeKey(email)).Result()
|
|
if err != nil {
|
|
t.Fatalf("no code stored for %s: %v", email, err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func TestService_RegisterFullFlow(t *testing.T) {
|
|
svc, store, _ := newService(t, ServiceConfig{})
|
|
ctx := context.Background()
|
|
const email = "alice@example.com"
|
|
|
|
if _, err := svc.SendCode(ctx, email, "1.1.1.1"); err != nil {
|
|
t.Fatalf("SendCode: %v", err)
|
|
}
|
|
code := codeInRedis(t, svc, email)
|
|
|
|
pair, apiErr := svc.Register(ctx, email, code, "supersecret")
|
|
if apiErr != nil {
|
|
t.Fatalf("Register: %v", apiErr)
|
|
}
|
|
if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 {
|
|
t.Fatalf("bad token pair: %+v", pair)
|
|
}
|
|
|
|
// User exists.
|
|
u, err := store.GetUserByEmail(ctx, email)
|
|
if err != nil {
|
|
t.Fatalf("user not created: %v", err)
|
|
}
|
|
// Trial subscription exists, PRO, ~7 days.
|
|
tr, ok := store.trials[u.ID]
|
|
if !ok {
|
|
t.Fatal("trial subscription not created")
|
|
}
|
|
if tr.plan != "pro" || tr.source != "trial" {
|
|
t.Fatalf("trial = %+v, want pro/trial", tr)
|
|
}
|
|
days := time.Until(tr.expiresAt).Hours() / 24
|
|
if days < 6.9 || days > 7.1 {
|
|
t.Fatalf("trial length = %.2f days, want ~7", days)
|
|
}
|
|
|
|
// Issued access token authenticates.
|
|
claims, perr := svc.tokens.ParseAccess(pair.AccessToken)
|
|
if perr != nil {
|
|
t.Fatalf("ParseAccess: %v", perr)
|
|
}
|
|
if claims.UID != u.ID || claims.Subject != u.UUID {
|
|
t.Fatalf("claims mismatch: %+v", claims)
|
|
}
|
|
}
|
|
|
|
func TestService_DuplicateEmailConflict(t *testing.T) {
|
|
// Higher send limit so the two registration attempts can each request a code.
|
|
svc, _, _ := newService(t, ServiceConfig{EmailPerMinute: 10})
|
|
ctx := context.Background()
|
|
const email = "dup@example.com"
|
|
|
|
// First registration.
|
|
_, _ = svc.SendCode(ctx, email, "")
|
|
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1"); e != nil {
|
|
t.Fatalf("first register: %v", e)
|
|
}
|
|
|
|
// Second: new code, but the email is already taken → 409.
|
|
_, _ = svc.SendCode(ctx, email, "")
|
|
_, apiErr := svc.Register(ctx, email, codeInRedis(t, svc, email), "password2")
|
|
if apiErr == nil || apiErr.Code != ErrEmailExists.Code {
|
|
t.Fatalf("want email_exists, got %v", apiErr)
|
|
}
|
|
}
|
|
|
|
func TestService_CodeWrong(t *testing.T) {
|
|
svc, _, _ := newService(t, ServiceConfig{})
|
|
ctx := context.Background()
|
|
const email = "wrong@example.com"
|
|
_, _ = svc.SendCode(ctx, email, "")
|
|
|
|
_, apiErr := svc.Register(ctx, email, "000000", "password1")
|
|
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
|
t.Fatalf("want code_invalid, got %v", apiErr)
|
|
}
|
|
}
|
|
|
|
func TestService_CodeExpired(t *testing.T) {
|
|
svc, _, _ := newService(t, ServiceConfig{CodeTTL: time.Minute})
|
|
ctx := context.Background()
|
|
const email = "expired@example.com"
|
|
_, _ = svc.SendCode(ctx, email, "")
|
|
code := codeInRedis(t, svc, email)
|
|
|
|
// Expire the code key.
|
|
svc.rdb.Del(ctx, codeKey(email))
|
|
|
|
_, apiErr := svc.Register(ctx, email, code, "password1")
|
|
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
|
t.Fatalf("want code_invalid after expiry, got %v", apiErr)
|
|
}
|
|
}
|
|
|
|
func TestService_CodeReuseRejected(t *testing.T) {
|
|
svc, _, _ := newService(t, ServiceConfig{})
|
|
ctx := context.Background()
|
|
const email = "reuse@example.com"
|
|
_, _ = svc.SendCode(ctx, email, "")
|
|
code := codeInRedis(t, svc, email)
|
|
|
|
if _, e := svc.Register(ctx, email, code, "password1"); e != nil {
|
|
t.Fatalf("first register: %v", e)
|
|
}
|
|
// Re-using the consumed code must fail.
|
|
_, apiErr := svc.Register(ctx, "other@example.com", code, "password1")
|
|
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
|
t.Fatalf("want code_invalid on reuse, got %v", apiErr)
|
|
}
|
|
}
|
|
|
|
func TestService_CodeBruteForceBurned(t *testing.T) {
|
|
svc, _, _ := newService(t, ServiceConfig{CodeMaxAttempts: 3})
|
|
ctx := context.Background()
|
|
const email = "brute@example.com"
|
|
_, _ = svc.SendCode(ctx, email, "")
|
|
good := codeInRedis(t, svc, email)
|
|
|
|
// 3 wrong attempts burn the code.
|
|
for i := 0; i < 3; i++ {
|
|
if _, e := svc.Register(ctx, email, "999999", "password1"); e == nil {
|
|
t.Fatal("wrong code should fail")
|
|
}
|
|
}
|
|
// Even the correct code no longer works.
|
|
if _, e := svc.Register(ctx, email, good, "password1"); e == nil || e.Code != ErrCodeInvalid.Code {
|
|
t.Fatalf("burned code should reject correct value, got %v", e)
|
|
}
|
|
}
|
|
|
|
func TestService_SendCodeRateLimited(t *testing.T) {
|
|
svc, _, _ := newService(t, ServiceConfig{EmailPerMinute: 1})
|
|
ctx := context.Background()
|
|
const email = "rl@example.com"
|
|
|
|
if _, e := svc.SendCode(ctx, email, "9.9.9.9"); e != nil {
|
|
t.Fatalf("first send: %v", e)
|
|
}
|
|
ra, apiErr := svc.SendCode(ctx, email, "9.9.9.9")
|
|
if apiErr == nil || apiErr.Code != ErrRateLimited.Code {
|
|
t.Fatalf("want rate_limited, got %v", apiErr)
|
|
}
|
|
if ra <= 0 {
|
|
t.Fatalf("expected positive retry-after, got %v", ra)
|
|
}
|
|
}
|
|
|
|
func TestService_SendCodeDisposableBlocked(t *testing.T) {
|
|
svc, _, _ := newService(t, ServiceConfig{})
|
|
_, apiErr := svc.SendCode(context.Background(), "x@mailinator.com", "")
|
|
if apiErr == nil || apiErr.Code != ErrEmailDisposable.Code {
|
|
t.Fatalf("want email_disposable, got %v", apiErr)
|
|
}
|
|
}
|
|
|
|
func TestService_LoginAndLockout(t *testing.T) {
|
|
svc, _, _ := newService(t, ServiceConfig{LoginFailMax: 3, LoginLockWindow: 15 * time.Minute})
|
|
ctx := context.Background()
|
|
const email = "login@example.com"
|
|
const pw = "rightpassword"
|
|
|
|
_, _ = svc.SendCode(ctx, email, "")
|
|
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw); e != nil {
|
|
t.Fatalf("register: %v", e)
|
|
}
|
|
|
|
// Correct login works.
|
|
pair, _, apiErr := svc.Login(ctx, email, pw, "")
|
|
if apiErr != nil || pair == nil {
|
|
t.Fatalf("login should succeed: %v", apiErr)
|
|
}
|
|
|
|
// 3 wrong attempts.
|
|
for i := 0; i < 3; i++ {
|
|
_, _, e := svc.Login(ctx, email, "wrong", "")
|
|
if e == nil || e.Code != ErrInvalidCredentials.Code {
|
|
t.Fatalf("attempt %d want invalid_credentials, got %v", i, e)
|
|
}
|
|
}
|
|
// Now locked, even with the correct password.
|
|
_, ra, e := svc.Login(ctx, email, pw, "")
|
|
if e == nil || e.Code != ErrAccountLocked.Code {
|
|
t.Fatalf("want account_locked, got %v", e)
|
|
}
|
|
if ra <= 0 {
|
|
t.Fatalf("expected positive retry-after on lock, got %v", ra)
|
|
}
|
|
}
|
|
|
|
func TestService_LoginUnknownUser(t *testing.T) {
|
|
svc, _, _ := newService(t, ServiceConfig{})
|
|
_, _, apiErr := svc.Login(context.Background(), "ghost@example.com", "whatever", "")
|
|
if apiErr == nil || apiErr.Code != ErrInvalidCredentials.Code {
|
|
t.Fatalf("want invalid_credentials for unknown user, got %v", apiErr)
|
|
}
|
|
}
|
|
|
|
func TestService_BannedUserRejected(t *testing.T) {
|
|
svc, store, _ := newService(t, ServiceConfig{})
|
|
ctx := context.Background()
|
|
const email = "banned@example.com"
|
|
const pw = "password1"
|
|
|
|
_, _ = svc.SendCode(ctx, email, "")
|
|
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw); e != nil {
|
|
t.Fatalf("register: %v", e)
|
|
}
|
|
store.setStatus(email, "banned")
|
|
|
|
_, _, apiErr := svc.Login(ctx, email, pw, "")
|
|
if apiErr == nil || apiErr.Code != ErrAccountBanned.Code {
|
|
t.Fatalf("want account_banned, got %v", apiErr)
|
|
}
|
|
}
|
|
|
|
func TestService_RefreshRotation(t *testing.T) {
|
|
svc, _, _ := newService(t, ServiceConfig{})
|
|
ctx := context.Background()
|
|
const email = "refresh@example.com"
|
|
|
|
_, _ = svc.SendCode(ctx, email, "")
|
|
pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1")
|
|
if e != nil {
|
|
t.Fatalf("register: %v", e)
|
|
}
|
|
|
|
rotated, apiErr := svc.Refresh(ctx, pair.RefreshToken)
|
|
if apiErr != nil {
|
|
t.Fatalf("refresh: %v", apiErr)
|
|
}
|
|
// Old refresh token now invalid.
|
|
if _, e := svc.Refresh(ctx, pair.RefreshToken); e == nil || e.Code != ErrInvalidToken.Code {
|
|
t.Fatalf("want invalid_token for rotated-out refresh, got %v", e)
|
|
}
|
|
// New one works.
|
|
if _, e := svc.Refresh(ctx, rotated.RefreshToken); e != nil {
|
|
t.Fatalf("new refresh should work: %v", e)
|
|
}
|
|
}
|