package web import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "rond-with-you/internal/auth" ) // enableTOTP 给 fixture 账号开上两步验证,返回明文秘钥;收尾时关掉并把计数器还原。 func enableTOTP(t *testing.T, f *loginFixture) string { t.Helper() ctx := context.Background() secret, err := auth.GenerateTOTPSecret() if err != nil { t.Fatalf("生成秘钥失败: %v", err) } if err := f.srv.auth.SetTOTPSecret(ctx, f.uid, secret); err != nil { t.Fatalf("写秘钥失败: %v", err) } if err := f.srv.auth.EnableTOTP(ctx, f.uid); err != nil { t.Fatalf("开启两步验证失败: %v", err) } f.srv.loginGuard.Reset() t.Cleanup(func() { f.srv.auth.DisableTOTP(ctx, f.uid) f.srv.loginGuard.Reset() }) return secret } // pending2FA 签发一张当前账号的中间令牌(等价于口令已经输对了)。 func pending2FA(t *testing.T, f *loginFixture) string { t.Helper() u, err := f.srv.auth.UserByID(context.Background(), f.uid) if err != nil { t.Fatalf("取账号失败: %v", err) } return f.srv.auth.SignPending2FA(u.ID, u.Hash) } // attempt2FA 走一次真实的验证码提交。 func attempt2FA(t *testing.T, f *loginFixture, pending, code string) *httptest.ResponseRecorder { t.Helper() form := url.Values{"pending": {pending}, "code": {code}} r := httptest.NewRequest("POST", "/admin/login/twofa", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() f.srv.login2FA(w, r) return w } // S7 回归:验证码这一环必须限速。 // // 中间令牌有 5 分钟有效期,而口令那一环的短锁在「口令正确」时就被清掉了, // 所以攻击者只要知道口令,就能拿着一张令牌在 5 分钟里无限试 6 位码。 func TestLogin2FARateLimited(t *testing.T) { f := newLoginFixture(t) secret := enableTOTP(t, f) pending := pending2FA(t, f) // 连错到阈值:阈值内只提示「验证码不正确」(手滑不该被罚),过了阈值才上锁 lockedAt := 0 for i := 1; i <= 8; i++ { rec := attempt2FA(t, f, pending, "000000") if strings.Contains(rec.Body.String(), "频繁") { lockedAt = i break } if !strings.Contains(rec.Body.String(), "验证码不正确") { t.Fatalf("第 %d 次错码的提示不对: %s", i, firstLine(rec.Body.String())) } } if lockedAt == 0 { t.Fatal("连错 8 次也没触发短锁") } if lockedAt < 2 { t.Errorf("第一次错码就上锁了,正常手滑会被误伤(lockedAt=%d)", lockedAt) } // 上锁之后,即使递上**正确**的码也必须被拦下——否则限速没生效 if code, err := auth.TOTPCode(secret, time.Now()); err == nil { rec := attempt2FA(t, f, pending, code) if !strings.Contains(rec.Body.String(), "频繁") { t.Errorf("锁定期内递正确码没被拦住,仍走到了后续分支: %s", firstLine(rec.Body.String())) } } else { t.Fatalf("算验证码失败: %v", err) } } // S7 回归:同一个验证码不能换到第二个会话。 func TestLogin2FACodeIsSingleUse(t *testing.T) { f := newLoginFixture(t) secret := enableTOTP(t, f) pending := pending2FA(t, f) code, err := auth.TOTPCode(secret, time.Now()) if err != nil { t.Fatalf("算验证码失败: %v", err) } rec := attempt2FA(t, f, pending, code) if rec.Code != http.StatusSeeOther { t.Fatalf("第一次用正确码应登录成功,实际 %d: %s", rec.Code, firstLine(rec.Body.String())) } rec = attempt2FA(t, f, pending, code) if !strings.Contains(rec.Body.String(), "刚用过") { t.Errorf("同一个码被重放了: %d %s", rec.Code, firstLine(rec.Body.String())) } }