fix(api): 登录验证码与 passkey 挑战不再被他人的 start 作废,冷却内重复 start 照常 202,登录挑战按来源限量

This commit is contained in:
Lemon-miaow committed 2026-09-25 16:37:03 +08:00
1 parent 084ba1ed9e
commit 4757353324
22 files changed
+1290 -314

No files matched your search

+287
View File
@@ -0,0 +1,287 @@
//go:build pgint
package pgint
import (
"context"
"errors"
"testing"
"time"
"felis.lolicon.best/internal/api"
)
// ---- login codes and ceremonies that coexist (migration 0029) --------------------
func liveLoginCodes(t *testing.T, userID, purpose string, now time.Time) int {
t.Helper()
var n int
if err := db.QueryRow(`SELECT count(*) FROM email_otps
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > $3`,
userID, purpose, now).Scan(&n); err != nil {
t.Fatalf("count live codes: %v", err)
}
return n
}
// A login start keeps the three newest live codes: a fourth drops the oldest, and
// redeeming any live one spends its siblings too.
func TestAddLoginEmailOTPKeepsNewestThree(t *testing.T) {
ctx := context.Background()
u := newUser(t, "user", "otp-keep")
purpose := "login_email"
addr := "keep-" + suffix(t) + "@example.net"
t0 := mustNow().Truncate(time.Second)
add := func(hash string, at time.Time) {
t.Helper()
if err := repo.AddLoginEmailOTP(ctx, "keep-"+suffix(t), u.ID, addr, hash, purpose, at, at.Add(10*time.Minute)); err != nil {
t.Fatalf("AddLoginEmailOTP(%s): %v", hash, err)
}
}
add("h1", t0)
add("h2", t0.Add(time.Minute))
add("h3", t0.Add(2*time.Minute))
if n := liveLoginCodes(t, u.ID, purpose, t0.Add(2*time.Minute)); n != 3 {
t.Fatalf("live codes after three starts = %d, want 3", n)
}
add("h4", t0.Add(3*time.Minute))
at := t0.Add(3 * time.Minute)
if n := liveLoginCodes(t, u.ID, purpose, at); n != 3 {
t.Fatalf("live codes after four starts = %d, want 3", n)
}
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h1", at); !errors.Is(err, api.ErrOTPInvalid) {
t.Fatalf("oldest code after a fourth start = %v, want ErrOTPInvalid", err)
}
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h2", at); err != nil {
t.Fatalf("second code = %v, want nil", err)
}
for _, h := range []string{"h3", "h4"} {
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, h, at); !errors.Is(err, api.ErrOTPInvalid) {
t.Fatalf("sibling %s after a sign-in = %v, want ErrOTPInvalid", h, err)
}
}
// Expired codes leave the allowance: a start after they lapse is the only live one.
add("h5", t0.Add(4*time.Minute))
add("h6", t0.Add(5*time.Minute))
later := t0.Add(20 * time.Minute)
add("h7", later)
if n := liveLoginCodes(t, u.ID, purpose, later); n != 1 {
t.Fatalf("live codes after the others expired = %d, want 1", n)
}
var unconsumed int
if err := db.QueryRow(`SELECT count(*) FROM email_otps WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL`,
u.ID, purpose).Scan(&unconsumed); err != nil {
t.Fatalf("count unconsumed: %v", err)
}
if unconsumed != 1 {
t.Fatalf("unconsumed rows = %d, want 1 (expired codes are deleted, not kept)", unconsumed)
}
}
// A wrong guess with several live codes costs each open code one attempt and the
// account one failure; when every live code is spent the door answers ErrOTPLocked,
// and a newer code still signs in.
func TestConsumeLoginEmailOTPAcrossLiveCodes(t *testing.T) {
ctx := context.Background()
u := newUser(t, "user", "otp-multi")
purpose := "op_login"
addr := "multi-" + suffix(t) + "@example.net"
t0 := mustNow().Truncate(time.Second)
add := func(hash string, at time.Time) {
t.Helper()
if err := repo.AddLoginEmailOTP(ctx, "multi-"+suffix(t), u.ID, addr, hash, purpose, at, at.Add(10*time.Minute)); err != nil {
t.Fatalf("AddLoginEmailOTP(%s): %v", hash, err)
}
}
add("h-a", t0)
add("h-b", t0.Add(time.Minute))
at := t0.Add(time.Minute)
attempts := func() map[string]int {
t.Helper()
rows, err := db.Query(`SELECT code_hash, attempts FROM email_otps WHERE user_id = $1 AND purpose = $2`, u.ID, purpose)
if err != nil {
t.Fatalf("read attempts: %v", err)
}
defer rows.Close()
got := map[string]int{}
for rows.Next() {
var h string
var n int
if err := rows.Scan(&h, &n); err != nil {
t.Fatalf("scan: %v", err)
}
got[h] = n
}
return got
}
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-wrong", at); !errors.Is(err, api.ErrOTPInvalid) {
t.Fatalf("wrong guess = %v, want ErrOTPInvalid", err)
}
if got := attempts(); got["h-a"] != 1 || got["h-b"] != 1 {
t.Fatalf("attempts after one wrong guess = %v, want h-a:1 h-b:1", got)
}
for i := 2; i <= 5; i++ {
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-wrong", at); !errors.Is(err, api.ErrOTPInvalid) {
t.Fatalf("wrong guess %d = %v, want ErrOTPInvalid", i, err)
}
}
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-a", at); !errors.Is(err, api.ErrOTPLocked) {
t.Fatalf("right code once every live code is spent = %v, want ErrOTPLocked", err)
}
var failures int
if err := db.QueryRow(`SELECT failures FROM otp_failure_windows WHERE user_id = $1 AND purpose = $2`,
u.ID, purpose).Scan(&failures); err != nil {
t.Fatalf("read budget row: %v", err)
}
if failures != 5 {
t.Fatalf("account failures = %d, want 5 (one per wrong guess, not one per code)", failures)
}
add("h-c", t0.Add(2*time.Minute))
at = t0.Add(2 * time.Minute)
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-b", at); !errors.Is(err, api.ErrOTPInvalid) {
t.Fatalf("spent code beside a fresh one = %v, want ErrOTPInvalid", err)
}
if got := attempts(); got["h-c"] != 1 || got["h-a"] != 5 || got["h-b"] != 5 {
t.Fatalf("attempts after a guess of a spent code = %v, want h-c:1 and the spent codes left at 5", got)
}
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-c", at); err != nil {
t.Fatalf("fresh code = %v, want nil", err)
}
if n := liveLoginCodes(t, u.ID, purpose, at); n != 0 {
t.Fatalf("live codes after a sign-in = %d, want 0", n)
}
}
// Email-first passkey ceremonies of one account coexist and are redeemed by the
// challenge the browser signed; one network holds at most 32 live ones.
func TestPasskeyLoginChallengesCoexist(t *testing.T) {
ctx := context.Background()
u := newUser(t, "user", "pk-login")
purpose := "passkey_login"
source := "203.0.113." + suffix(t)
now := mustNow()
add := func(id, source, challenge, session string, expiresAt time.Time) error {
return repo.AddPasskeyLoginChallenge(ctx, id+"-"+suffix(t), u.ID, purpose, source, challenge, []byte(session), now, expiresAt)
}
if err := add("c1", source, "Y2hhbGxlbmdlMQ", "s1", now.Add(5*time.Minute)); err != nil {
t.Fatalf("add c1: %v", err)
}
if err := add("c2", source, "Y2hhbGxlbmdlMg", "s2", now.Add(5*time.Minute)); err != nil {
t.Fatalf("add c2: %v", err)
}
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "bm9ib2R5", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
t.Fatalf("unissued challenge = %v, want ErrPasskeyChallengeInvalid", err)
}
if sd, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMQ", now); err != nil || string(sd) != "s1" {
t.Fatalf("earlier ceremony = %q, %v; want s1 (a later begin must not cancel it)", sd, err)
}
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMQ", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
t.Fatalf("replay = %v, want ErrPasskeyChallengeInvalid", err)
}
if sd, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMg", now); err != nil || string(sd) != "s2" {
t.Fatalf("later ceremony = %q, %v; want s2", sd, err)
}
// The same challenge under another purpose is a different door.
if err := add("c3", source, "Y2hhbGxlbmdlMw", "s3", now.Add(5*time.Minute)); err != nil {
t.Fatalf("add c3: %v", err)
}
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, "passkey_register", "Y2hhbGxlbmdlMw", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
t.Fatalf("other purpose = %v, want ErrPasskeyChallengeInvalid", err)
}
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMw", now.Add(6*time.Minute)); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
t.Fatalf("expired ceremony = %v, want ErrPasskeyChallengeInvalid", err)
}
// Other accounts' spent rows from the same source hold no slot: one expired, one
// redeemed. Two accounts, since an account's own begin reaps its spent rows.
other := newUser(t, "user", "pk-login-other")
if err := repo.AddPasskeyLoginChallenge(ctx, "ox-"+suffix(t), other.ID, purpose, source, "ZXhwaXJlZA", []byte("s"), now, now.Add(-time.Second)); err != nil {
t.Fatalf("other account's expired begin: %v", err)
}
third := newUser(t, "user", "pk-login-third")
if err := repo.AddPasskeyLoginChallenge(ctx, "oc-"+suffix(t), third.ID, purpose, source, "cmVkZWVtZWQ", []byte("s"), now, now.Add(5*time.Minute)); err != nil {
t.Fatalf("third account's begin: %v", err)
}
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, third.ID, purpose, "cmVkZWVtZWQ", now); err != nil {
t.Fatalf("third account's finish: %v", err)
}
// Per-source bound: c3 is still live at now, so 31 more fill the allowance.
for i := 0; i < 31; i++ {
if err := add("cap", source, "Y2Fw", "s", now.Add(5*time.Minute)); err != nil {
t.Fatalf("add %d from the source: %v", i+2, err)
}
}
if err := add("over", source, "b3Zlcg", "s", now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) {
t.Fatalf("33rd live challenge from one source = %v, want ErrTooManyPasskeyChallenges", err)
}
if err := repo.AddPasskeyLoginChallenge(ctx, "x-"+suffix(t), other.ID, purpose, source, "eA", []byte("s"), now, now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) {
t.Fatalf("another account's begin from the full source = %v, want ErrTooManyPasskeyChallenges", err)
}
if err := add("elsewhere", "198.51.100."+suffix(t), "ZWxzZXdoZXJl", "s", now.Add(5*time.Minute)); err != nil {
t.Fatalf("begin from another source: %v", err)
}
// A redeemed ceremony frees its slot.
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "b3Zlcg", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
t.Fatalf("the refused begin left a row: %v", err)
}
var capID string
if err := db.QueryRow(`SELECT challenge FROM webauthn_challenges WHERE user_id = $1 AND source = $2 AND consumed_at IS NULL LIMIT 1`,
u.ID, source).Scan(&capID); err != nil {
t.Fatalf("pick a live challenge: %v", err)
}
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, capID, now); err != nil {
t.Fatalf("redeem one from the full source: %v", err)
}
if err := add("after", source, "YWZ0ZXI", "s", now.Add(5*time.Minute)); err != nil {
t.Fatalf("begin after one was redeemed = %v, want nil", err)
}
}
// The usernameless store holds each source to 32 live challenges and the whole
// table to 16384.
func TestDiscoverableChallengeBounds(t *testing.T) {
ctx := context.Background()
now := mustNow()
source := "2001:db8:" + suffix(t)[:4] + "::/48"
t.Cleanup(func() {
db.Exec(`DELETE FROM webauthn_discoverable_challenges WHERE source LIKE 'pgint-bulk-%' OR source = $1`, source) //nolint:errcheck
})
var ids []string
for i := 0; i < 32; i++ {
id := "disc-" + suffix(t)
if err := repo.CreateDiscoverableChallenge(ctx, id, source, []byte("s"), now, now.Add(5*time.Minute)); err != nil {
t.Fatalf("create %d: %v", i+1, err)
}
ids = append(ids, id)
}
if err := repo.CreateDiscoverableChallenge(ctx, "disc-over-"+suffix(t), source, []byte("s"), now, now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) {
t.Fatalf("33rd from one source = %v, want ErrTooManyPasskeyChallenges", err)
}
if err := repo.CreateDiscoverableChallenge(ctx, "disc-else-"+suffix(t), "pgint-bulk-else", []byte("s"), now, now.Add(5*time.Minute)); err != nil {
t.Fatalf("another source: %v", err)
}
if _, err := repo.ConsumeDiscoverableChallenge(ctx, ids[0], now); err != nil {
t.Fatalf("consume: %v", err)
}
if err := repo.CreateDiscoverableChallenge(ctx, "disc-after-"+suffix(t), source, []byte("s"), now, now.Add(5*time.Minute)); err != nil {
t.Fatalf("after a redeem freed a slot = %v, want nil", err)
}
// Fill the table to its store-wide bound from many sources, none of them full.
var live int
if err := db.QueryRow(`SELECT count(*) FROM webauthn_discoverable_challenges WHERE consumed_at IS NULL AND expires_at > $1`, now).Scan(&live); err != nil {
t.Fatalf("count: %v", err)
}
if _, err := db.Exec(`INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at, source)
SELECT 'bulk-' || $1 || '-' || i, '\x00'::bytea, $2, 'pgint-bulk-' || (i % 1000)
FROM generate_series(1, $3) AS i`, suffix(t), now.Add(5*time.Minute), 16384-live); err != nil {
t.Fatalf("bulk insert: %v", err)
}
if err := repo.CreateDiscoverableChallenge(ctx, "disc-full-"+suffix(t), "pgint-bulk-fresh", []byte("s"), now, now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) {
t.Fatalf("begin with the table full = %v, want ErrTooManyPasskeyChallenges", err)
}
}