@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2

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 (138) hide show
  1. package/backend/api/openapi.yaml +119 -0
  2. package/backend/cmd/server/main.go +36 -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/internal/passwords/NOTICE +36 -0
  13. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  14. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  15. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  16. package/backend/internal/account/keyboard.go +75 -0
  17. package/backend/internal/account/keyboard_test.go +43 -0
  18. package/backend/internal/account/logging_integration_test.go +1 -1
  19. package/backend/internal/account/password.go +131 -0
  20. package/backend/internal/account/sequence.go +54 -0
  21. package/backend/internal/account/sequence_test.go +38 -0
  22. package/backend/internal/account/validate.go +4 -21
  23. package/backend/internal/api/api.gen.go +262 -0
  24. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  25. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  26. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  27. package/backend/internal/auth/internal/store/session.go +15 -7
  28. package/backend/internal/auth/login.go +16 -4
  29. package/backend/internal/auth/login_integration_test.go +14 -1
  30. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  31. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  32. package/backend/internal/auth/login_test.go +15 -0
  33. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  34. package/backend/internal/auth/purge.go +93 -0
  35. package/backend/internal/auth/purge_integration_test.go +220 -0
  36. package/backend/internal/auth/purge_test.go +51 -0
  37. package/backend/internal/auth/session.go +0 -6
  38. package/backend/internal/auth/session_integration_test.go +3 -3
  39. package/backend/internal/auth/throttle.go +122 -0
  40. package/backend/internal/deadline/gate.go +80 -0
  41. package/backend/internal/deadline/gate_integration_test.go +67 -0
  42. package/backend/internal/deadline/gate_test.go +61 -0
  43. package/backend/internal/idea/create.go +356 -0
  44. package/backend/internal/idea/create_integration_test.go +680 -0
  45. package/backend/internal/idea/download.go +193 -0
  46. package/backend/internal/idea/download_integration_test.go +311 -0
  47. package/backend/internal/idea/download_test.go +25 -0
  48. package/backend/internal/idea/handler.go +151 -0
  49. package/backend/internal/idea/handler_integration_test.go +107 -0
  50. package/backend/internal/idea/integration_test.go +149 -0
  51. package/backend/internal/idea/internal/store/list.go +146 -0
  52. package/backend/internal/idea/internal/store/list_test.go +33 -0
  53. package/backend/internal/idea/internal/store/store.go +166 -0
  54. package/backend/internal/idea/list.go +166 -0
  55. package/backend/internal/idea/list_integration_test.go +252 -0
  56. package/backend/internal/idea/list_test.go +96 -0
  57. package/backend/internal/idea/service.go +54 -0
  58. package/backend/internal/server/api.go +22 -0
  59. package/backend/internal/server/binding.go +4 -0
  60. package/backend/internal/server/public.go +1 -0
  61. package/backend/internal/server/server.go +20 -1
  62. package/backend/internal/server/slides.go +21 -0
  63. package/backend/internal/server/slides_test.go +82 -0
  64. package/backend/internal/shared/config/config.go +44 -1
  65. package/backend/internal/shared/config/config_test.go +21 -13
  66. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  67. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  68. package/backend/internal/shared/dbtest/faults.go +162 -0
  69. package/backend/internal/shared/job/job.go +54 -0
  70. package/backend/internal/shared/job/job_test.go +105 -0
  71. package/backend/internal/shared/web/multipart.go +175 -0
  72. package/backend/internal/shared/web/multipart_test.go +169 -0
  73. package/backend/internal/shared/web/problem.go +1 -0
  74. package/backend/internal/shared/web/request.go +42 -1
  75. package/backend/internal/shared/web/transport.go +4 -3
  76. package/backend/internal/shared/web/web_test.go +36 -0
  77. package/backend/internal/storage/expectedfilename.go +43 -0
  78. package/backend/internal/storage/expectedfilename_test.go +16 -0
  79. package/backend/internal/storage/internal/store/pending.go +41 -0
  80. package/backend/internal/storage/minio.go +139 -0
  81. package/backend/internal/storage/minio_integration_test.go +87 -0
  82. package/backend/internal/storage/minio_test.go +84 -0
  83. package/backend/internal/storage/pending.go +144 -0
  84. package/backend/internal/storage/pending_integration_test.go +324 -0
  85. package/backend/internal/storage/pending_test.go +23 -0
  86. package/backend/internal/storage/validator.go +100 -0
  87. package/backend/internal/storage/validator_test.go +95 -0
  88. package/backend/test/arch/arch_test.go +5 -1
  89. package/backend/test/contract/expected_filename_test.go +60 -0
  90. package/backend/test/contract/password_test.go +46 -0
  91. package/backend/test/matrix/load_test.go +12 -0
  92. package/backend/test/matrix/matrix_integration_test.go +92 -5
  93. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  94. package/docs/API/api-endpoints.md +1 -1
  95. package/docs/API/error-codes.md +1 -1
  96. package/docs/Architecture/Architecture.md +1 -1
  97. package/docs/Architecture/Backend.md +2 -1
  98. package/frontend/Dockerfile +2 -0
  99. package/frontend/package-lock.json +1 -0
  100. package/frontend/package.json +1 -0
  101. package/frontend/src/app/App.tsx +11 -2
  102. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  103. package/frontend/src/app/pages/ideas.tsx +23 -9
  104. package/frontend/src/app/pages/login.test.tsx +38 -1
  105. package/frontend/src/app/pages/register.test.tsx +32 -0
  106. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  107. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  108. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  109. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  110. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  111. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  112. package/frontend/src/features/auth/common-passwords.ts +31 -0
  113. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  114. package/frontend/src/features/auth/password-policy.ts +168 -0
  115. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  116. package/frontend/src/features/auth/useCountdown.ts +28 -0
  117. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  118. package/frontend/src/features/idea/create.ts +123 -0
  119. package/frontend/src/shared/api/activity.ts +20 -0
  120. package/frontend/src/shared/api/http.ts +2 -0
  121. package/frontend/src/shared/api/index.ts +1 -0
  122. package/frontend/src/shared/api/types.generated.ts +319 -1
  123. package/frontend/src/shared/i18n/locales/en.ts +46 -4
  124. package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
  125. package/frontend/src/shared/slides/index.ts +74 -0
  126. package/frontend/src/shared/slides/slides.test.ts +51 -0
  127. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  128. package/frontend/src/shared/ui/native-select.tsx +27 -0
  129. package/frontend/src/shared/ui/textarea.tsx +19 -0
  130. package/frontend/src/test-setup.ts +37 -2
  131. package/frontend/vite.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/testdata/expected-filename.json +93 -0
  134. package/testdata/password.json +582 -0
  135. package/testdata/permission-matrix.json +71 -0
  136. package/testdata/schema/expected-filename.schema.json +32 -0
  137. package/testdata/schema/password.schema.json +59 -0
  138. package/testdata/schema/permission-matrix.schema.json +22 -1
@@ -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
+ }
@@ -0,0 +1,88 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "strconv"
7
+ "testing"
8
+ )
9
+
10
+ func (e loginEnv) throttledRows(t *testing.T) int {
11
+ t.Helper()
12
+ n := 0
13
+ for _, r := range e.audits(t) {
14
+ if r.Outcome == "THROTTLED" {
15
+ n++
16
+ }
17
+ }
18
+ return n
19
+ }
20
+
21
+ // A locked key answers 429 every time but is audited once per lock.
22
+ func TestLoginKeyLockAuditedOncePerWindow(t *testing.T) {
23
+ e := newLockEnv(t)
24
+ e.addUser(t, "123456", "USER", true)
25
+ for range 5 {
26
+ e.fail(t, "123456")
27
+ }
28
+ for i := range 4 {
29
+ if rec := e.login(t, creds("123456", password)); rec.Code != 429 {
30
+ t.Fatalf("attempt %d: %d", i, rec.Code)
31
+ }
32
+ }
33
+ if n := e.throttledRows(t); n != 1 {
34
+ t.Fatalf("THROTTLED rows in one lock = %d, want 1", n)
35
+ }
36
+ // Another key is its own window.
37
+ for range 5 {
38
+ e.fail(t, "654321")
39
+ }
40
+ e.login(t, creds("654321", wrongPassword))
41
+ if n := e.throttledRows(t); n != 2 {
42
+ t.Fatalf("THROTTLED rows for two keys = %d, want 2", n)
43
+ }
44
+ // After the lock expires and the key locks again, a new window is audited.
45
+ e.ageLock(t, "123456", 16)
46
+ if _, err := e.db.Exec(`UPDATE login_throttle_audit SET audited_until = audited_until - INTERVAL 16 MINUTE`); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ for range 5 {
50
+ e.fail(t, "123456")
51
+ }
52
+ e.login(t, creds("123456", wrongPassword))
53
+ e.login(t, creds("123456", wrongPassword))
54
+ if n := e.throttledRows(t); n != 3 {
55
+ t.Fatalf("THROTTLED rows after second lock = %d, want 3", n)
56
+ }
57
+ }
58
+
59
+ // The IP limit is audited once per window too, and a different key from the
60
+ // same address does not add a row.
61
+ func TestLoginIPLimitAuditedOncePerWindow(t *testing.T) {
62
+ e := newLockEnv(t)
63
+ e.seed(t, "192.0.2.9", 30, 0)
64
+ for _, k := range []string{"111111", "222222", "333333"} {
65
+ if rec := e.loginFrom(t, "192.0.2.9", k, wrongPassword, nil); rec.Code != 429 {
66
+ t.Fatalf("%s: %d", k, rec.Code)
67
+ }
68
+ }
69
+ if n := e.throttledRows(t); n != 1 {
70
+ t.Fatalf("THROTTLED rows = %d, want 1", n)
71
+ }
72
+ }
73
+
74
+ // Both limits at once: the larger Retry-After, and a new window on either side is audited.
75
+ func TestLoginBothLimitsLargerRetryAfter(t *testing.T) {
76
+ e := newLockEnv(t)
77
+ for range 5 {
78
+ e.fail(t, "123456")
79
+ }
80
+ e.seed(t, "192.0.2.9", 30, 10*60) // IP frees in ~5 min, key lock holds ~15 min
81
+ rec := e.login(t, creds("123456", wrongPassword))
82
+ if got, _ := strconv.Atoi(rec.Header().Get("Retry-After")); rec.Code != 429 || got < 890 || got > 900 {
83
+ t.Fatalf("%d Retry-After=%q, want ~900", rec.Code, rec.Header().Get("Retry-After"))
84
+ }
85
+ if n := e.throttledRows(t); n != 1 {
86
+ t.Fatalf("THROTTLED rows = %d, want 1", n)
87
+ }
88
+ }
@@ -0,0 +1,93 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "log/slog"
7
+ "time"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth/internal/store"
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
11
+ )
12
+
13
+ // Purge schedule (B1b-07). Expired rows already fail every check, so the purge
14
+ // only bounds table size; a few minutes of delay change nothing.
15
+ const (
16
+ PurgeInterval = 5 * time.Minute
17
+ PurgeJitter = time.Minute
18
+ purgeBatch = 1000
19
+ )
20
+
21
+ // Purger deletes dead rows of the session and throttle tables: expired
22
+ // sessions, login_lock rows without an active lock whose last failure left the
23
+ // window, login_attempt rows outside the IP window and expired
24
+ // login_throttle_audit claims. Each DELETE removes at most Batch rows, so row
25
+ // locks are held briefly, and repeats until a short batch.
26
+ //
27
+ // Every replica runs its own Purger and no leader is elected. That is safe:
28
+ // each condition only matches rows that every reader already ignores, a
29
+ // DELETE of a row that is already gone affects nothing, and two runners on the
30
+ // same rows only wait for each other's row locks. A deadlock or lock-wait
31
+ // timeout ends that table's turn without an error; the next run continues.
32
+ type Purger struct {
33
+ DB *sql.DB
34
+ Log *slog.Logger
35
+ Batch int // rows per DELETE; 0 means 1000
36
+ }
37
+
38
+ // Purge runs one pass over all four tables. The returned error is the first
39
+ // non-contention database error; the remaining tables are still purged.
40
+ func (p Purger) Purge(ctx context.Context) error {
41
+ batch := p.Batch
42
+ if batch <= 0 {
43
+ batch = purgeBatch
44
+ }
45
+ tables := []struct {
46
+ name string
47
+ del func(context.Context, *sql.DB, int) (int64, error)
48
+ }{
49
+ {"session", func(c context.Context, d *sql.DB, n int) (int64, error) { return store.DeleteExpired(c, d, limits, n) }},
50
+ {"login_lock", func(c context.Context, d *sql.DB, n int) (int64, error) {
51
+ return store.DeleteStaleLocks(c, d, lockPolicy, n)
52
+ }},
53
+ {"login_attempt", func(c context.Context, d *sql.DB, n int) (int64, error) {
54
+ return store.DeleteOldAttempts(c, d, ipPolicy, n)
55
+ }},
56
+ {"login_throttle_audit", store.DeleteExpiredThrottleAudits},
57
+ }
58
+ var first error
59
+ for _, t := range tables {
60
+ n, err := drain(ctx, p.DB, batch, t.del)
61
+ if ctx.Err() != nil {
62
+ return ctx.Err() // shutdown; not a failure
63
+ }
64
+ switch {
65
+ case err != nil && db.IsContention(err):
66
+ p.Log.Info("purge yielded to another runner", "table", t.name, "deleted", n)
67
+ case err != nil:
68
+ p.Log.Warn("purge failed", "table", t.name, "deleted", n, "error", err.Error())
69
+ if first == nil {
70
+ first = err
71
+ }
72
+ case n > 0:
73
+ p.Log.Info("purged", "table", t.name, "deleted", n)
74
+ }
75
+ }
76
+ return first
77
+ }
78
+
79
+ // drain repeats del until it deletes fewer than batch rows and returns the
80
+ // total.
81
+ func drain(ctx context.Context, d *sql.DB, batch int, del func(context.Context, *sql.DB, int) (int64, error)) (int64, error) {
82
+ var total int64
83
+ for {
84
+ n, err := del(ctx, d, batch)
85
+ total += n
86
+ if err != nil || n < int64(batch) {
87
+ return total, err
88
+ }
89
+ if err := ctx.Err(); err != nil {
90
+ return total, err
91
+ }
92
+ }
93
+ }