@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/backend/api/openapi.yaml +83 -0
  2. package/backend/cmd/server/main.go +35 -2
  3. package/backend/db/migration/V005__idea.sql +49 -0
  4. package/backend/db/migration/V006__login_lock.sql +15 -0
  5. package/backend/db/migration/V007__login_attempt.sql +15 -0
  6. package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
  7. package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
  8. package/backend/go.mod +17 -3
  9. package/backend/go.sum +48 -7
  10. package/backend/internal/account/account_test.go +6 -6
  11. package/backend/internal/account/integration_test.go +45 -9
  12. package/backend/internal/account/keyboard.go +75 -0
  13. package/backend/internal/account/keyboard_test.go +43 -0
  14. package/backend/internal/account/logging_integration_test.go +1 -1
  15. package/backend/internal/account/password.go +125 -0
  16. package/backend/internal/account/validate.go +4 -21
  17. package/backend/internal/api/api.gen.go +224 -0
  18. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  19. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  20. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  21. package/backend/internal/auth/internal/store/session.go +15 -7
  22. package/backend/internal/auth/login.go +16 -4
  23. package/backend/internal/auth/login_integration_test.go +14 -1
  24. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  25. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  26. package/backend/internal/auth/login_test.go +15 -0
  27. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  28. package/backend/internal/auth/purge.go +93 -0
  29. package/backend/internal/auth/purge_integration_test.go +220 -0
  30. package/backend/internal/auth/purge_test.go +51 -0
  31. package/backend/internal/auth/session.go +0 -6
  32. package/backend/internal/auth/session_integration_test.go +3 -3
  33. package/backend/internal/auth/throttle.go +122 -0
  34. package/backend/internal/deadline/gate.go +80 -0
  35. package/backend/internal/deadline/gate_integration_test.go +67 -0
  36. package/backend/internal/deadline/gate_test.go +61 -0
  37. package/backend/internal/idea/create.go +352 -0
  38. package/backend/internal/idea/create_integration_test.go +650 -0
  39. package/backend/internal/idea/handler.go +148 -0
  40. package/backend/internal/idea/handler_integration_test.go +107 -0
  41. package/backend/internal/idea/integration_test.go +149 -0
  42. package/backend/internal/idea/internal/store/list.go +146 -0
  43. package/backend/internal/idea/internal/store/list_test.go +33 -0
  44. package/backend/internal/idea/internal/store/store.go +166 -0
  45. package/backend/internal/idea/list.go +166 -0
  46. package/backend/internal/idea/list_integration_test.go +252 -0
  47. package/backend/internal/idea/list_test.go +96 -0
  48. package/backend/internal/idea/service.go +53 -0
  49. package/backend/internal/server/api.go +17 -0
  50. package/backend/internal/server/public.go +1 -0
  51. package/backend/internal/server/slides.go +21 -0
  52. package/backend/internal/shared/config/config.go +44 -1
  53. package/backend/internal/shared/config/config_test.go +21 -13
  54. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  55. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  56. package/backend/internal/shared/dbtest/faults.go +162 -0
  57. package/backend/internal/shared/job/job.go +54 -0
  58. package/backend/internal/shared/job/job_test.go +105 -0
  59. package/backend/internal/shared/web/multipart.go +175 -0
  60. package/backend/internal/shared/web/multipart_test.go +169 -0
  61. package/backend/internal/shared/web/problem.go +1 -0
  62. package/backend/internal/shared/web/request.go +42 -1
  63. package/backend/internal/shared/web/web_test.go +36 -0
  64. package/backend/internal/storage/expectedfilename.go +43 -0
  65. package/backend/internal/storage/expectedfilename_test.go +16 -0
  66. package/backend/internal/storage/internal/store/pending.go +41 -0
  67. package/backend/internal/storage/minio.go +139 -0
  68. package/backend/internal/storage/minio_integration_test.go +87 -0
  69. package/backend/internal/storage/minio_test.go +84 -0
  70. package/backend/internal/storage/pending.go +144 -0
  71. package/backend/internal/storage/pending_integration_test.go +324 -0
  72. package/backend/internal/storage/pending_test.go +23 -0
  73. package/backend/internal/storage/validator.go +100 -0
  74. package/backend/internal/storage/validator_test.go +95 -0
  75. package/backend/test/contract/expected_filename_test.go +60 -0
  76. package/backend/test/contract/password_test.go +46 -0
  77. package/backend/test/matrix/load_test.go +12 -0
  78. package/backend/test/matrix/matrix_integration_test.go +86 -5
  79. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  80. package/docs/API/api-endpoints.md +1 -1
  81. package/docs/API/error-codes.md +1 -1
  82. package/frontend/src/app/App.tsx +11 -2
  83. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  84. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  85. package/frontend/src/shared/api/activity.ts +20 -0
  86. package/frontend/src/shared/api/http.ts +2 -0
  87. package/frontend/src/shared/api/index.ts +1 -0
  88. package/frontend/src/shared/i18n/locales/en.ts +1 -0
  89. package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
  90. package/package.json +1 -1
  91. package/testdata/expected-filename.json +93 -0
  92. package/testdata/password.json +425 -0
  93. package/testdata/permission-matrix.json +48 -0
  94. package/testdata/schema/expected-filename.schema.json +32 -0
  95. package/testdata/schema/password.schema.json +59 -0
  96. package/testdata/schema/permission-matrix.schema.json +21 -0
@@ -0,0 +1,53 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "time"
9
+ )
10
+
11
+ // IPPolicy is the per-IP sliding-window rule (PRD §6.5).
12
+ type IPPolicy struct {
13
+ MaxFailures int
14
+ WindowSeconds int
15
+ }
16
+
17
+ // RecordIPFailure stores one failed attempt of ip at database time.
18
+ func RecordIPFailure(ctx context.Context, db *sql.DB, ip string) error {
19
+ if _, err := db.ExecContext(ctx,
20
+ `INSERT INTO login_attempt (ip, attempted_at) VALUES (?, NOW(6))`, ip); err != nil {
21
+ return fmt.Errorf("record login attempt: %w", err)
22
+ }
23
+ return nil
24
+ }
25
+
26
+ // IPRetryAfter returns how long ip stays over the limit, 0 when it is not.
27
+ // The address is over the limit while at least MaxFailures failures lie inside
28
+ // the window; it is free again when the MaxFailures-th newest one leaves it,
29
+ // so that row's age decides (with exactly MaxFailures rows this is the oldest).
30
+ func IPRetryAfter(ctx context.Context, db *sql.DB, ip string, p IPPolicy) (time.Duration, error) {
31
+ var us int64
32
+ err := db.QueryRowContext(ctx,
33
+ `SELECT TIMESTAMPDIFF(MICROSECOND, NOW(6), attempted_at + INTERVAL ? SECOND)
34
+ FROM login_attempt
35
+ WHERE ip = ? AND attempted_at > NOW(6) - INTERVAL ? SECOND
36
+ ORDER BY attempted_at DESC
37
+ LIMIT 1 OFFSET ?`, p.WindowSeconds, ip, p.WindowSeconds, p.MaxFailures-1).Scan(&us)
38
+ if errors.Is(err, sql.ErrNoRows) {
39
+ return 0, nil
40
+ }
41
+ if err != nil {
42
+ return 0, fmt.Errorf("read login attempts: %w", err)
43
+ }
44
+ return time.Duration(us) * time.Microsecond, nil
45
+ }
46
+
47
+ // DeleteOldAttempts removes at most limit login_attempt rows outside the
48
+ // window; IPRetryAfter only reads rows inside it.
49
+ func DeleteOldAttempts(ctx context.Context, db *sql.DB, p IPPolicy, limit int) (int64, error) {
50
+ return deleteRows(ctx, db, "purge login attempts",
51
+ `DELETE FROM login_attempt WHERE attempted_at <= NOW(6) - INTERVAL ? SECOND LIMIT ?`,
52
+ p.WindowSeconds, limit)
53
+ }
@@ -0,0 +1,77 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "time"
9
+ )
10
+
11
+ // LockPolicy is the key-lock rule (PRD §6.5), in seconds and failures.
12
+ type LockPolicy struct {
13
+ MaxFailures int
14
+ WindowSeconds int // a failure older than this no longer counts as consecutive
15
+ LockSeconds int // fixed lock length
16
+ }
17
+
18
+ // RecordFailure counts one failed attempt for key in ONE statement on database
19
+ // time. The assignments run left to right in MariaDB, so later columns see the
20
+ // new fail_count. A lock that is still active is never extended or re-counted
21
+ // (a concurrent attempt that passed Check before the lock was set); an expired
22
+ // lock or a stale window starts again at 1.
23
+ func RecordFailure(ctx context.Context, db *sql.DB, key string, p LockPolicy) error {
24
+ _, err := db.ExecContext(ctx,
25
+ `INSERT INTO login_lock (lock_key, fail_count, last_fail_at, locked_until)
26
+ VALUES (?, 1, NOW(6), NULL)
27
+ ON DUPLICATE KEY UPDATE
28
+ fail_count = IF(locked_until > NOW(6), fail_count,
29
+ IF(locked_until IS NOT NULL OR last_fail_at <= NOW(6) - INTERVAL ? SECOND, 1, fail_count + 1)),
30
+ locked_until = CASE WHEN locked_until > NOW(6) THEN locked_until
31
+ WHEN fail_count >= ? THEN NOW(6) + INTERVAL ? SECOND
32
+ ELSE NULL END,
33
+ last_fail_at = NOW(6)`,
34
+ key, p.WindowSeconds, p.MaxFailures, p.LockSeconds)
35
+ if err != nil {
36
+ return fmt.Errorf("record login failure: %w", err)
37
+ }
38
+ return nil
39
+ }
40
+
41
+ // LockRemaining returns how long key stays locked, 0 when it is not locked.
42
+ func LockRemaining(ctx context.Context, db *sql.DB, key string) (time.Duration, error) {
43
+ var us sql.NullInt64
44
+ err := db.QueryRowContext(ctx,
45
+ `SELECT TIMESTAMPDIFF(MICROSECOND, NOW(6), locked_until)
46
+ FROM login_lock WHERE lock_key = ? AND locked_until > NOW(6)`, key).Scan(&us)
47
+ if errors.Is(err, sql.ErrNoRows) {
48
+ return 0, nil
49
+ }
50
+ if err != nil {
51
+ return 0, fmt.Errorf("read login lock: %w", err)
52
+ }
53
+ return time.Duration(us.Int64) * time.Microsecond, nil
54
+ }
55
+
56
+ // ClearFailures resets the counter after a successful login. An active lock is
57
+ // left alone (a success cannot shorten it).
58
+ func ClearFailures(ctx context.Context, db *sql.DB, key string) error {
59
+ _, err := db.ExecContext(ctx,
60
+ `DELETE FROM login_lock WHERE lock_key = ? AND (locked_until IS NULL OR locked_until <= NOW(6))`, key)
61
+ if err != nil {
62
+ return fmt.Errorf("clear login failures: %w", err)
63
+ }
64
+ return nil
65
+ }
66
+
67
+ // DeleteStaleLocks removes at most limit login_lock rows that no longer
68
+ // matter: no active lock and the last failure outside the window. For such a
69
+ // row RecordFailure would start again at 1, exactly as for a missing row.
70
+ func DeleteStaleLocks(ctx context.Context, db *sql.DB, p LockPolicy, limit int) (int64, error) {
71
+ return deleteRows(ctx, db, "purge login locks",
72
+ `DELETE FROM login_lock
73
+ WHERE last_fail_at <= NOW(6) - INTERVAL ? SECOND
74
+ AND (locked_until IS NULL OR locked_until <= NOW(6))
75
+ LIMIT ?`,
76
+ p.WindowSeconds, limit)
77
+ }
@@ -0,0 +1,42 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "time"
8
+ )
9
+
10
+ // ClaimThrottleAudit reports whether the caller may write the THROTTLED audit
11
+ // row of (scope, subject): true for the first throttled attempt of a window,
12
+ // false while an earlier claim is still running. A winning claim holds until
13
+ // database now + hold. The claim is atomic, so concurrent attempts on several
14
+ // replicas produce exactly one winner.
15
+ func ClaimThrottleAudit(ctx context.Context, db *sql.DB, scope, subject string, hold time.Duration) (bool, error) {
16
+ us := hold.Microseconds()
17
+ res, err := db.ExecContext(ctx,
18
+ `UPDATE login_throttle_audit SET audited_until = NOW(6) + INTERVAL ? MICROSECOND
19
+ WHERE scope = ? AND subject = ? AND audited_until <= NOW(6)`, us, scope, subject)
20
+ if err != nil {
21
+ return false, fmt.Errorf("claim throttle audit: %w", err)
22
+ }
23
+ if n, _ := res.RowsAffected(); n > 0 {
24
+ return true, nil
25
+ }
26
+ res, err = db.ExecContext(ctx,
27
+ `INSERT IGNORE INTO login_throttle_audit (scope, subject, audited_until)
28
+ VALUES (?, ?, NOW(6) + INTERVAL ? MICROSECOND)`, scope, subject, us)
29
+ if err != nil {
30
+ return false, fmt.Errorf("claim throttle audit: %w", err)
31
+ }
32
+ n, _ := res.RowsAffected()
33
+ return n > 0, nil
34
+ }
35
+
36
+ // DeleteExpiredThrottleAudits removes at most limit claims that have run out.
37
+ // ClaimThrottleAudit treats a missing row like an expired one (the next
38
+ // throttled attempt wins), so deleting it changes no outcome.
39
+ func DeleteExpiredThrottleAudits(ctx context.Context, db *sql.DB, limit int) (int64, error) {
40
+ return deleteRows(ctx, db, "purge throttle audits",
41
+ `DELETE FROM login_throttle_audit WHERE audited_until <= NOW(6) LIMIT ?`, limit)
42
+ }
@@ -99,19 +99,27 @@ func DeleteByUser(ctx context.Context, db *sql.DB, userID uuid.UUID) error {
99
99
  return nil
100
100
  }
101
101
 
102
- // DeleteExpired removes sessions past either timeout and returns how many.
103
- func DeleteExpired(ctx context.Context, db *sql.DB, l Limits) (int64, error) {
104
- res, err := db.ExecContext(ctx,
102
+ // DeleteExpired removes at most limit sessions past either timeout and
103
+ // returns how many. The condition is the exact complement of Touch's, so a row
104
+ // it deletes could no longer be validated anyway.
105
+ func DeleteExpired(ctx context.Context, db *sql.DB, l Limits, limit int) (int64, error) {
106
+ return deleteRows(ctx, db, "purge sessions",
105
107
  `DELETE FROM session
106
108
  WHERE last_access_at <= NOW(6) - INTERVAL ? SECOND
107
- OR created_at <= NOW(6) - INTERVAL ? SECOND`,
108
- l.IdleSeconds, l.AbsoluteSeconds)
109
+ OR created_at <= NOW(6) - INTERVAL ? SECOND
110
+ LIMIT ?`,
111
+ l.IdleSeconds, l.AbsoluteSeconds, limit)
112
+ }
113
+
114
+ // deleteRows runs one DELETE and returns the affected row count.
115
+ func deleteRows(ctx context.Context, db *sql.DB, what, query string, args ...any) (int64, error) {
116
+ res, err := db.ExecContext(ctx, query, args...)
109
117
  if err != nil {
110
- return 0, fmt.Errorf("purge sessions: %w", err)
118
+ return 0, fmt.Errorf("%s: %w", what, err)
111
119
  }
112
120
  n, err := res.RowsAffected()
113
121
  if err != nil {
114
- return 0, fmt.Errorf("purge sessions rows: %w", err)
122
+ return 0, fmt.Errorf("%s rows: %w", what, err)
115
123
  }
116
124
  return n, nil
117
125
  }
@@ -33,15 +33,20 @@ const (
33
33
 
34
34
  var empNoFormat = regexp.MustCompile(`^[0-9]{6}$`)
35
35
 
36
- // Throttle is the login throttling hook (PRD §6.5). B1b-04 implements it on
37
- // login_lock / login_attempt; until then NoThrottle is wired. Errors fail the
38
- // login closed (500): an unreachable counter must not disable the limit.
36
+ // Throttle is the login throttling hook (PRD §6.5); DBThrottle implements it on
37
+ // login_lock. Errors fail the login closed (500): an unreachable counter must
38
+ // not disable the limit.
39
39
  type Throttle interface {
40
40
  // Check runs before any password work. A positive retryAfter means the
41
41
  // attempt is throttled: it answers 429, runs no bcrypt and is not counted.
42
42
  // key is the submitted employee-ID string as typed; implementations strip
43
43
  // control characters and cut it to 32 characters.
44
44
  Check(ctx context.Context, key string, ip netip.Addr) (retryAfter time.Duration, err error)
45
+ // AuditOnce is asked after Check reported a wait. It returns true for the
46
+ // first throttled attempt of a window and false for the rest, so a throttled
47
+ // key or address is audited once per window (PRD §6.11). Its error is not
48
+ // fatal: the attempt is audited anyway.
49
+ AuditOnce(ctx context.Context, key string, ip netip.Addr) (bool, error)
45
50
  // Fail counts one failed attempt (also for unknown accounts).
46
51
  Fail(ctx context.Context, key string, ip netip.Addr) error
47
52
  // Succeed clears the key's counter.
@@ -54,6 +59,9 @@ type NoThrottle struct{}
54
59
  // Check implements Throttle.
55
60
  func (NoThrottle) Check(context.Context, string, netip.Addr) (time.Duration, error) { return 0, nil }
56
61
 
62
+ // AuditOnce implements Throttle.
63
+ func (NoThrottle) AuditOnce(context.Context, string, netip.Addr) (bool, error) { return true, nil }
64
+
57
65
  // Fail implements Throttle.
58
66
  func (NoThrottle) Fail(context.Context, string, netip.Addr) error { return nil }
59
67
 
@@ -122,7 +130,11 @@ func (l *Logins) Login(ctx context.Context, cmd LoginCommand, req Request) (Logg
122
130
  if retry > 0 {
123
131
  e := web.NewError(web.CodeLoginThrottled, "too many login attempts")
124
132
  e.RetryAfter = retry
125
- return LoggedIn{}, loginRefused(e, audit.Throttled, "throttled", cmd.EmpNo)
133
+ if first, err := l.Throttle.AuditOnce(ctx, cmd.EmpNo, req.IP); err != nil || first {
134
+ // An error audits anyway: a duplicate row beats a missing one.
135
+ return LoggedIn{}, loginRefused(e, audit.Throttled, "throttled", cmd.EmpNo)
136
+ }
137
+ return LoggedIn{}, e
126
138
  }
127
139
 
128
140
  var (
@@ -42,6 +42,9 @@ func (f *fakeThrottle) Check(_ context.Context, key string, _ netip.Addr) (time.
42
42
  f.lastKey = key
43
43
  return f.retry, f.err
44
44
  }
45
+ func (f *fakeThrottle) AuditOnce(context.Context, string, netip.Addr) (bool, error) {
46
+ return true, nil
47
+ }
45
48
  func (f *fakeThrottle) Fail(context.Context, string, netip.Addr) error {
46
49
  f.mu.Lock()
47
50
  defer f.mu.Unlock()
@@ -67,13 +70,23 @@ func newLoginEnv(t *testing.T) loginEnv {
67
70
  }
68
71
 
69
72
  func newLoginEnvLog(t *testing.T, log *slog.Logger) loginEnv {
73
+ t.Helper()
74
+ return newLoginEnvWith(t, log, nil)
75
+ }
76
+
77
+ // newLoginEnvWith wires mk(db) as the throttle; nil keeps the fake.
78
+ func newLoginEnvWith(t *testing.T, log *slog.Logger, mk func(*sql.DB) auth.Throttle) loginEnv {
70
79
  t.Helper()
71
80
  db := dbtest.New(t)
72
81
  auditSvc := audit.New(db, nil)
73
82
  errs := server.ErrorAdapter{Audit: auditSvc}
74
83
  sessions := auth.New(db)
75
84
  th := &fakeThrottle{}
76
- logins, err := auth.NewLogins(sessions, auditSvc, th)
85
+ var thr auth.Throttle = th
86
+ if mk != nil {
87
+ thr = mk(db)
88
+ }
89
+ logins, err := auth.NewLogins(sessions, auditSvc, thr)
77
90
  if err != nil {
78
91
  t.Fatal(err)
79
92
  }
@@ -0,0 +1,153 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "net/http/httptest"
7
+ "strconv"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
12
+ )
13
+
14
+ // loginFrom posts a login as peer ip (TCP address) with each empNo of its own
15
+ // so that the key lock never interferes with the IP window.
16
+ func (e loginEnv) loginFrom(t *testing.T, ip, empNo, pw string, hdr map[string]string) *httptest.ResponseRecorder {
17
+ t.Helper()
18
+ tok, ck := e.csrf(t, nil)
19
+ req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(creds(empNo, pw)))
20
+ req.Header.Set("Content-Type", "application/json")
21
+ req.Header.Set("X-CSRF-Token", tok)
22
+ req.RemoteAddr = ip + ":4000"
23
+ req.AddCookie(ck)
24
+ for k, v := range hdr {
25
+ req.Header.Set(k, v)
26
+ }
27
+ return e.do(req)
28
+ }
29
+
30
+ func (e loginEnv) ipFailures(t *testing.T, ip string) (n int) {
31
+ t.Helper()
32
+ if err := e.db.QueryRow(`SELECT COUNT(*) FROM login_attempt WHERE ip = ?`, ip).Scan(&n); err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ return n
36
+ }
37
+
38
+ // seed inserts n failures of ip, all age seconds old, without running logins
39
+ // (a real failure costs a bcrypt comparison, ~1 s under -race).
40
+ func (e loginEnv) seed(t *testing.T, ip string, n, ageSeconds int) {
41
+ t.Helper()
42
+ for range n {
43
+ if _, err := e.db.Exec(`INSERT INTO login_attempt (ip, attempted_at) VALUES (?, NOW(6) - INTERVAL ? SECOND)`, ip, ageSeconds); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ }
47
+ }
48
+
49
+ func TestLoginIP30thFailureIs401And31stIs429(t *testing.T) {
50
+ e := newLockEnv(t)
51
+ e.seed(t, "192.0.2.9", 29, 0)
52
+ // The 30th failure is a real login: still 401, and it is recorded.
53
+ if rec := e.loginFrom(t, "192.0.2.9", "100000", wrongPassword, nil); rec.Code != 401 {
54
+ t.Fatalf("30th: %d %s", rec.Code, rec.Body)
55
+ }
56
+ if n := e.ipFailures(t, "192.0.2.9"); n != 30 {
57
+ t.Fatalf("rows = %d", n)
58
+ }
59
+ rec := e.loginFrom(t, "192.0.2.9", "999999", wrongPassword, nil)
60
+ if rec.Code != 429 || code(t, rec) != "LOGIN_THROTTLED" {
61
+ t.Fatalf("31st: %d %s", rec.Code, rec.Body)
62
+ }
63
+ if got, _ := strconv.Atoi(rec.Header().Get("Retry-After")); got < 895 || got > 900 {
64
+ t.Errorf("Retry-After = %q, want ~900", rec.Header().Get("Retry-After"))
65
+ }
66
+ // Throttled attempts are not counted, for the IP or the key.
67
+ if n := e.ipFailures(t, "192.0.2.9"); n != 30 {
68
+ t.Errorf("rows after throttled attempt = %d", n)
69
+ }
70
+ if n, _ := e.lockRow(t, "999999"); n != 0 {
71
+ t.Errorf("key counted: %d", n)
72
+ }
73
+ }
74
+
75
+ func TestLoginIPCorrectPasswordIsThrottledToo(t *testing.T) {
76
+ e := newLockEnv(t)
77
+ e.addUser(t, "123456", "ADMIN", true)
78
+ e.seed(t, "192.0.2.9", 30, 0)
79
+ if rec := e.loginFrom(t, "192.0.2.9", "123456", password, nil); rec.Code != 429 {
80
+ t.Fatalf("admin from throttled IP: %d %s", rec.Code, rec.Body)
81
+ }
82
+ if rec := e.loginFrom(t, "192.0.2.10", "123456", password, nil); rec.Code != 200 {
83
+ t.Fatalf("other IP: %d %s", rec.Code, rec.Body)
84
+ }
85
+ }
86
+
87
+ func TestLoginIPWindowSlides(t *testing.T) {
88
+ e := newLockEnv(t)
89
+ e.seed(t, "192.0.2.9", 1, 14*60) // oldest: leaves the window in ~1 min
90
+ e.seed(t, "192.0.2.9", 29, 60)
91
+ rec := e.loginFrom(t, "192.0.2.9", "999999", wrongPassword, nil)
92
+ if rec.Code != 429 {
93
+ t.Fatalf("30 in window: %d", rec.Code)
94
+ }
95
+ if got, _ := strconv.Atoi(rec.Header().Get("Retry-After")); got < 50 || got > 61 {
96
+ t.Errorf("Retry-After = %d, want ~60 (oldest leaves in 1 min)", got)
97
+ }
98
+ // Push the oldest out: 29 in the window, so the next attempt is allowed (and counted).
99
+ if _, err := e.db.Exec(`UPDATE login_attempt SET attempted_at = attempted_at - INTERVAL 2 MINUTE ORDER BY attempted_at LIMIT 1`); err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ if rec := e.loginFrom(t, "192.0.2.9", "999999", wrongPassword, nil); rec.Code != 401 {
103
+ t.Fatalf("29 in window: %d %s", rec.Code, rec.Body)
104
+ }
105
+ if rec := e.loginFrom(t, "192.0.2.9", "999998", wrongPassword, nil); rec.Code != 429 {
106
+ t.Fatalf("back to 30: %d", rec.Code)
107
+ }
108
+ }
109
+
110
+ // With more than 30 rows the wait is until the 30th newest leaves, not the oldest.
111
+ func TestLoginIPRetryAfterUsesThirtiethNewest(t *testing.T) {
112
+ e := newLockEnv(t)
113
+ e.seed(t, "192.0.2.9", 5, 14*60) // five old ones
114
+ e.seed(t, "192.0.2.9", 30, 10) // 30 recent: the 30th newest is 10 s old
115
+ rec := e.loginFrom(t, "192.0.2.9", "999999", wrongPassword, nil)
116
+ if got, _ := strconv.Atoi(rec.Header().Get("Retry-After")); rec.Code != 429 || got < 880 || got > 891 {
117
+ t.Fatalf("%d Retry-After=%q, want ~890", rec.Code, rec.Header().Get("Retry-After"))
118
+ }
119
+ }
120
+
121
+ func TestLoginIPLimitIsPerAddress(t *testing.T) {
122
+ e := newLockEnv(t)
123
+ e.seed(t, "192.0.2.9", 30, 0)
124
+ e.seed(t, "2001:db8::1", 5, 0)
125
+ if rec := e.loginFrom(t, "2001:db8::1", "999999", wrongPassword, nil); rec.Code != 401 {
126
+ t.Fatalf("other address: %d", rec.Code)
127
+ }
128
+ }
129
+
130
+ func TestLoginIPUsesForwardedClientWhenTrusted(t *testing.T) {
131
+ e := newLockEnv(t)
132
+ e.h = web.TrustedProxies(1)(e.h)
133
+ hdr := map[string]string{"X-Forwarded-For": "10.9.9.9, 198.51.100.1"}
134
+ if rec := e.loginFrom(t, "192.0.2.9", "100000", wrongPassword, hdr); rec.Code != 401 {
135
+ t.Fatalf("failure: %d", rec.Code)
136
+ }
137
+ if n := e.ipFailures(t, "198.51.100.1"); n != 1 || e.ipFailures(t, "192.0.2.9") != 0 || e.ipFailures(t, "10.9.9.9") != 0 {
138
+ t.Fatalf("failure not recorded for the forwarded client only")
139
+ }
140
+ e.seed(t, "198.51.100.1", 29, 0)
141
+ if rec := e.loginFrom(t, "192.0.2.9", "999999", wrongPassword, hdr); rec.Code != 429 {
142
+ t.Fatalf("forwarded client not throttled: %d", rec.Code)
143
+ }
144
+ // Changing the spoofable prefix does not help; another proxy peer with another client is free.
145
+ if rec := e.loginFrom(t, "192.0.2.9", "999999", wrongPassword,
146
+ map[string]string{"X-Forwarded-For": "1.1.1.1, 198.51.100.1"}); rec.Code != 429 {
147
+ t.Fatalf("spoofed prefix: %d", rec.Code)
148
+ }
149
+ if rec := e.loginFrom(t, "192.0.2.9", "999999", wrongPassword,
150
+ map[string]string{"X-Forwarded-For": "198.51.100.2"}); rec.Code != 401 {
151
+ t.Fatalf("other client: %d", rec.Code)
152
+ }
153
+ }
@@ -0,0 +1,205 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "database/sql"
7
+ "log/slog"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
12
+ )
13
+
14
+ const wrongPassword = "Wrong-Horse-9"
15
+
16
+ func newLockEnv(t *testing.T) loginEnv {
17
+ t.Helper()
18
+ return newLoginEnvWith(t, slog.New(slog.DiscardHandler), func(db *sql.DB) auth.Throttle { return auth.DBThrottle{DB: db} })
19
+ }
20
+
21
+ func (e loginEnv) fail(t *testing.T, empNo string) {
22
+ t.Helper()
23
+ if rec := e.login(t, creds(empNo, wrongPassword)); rec.Code != 401 {
24
+ t.Fatalf("failure attempt: %d %s", rec.Code, rec.Body)
25
+ }
26
+ }
27
+
28
+ func (e loginEnv) lockRow(t *testing.T, key string) (count int, locked bool) {
29
+ t.Helper()
30
+ err := e.db.QueryRow(`SELECT fail_count, COALESCE(locked_until > NOW(6), 0) FROM login_lock WHERE lock_key = ?`, key).Scan(&count, &locked)
31
+ if err == sql.ErrNoRows {
32
+ return 0, false
33
+ }
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ return count, locked
38
+ }
39
+
40
+ // ageLock moves the key's lock state back by d minutes (time passing, DB clock).
41
+ func (e loginEnv) ageLock(t *testing.T, key string, minutes int) {
42
+ t.Helper()
43
+ if _, err := e.db.Exec(`UPDATE login_lock SET last_fail_at = last_fail_at - INTERVAL ? MINUTE,
44
+ locked_until = locked_until - INTERVAL ? MINUTE WHERE lock_key = ?`, minutes, minutes, key); err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ }
48
+
49
+ func TestLoginLockFifthFailureIs401SixthIs429(t *testing.T) {
50
+ e := newLockEnv(t)
51
+ e.addUser(t, "123456", "USER", true)
52
+ for range 5 {
53
+ e.fail(t, "123456")
54
+ }
55
+ if n, locked := e.lockRow(t, "123456"); n != 5 || !locked {
56
+ t.Fatalf("after 5th failure: count=%d locked=%v", n, locked)
57
+ }
58
+ rec := e.login(t, creds("123456", wrongPassword))
59
+ if rec.Code != 429 || code(t, rec) != "LOGIN_THROTTLED" {
60
+ t.Fatalf("6th attempt: %d %s", rec.Code, rec.Body)
61
+ }
62
+ if got := rec.Header().Get("Retry-After"); got != "900" && got != "899" {
63
+ t.Errorf("Retry-After = %q, want ~900", got)
64
+ }
65
+ }
66
+
67
+ func TestLoginLockCorrectPasswordDuringLockIs429AndDoesNotExtend(t *testing.T) {
68
+ e := newLockEnv(t)
69
+ e.addUser(t, "123456", "USER", true)
70
+ for range 5 {
71
+ e.fail(t, "123456")
72
+ }
73
+ e.ageLock(t, "123456", 10) // 5 minutes left
74
+ var before string
75
+ if err := e.db.QueryRow(`SELECT locked_until FROM login_lock WHERE lock_key = '123456'`).Scan(&before); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ for _, pw := range []string{password, wrongPassword} {
79
+ rec := e.login(t, creds("123456", pw))
80
+ if rec.Code != 429 {
81
+ t.Fatalf("during lock with %q: %d %s", pw, rec.Code, rec.Body)
82
+ }
83
+ if got := rec.Header().Get("Retry-After"); got != "300" && got != "299" {
84
+ t.Errorf("Retry-After = %q, want ~300", got)
85
+ }
86
+ }
87
+ var after string
88
+ if err := e.db.QueryRow(`SELECT locked_until FROM login_lock WHERE lock_key = '123456'`).Scan(&after); err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ if before != after {
92
+ t.Errorf("lock extended: %s → %s", before, after)
93
+ }
94
+ if e.sessions(t) != 0 {
95
+ t.Error("session opened during lock")
96
+ }
97
+ }
98
+
99
+ func TestLoginLockExpiresAfterFifteenMinutes(t *testing.T) {
100
+ e := newLockEnv(t)
101
+ e.addUser(t, "123456", "USER", true)
102
+ for range 5 {
103
+ e.fail(t, "123456")
104
+ }
105
+ e.ageLock(t, "123456", 16)
106
+ if rec := e.login(t, creds("123456", password)); rec.Code != 200 {
107
+ t.Fatalf("after lock expiry: %d %s", rec.Code, rec.Body)
108
+ }
109
+ if n, _ := e.lockRow(t, "123456"); n != 0 {
110
+ t.Errorf("counter not cleared by success: %d", n)
111
+ }
112
+ }
113
+
114
+ func TestLoginLockCounterRestartsAfterExpiredLock(t *testing.T) {
115
+ e := newLockEnv(t)
116
+ e.addUser(t, "123456", "USER", true)
117
+ for range 5 {
118
+ e.fail(t, "123456")
119
+ }
120
+ e.ageLock(t, "123456", 16)
121
+ e.fail(t, "123456")
122
+ if n, locked := e.lockRow(t, "123456"); n != 1 || locked {
123
+ t.Errorf("count=%d locked=%v, want 1 / unlocked", n, locked)
124
+ }
125
+ }
126
+
127
+ func TestLoginLockFailuresMustBeConsecutiveWithinWindow(t *testing.T) {
128
+ e := newLockEnv(t)
129
+ e.addUser(t, "123456", "USER", true)
130
+ for range 4 {
131
+ e.fail(t, "123456")
132
+ }
133
+ e.ageLock(t, "123456", 16) // the 4 failures are stale
134
+ e.fail(t, "123456")
135
+ if n, locked := e.lockRow(t, "123456"); n != 1 || locked {
136
+ t.Errorf("count=%d locked=%v, want 1 / unlocked", n, locked)
137
+ }
138
+ // Within the window the count keeps growing.
139
+ e.ageLock(t, "123456", 14)
140
+ for range 3 {
141
+ e.fail(t, "123456")
142
+ }
143
+ if n, _ := e.lockRow(t, "123456"); n != 4 {
144
+ t.Errorf("count = %d, want 4", n)
145
+ }
146
+ }
147
+
148
+ func TestLoginLockSuccessClearsCounter(t *testing.T) {
149
+ e := newLockEnv(t)
150
+ e.addUser(t, "123456", "USER", true)
151
+ for range 4 {
152
+ e.fail(t, "123456")
153
+ }
154
+ if rec := e.login(t, creds("123456", password)); rec.Code != 200 {
155
+ t.Fatalf("%d %s", rec.Code, rec.Body)
156
+ }
157
+ for range 4 {
158
+ e.fail(t, "123456")
159
+ }
160
+ if rec := e.login(t, creds("123456", password)); rec.Code != 200 {
161
+ t.Fatalf("counter not reset by success: %d %s", rec.Code, rec.Body)
162
+ }
163
+ }
164
+
165
+ func TestLoginLockCountsUnknownAndMalformedKeys(t *testing.T) {
166
+ e := newLockEnv(t)
167
+ for _, key := range []string{"999999", "not-an-id"} {
168
+ for range 5 {
169
+ e.fail(t, key)
170
+ }
171
+ if rec := e.login(t, creds(key, wrongPassword)); rec.Code != 429 {
172
+ t.Errorf("%q: 6th attempt = %d, want 429", key, rec.Code)
173
+ }
174
+ }
175
+ }
176
+
177
+ func TestLoginLockIsPerKeyAndDisabledAccountsCount(t *testing.T) {
178
+ e := newLockEnv(t)
179
+ e.addUser(t, "123456", "USER", true)
180
+ e.addUser(t, "654321", "USER", false)
181
+ for range 5 {
182
+ e.fail(t, "654321") // disabled: same 401, still counted
183
+ }
184
+ if rec := e.login(t, creds("654321", password)); rec.Code != 429 {
185
+ t.Errorf("disabled key: %d", rec.Code)
186
+ }
187
+ if rec := e.login(t, creds("123456", password)); rec.Code != 200 {
188
+ t.Errorf("other key affected: %d %s", rec.Code, rec.Body)
189
+ }
190
+ }
191
+
192
+ func TestLoginLockKeyIsNormalised(t *testing.T) {
193
+ e := newLockEnv(t)
194
+ long := strings.Repeat("9", 40)
195
+ for range 5 {
196
+ e.fail(t, long)
197
+ }
198
+ // Same first 32 characters share the key.
199
+ if rec := e.login(t, creds(strings.Repeat("9", 32)+"x", wrongPassword)); rec.Code != 429 {
200
+ t.Errorf("cut key: %d", rec.Code)
201
+ }
202
+ if n, _ := e.lockRow(t, strings.Repeat("9", 32)); n != 5 {
203
+ t.Errorf("count = %d, want 5", n)
204
+ }
205
+ }
@@ -66,3 +66,18 @@ func TestComparePassword(t *testing.T) {
66
66
  }
67
67
  }
68
68
  }
69
+
70
+ func TestLockKey(t *testing.T) {
71
+ tests := []struct{ in, want string }{
72
+ {"123456", "123456"},
73
+ {"12\x00\n34\t56\x7f", "123456"},
74
+ {strings.Repeat("a", 40), strings.Repeat("a", 32)},
75
+ {strings.Repeat("工", 33), strings.Repeat("工", 32)},
76
+ {"", ""},
77
+ }
78
+ for _, tc := range tests {
79
+ if got := LockKey(tc.in); got != tc.want {
80
+ t.Errorf("LockKey(%q) = %q, want %q", tc.in, got, tc.want)
81
+ }
82
+ }
83
+ }