@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,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
+ }
@@ -0,0 +1,220 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "bytes"
7
+ "context"
8
+ "database/sql"
9
+ "errors"
10
+ "fmt"
11
+ "log/slog"
12
+ "strings"
13
+ "sync"
14
+ "testing"
15
+ "time"
16
+
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
19
+ )
20
+
21
+ // purgeFixture seeds every purged table with rows that must stay and rows that
22
+ // must go. Each "dead" group is larger than the batch size so the drain loop
23
+ // runs several DELETEs.
24
+ type purgeFixture struct {
25
+ liveSessions []string
26
+ deadSessions int
27
+ }
28
+
29
+ func seedPurge(t *testing.T, db *sql.DB, s *auth.Sessions) purgeFixture {
30
+ t.Helper()
31
+ ctx := context.Background()
32
+ exec := func(q string, args ...any) {
33
+ t.Helper()
34
+ if _, err := db.Exec(q, args...); err != nil {
35
+ t.Fatalf("%s: %v", q, err)
36
+ }
37
+ }
38
+ var f purgeFixture
39
+ u := addUser(t, db, "200001", "USER", true)
40
+ for i := range 3 {
41
+ id, err := s.Create(ctx, u)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if i == 0 { // inside both timeouts, but close to both
46
+ age(t, db, id, 7*time.Hour+50*time.Minute, 29*time.Minute)
47
+ }
48
+ f.liveSessions = append(f.liveSessions, id)
49
+ }
50
+ for i := range 7 {
51
+ id, err := s.Create(ctx, u)
52
+ if err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ if i%2 == 0 {
56
+ age(t, db, id, time.Hour, 31*time.Minute) // idle timeout
57
+ } else {
58
+ age(t, db, id, 8*time.Hour+time.Minute, 0) // absolute timeout
59
+ }
60
+ f.deadSessions++
61
+ }
62
+
63
+ lock := func(key string, lastFailAgo time.Duration, lockedUntil string) {
64
+ exec(`INSERT INTO login_lock (lock_key, fail_count, last_fail_at, locked_until)
65
+ VALUES (?, 5, NOW(6) - INTERVAL ? SECOND, `+lockedUntil+`)`, key, int(lastFailAgo.Seconds()))
66
+ }
67
+ lock("keep-recent", time.Minute, "NULL") // counting window still open
68
+ lock("keep-locked", 20*time.Minute, "NOW(6) + INTERVAL 5 MINUTE") // lock active
69
+ lock("keep-just-unlocked", time.Minute, "NOW(6) - INTERVAL 1 SECOND") // window still open
70
+ for i := range 5 {
71
+ lock(fmt.Sprintf("dead-stale-%d", i), 16*time.Minute, "NULL")
72
+ lock(fmt.Sprintf("dead-unlocked-%d", i), 31*time.Minute, "NOW(6) - INTERVAL 1 MINUTE")
73
+ }
74
+
75
+ for range 4 {
76
+ exec(`INSERT INTO login_attempt (ip, attempted_at) VALUES ('192.0.2.1', NOW(6) - INTERVAL 14 MINUTE)`)
77
+ }
78
+ for range 9 {
79
+ exec(`INSERT INTO login_attempt (ip, attempted_at) VALUES ('192.0.2.2', NOW(6) - INTERVAL 901 SECOND)`)
80
+ }
81
+
82
+ exec(`INSERT INTO login_throttle_audit VALUES ('ip', 'keep', NOW(6) + INTERVAL 1 MINUTE)`)
83
+ for i := range 5 {
84
+ exec(`INSERT INTO login_throttle_audit VALUES ('key', ?, NOW(6) - INTERVAL 1 SECOND)`, fmt.Sprintf("dead-%d", i))
85
+ }
86
+ return f
87
+ }
88
+
89
+ func count(t *testing.T, db *sql.DB, q string) (n int) {
90
+ t.Helper()
91
+ if err := db.QueryRow(q).Scan(&n); err != nil {
92
+ t.Fatalf("%s: %v", q, err)
93
+ }
94
+ return n
95
+ }
96
+
97
+ func assertPurged(t *testing.T, db *sql.DB, s *auth.Sessions, f purgeFixture) {
98
+ t.Helper()
99
+ if n := count(t, db, `SELECT COUNT(*) FROM session`); n != len(f.liveSessions) {
100
+ t.Errorf("sessions = %d, want %d", n, len(f.liveSessions))
101
+ }
102
+ for _, id := range f.liveSessions {
103
+ if _, ok, err := s.Validate(context.Background(), id); err != nil || !ok {
104
+ t.Errorf("live session lost: %v", err)
105
+ }
106
+ }
107
+ rows, err := db.Query(`SELECT lock_key FROM login_lock ORDER BY lock_key`)
108
+ if err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ defer func() { _ = rows.Close() }()
112
+ var keys []string
113
+ for rows.Next() {
114
+ var k string
115
+ if err := rows.Scan(&k); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ keys = append(keys, k)
119
+ }
120
+ if got := strings.Join(keys, ","); got != "keep-just-unlocked,keep-locked,keep-recent" {
121
+ t.Errorf("login_lock keys = %s", got)
122
+ }
123
+ if n := count(t, db, `SELECT COUNT(*) FROM login_attempt WHERE ip = '192.0.2.1'`); n != 4 {
124
+ t.Errorf("attempts inside window = %d, want 4", n)
125
+ }
126
+ if n := count(t, db, `SELECT COUNT(*) FROM login_attempt WHERE ip = '192.0.2.2'`); n != 0 {
127
+ t.Errorf("attempts outside window = %d, want 0", n)
128
+ }
129
+ if n := count(t, db, `SELECT COUNT(*) FROM login_throttle_audit WHERE subject = 'keep'`); n != 1 {
130
+ t.Errorf("active throttle claim lost")
131
+ }
132
+ if n := count(t, db, `SELECT COUNT(*) FROM login_throttle_audit`); n != 1 {
133
+ t.Errorf("throttle claims = %d, want 1", n)
134
+ }
135
+ }
136
+
137
+ func TestPurgeDeletesDeadRowsOnly(t *testing.T) {
138
+ db := dbtest.New(t)
139
+ s := auth.New(db)
140
+ f := seedPurge(t, db, s)
141
+ var buf bytes.Buffer
142
+ p := auth.Purger{DB: db, Log: slog.New(slog.NewTextHandler(&buf, nil)), Batch: 2}
143
+ if err := p.Purge(context.Background()); err != nil {
144
+ t.Fatal(err)
145
+ }
146
+ assertPurged(t, db, s, f)
147
+ for _, want := range []string{
148
+ "table=session deleted=7", "table=login_lock deleted=10",
149
+ "table=login_attempt deleted=9", "table=login_throttle_audit deleted=5",
150
+ } {
151
+ if !strings.Contains(buf.String(), want) {
152
+ t.Errorf("log lacks %q: %s", want, buf.String())
153
+ }
154
+ }
155
+
156
+ // Idempotent: a second pass finds nothing and logs nothing.
157
+ buf.Reset()
158
+ if err := p.Purge(context.Background()); err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ assertPurged(t, db, s, f)
162
+ if buf.Len() != 0 {
163
+ t.Errorf("second pass logged: %s", buf.String())
164
+ }
165
+ }
166
+
167
+ // Every replica runs the purge with no leader. Several runners on the same
168
+ // rows must neither fail nor delete a row that is still live.
169
+ func TestPurgeConcurrentRunnersDoNotConflict(t *testing.T) {
170
+ db := dbtest.New(t)
171
+ s := auth.New(db)
172
+ f := seedPurge(t, db, s)
173
+ const runners = 4
174
+ var wg sync.WaitGroup
175
+ errs := make(chan error, runners)
176
+ start := make(chan struct{})
177
+ for range runners {
178
+ wg.Go(func() {
179
+ <-start
180
+ p := auth.Purger{DB: db, Log: slog.New(slog.DiscardHandler), Batch: 1}
181
+ errs <- p.Purge(context.Background())
182
+ })
183
+ }
184
+ close(start)
185
+ wg.Wait()
186
+ close(errs)
187
+ for err := range errs {
188
+ if err != nil {
189
+ t.Errorf("runner failed: %v", err)
190
+ }
191
+ }
192
+ assertPurged(t, db, s, f)
193
+ }
194
+
195
+ func TestPurgeStopsOnCancel(t *testing.T) {
196
+ db := dbtest.New(t)
197
+ ctx, cancel := context.WithCancel(context.Background())
198
+ cancel()
199
+ if err := (auth.Purger{DB: db, Log: slog.New(slog.DiscardHandler)}).Purge(ctx); !errors.Is(err, context.Canceled) {
200
+ t.Fatalf("err = %v, want context.Canceled", err)
201
+ }
202
+ }
203
+
204
+ // A database error on one table is returned, but the other tables still get
205
+ // their turn and each failure is logged.
206
+ func TestPurgeReportsDatabaseErrors(t *testing.T) {
207
+ db, err := sql.Open("mysql", dbtest.DSN("127.0.0.1:1"))
208
+ if err != nil {
209
+ t.Fatal(err)
210
+ }
211
+ _ = db.Close()
212
+ var buf bytes.Buffer
213
+ err = (auth.Purger{DB: db, Log: slog.New(slog.NewTextHandler(&buf, nil))}).Purge(context.Background())
214
+ if err == nil || !strings.Contains(err.Error(), "purge sessions") {
215
+ t.Fatalf("err = %v", err)
216
+ }
217
+ if n := strings.Count(buf.String(), "purge failed"); n != 4 {
218
+ t.Errorf("logged %d failures, want 4: %s", n, buf.String())
219
+ }
220
+ }
@@ -0,0 +1,51 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "testing"
8
+ )
9
+
10
+ func TestDrainRepeatsUntilShortBatch(t *testing.T) {
11
+ batches := []int64{3, 3, 1, 99}
12
+ var calls int
13
+ n, err := drain(context.Background(), nil, 3, func(_ context.Context, _ *sql.DB, limit int) (int64, error) {
14
+ if limit != 3 {
15
+ t.Fatalf("limit = %d", limit)
16
+ }
17
+ calls++
18
+ return batches[calls-1], nil
19
+ })
20
+ if err != nil || n != 7 || calls != 3 {
21
+ t.Fatalf("drain = %d, %v after %d calls; want 7 after 3", n, err, calls)
22
+ }
23
+ }
24
+
25
+ func TestDrainStopsOnError(t *testing.T) {
26
+ boom := errors.New("boom")
27
+ var calls int
28
+ n, err := drain(context.Background(), nil, 2, func(context.Context, *sql.DB, int) (int64, error) {
29
+ calls++
30
+ if calls == 2 {
31
+ return 0, boom
32
+ }
33
+ return 2, nil
34
+ })
35
+ if !errors.Is(err, boom) || n != 2 || calls != 2 {
36
+ t.Fatalf("drain = %d, %v after %d calls", n, err, calls)
37
+ }
38
+ }
39
+
40
+ func TestDrainStopsOnCancel(t *testing.T) {
41
+ ctx, cancel := context.WithCancel(context.Background())
42
+ var calls int
43
+ n, err := drain(ctx, nil, 1, func(context.Context, *sql.DB, int) (int64, error) {
44
+ calls++
45
+ cancel()
46
+ return 1, nil
47
+ })
48
+ if !errors.Is(err, context.Canceled) || n != 1 || calls != 1 {
49
+ t.Fatalf("drain = %d, %v after %d calls", n, err, calls)
50
+ }
51
+ }
@@ -106,12 +106,6 @@ func (s *Sessions) RevokeAll(ctx context.Context, userID uuid.UUID) error {
106
106
  return store.DeleteByUser(ctx, s.DB, userID)
107
107
  }
108
108
 
109
- // Purge deletes expired sessions and returns how many; a background ticker
110
- // calls it. Validation never depends on it: expired rows already fail Touch.
111
- func (s *Sessions) Purge(ctx context.Context) (int64, error) {
112
- return store.DeleteExpired(ctx, s.DB, limits)
113
- }
114
-
115
109
  // Binding returns SHA-256 of the session ID, the value the CSRF token is bound
116
110
  // to (D40). ok is false for a malformed ID.
117
111
  func Binding(id string) (b []byte, ok bool) { return parse(id) }
@@ -7,6 +7,7 @@ import (
7
7
  "crypto/sha256"
8
8
  "database/sql"
9
9
  "encoding/base64"
10
+ "log/slog"
10
11
  "testing"
11
12
  "time"
12
13
 
@@ -216,9 +217,8 @@ func TestSessions(t *testing.T) {
216
217
  live, idle, old := create(t, u), create(t, u), create(t, u)
217
218
  age(t, db, idle, time.Hour, time.Hour)
218
219
  age(t, db, old, 9*time.Hour, 0)
219
- n, err := s.Purge(ctx)
220
- if err != nil || n < 2 {
221
- t.Fatalf("purged %d, %v; want at least 2", n, err)
220
+ if err := (auth.Purger{DB: db, Log: slog.New(slog.DiscardHandler)}).Purge(ctx); err != nil {
221
+ t.Fatal(err)
222
222
  }
223
223
  if !valid(t, live) {
224
224
  t.Fatal("live session purged")
@@ -0,0 +1,122 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "net/netip"
7
+ "strings"
8
+ "time"
9
+ "unicode"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth/internal/store"
12
+ )
13
+
14
+ const (
15
+ loginMaxFailures = 5
16
+ loginWindow = 15 * time.Minute
17
+ loginLockFor = 15 * time.Minute
18
+ loginKeyMax = 32
19
+
20
+ ipMaxFailures = 30
21
+ ipWindow = 15 * time.Minute
22
+ )
23
+
24
+ // DBThrottle is the Throttle over the login_lock table: 5 consecutive
25
+ // failures, each within 15 minutes of the previous one, lock the submitted
26
+ // employee-ID string for a fixed 15 minutes (PRD §6.5, OI-12). The 5th failure
27
+ // still answers 401; the lock shows from the 6th attempt. The counters live in
28
+ // MariaDB so all replicas share them, and every time is database time.
29
+ //
30
+ // It also applies the per-IP limit: 30 failures from one address inside a
31
+ // sliding 15-minute window answer 429 until the 30th newest failure leaves the
32
+ // window (login_attempt). A throttled attempt is neither counted for the key
33
+ // nor for the IP. An invalid (zero) ip skips the IP window.
34
+ type DBThrottle struct{ DB *sql.DB }
35
+
36
+ var lockPolicy = store.LockPolicy{
37
+ MaxFailures: loginMaxFailures,
38
+ WindowSeconds: int(loginWindow / time.Second),
39
+ LockSeconds: int(loginLockFor / time.Second),
40
+ }
41
+
42
+ var ipPolicy = store.IPPolicy{MaxFailures: ipMaxFailures, WindowSeconds: int(ipWindow / time.Second)}
43
+
44
+ // LockKey normalises the submitted employee-ID string: control characters are
45
+ // removed and the rest is cut to 32 characters.
46
+ func LockKey(s string) string {
47
+ s = strings.Map(func(r rune) rune {
48
+ if unicode.IsControl(r) {
49
+ return -1
50
+ }
51
+ return r
52
+ }, s)
53
+ if r := []rune(s); len(r) > loginKeyMax {
54
+ s = string(r[:loginKeyMax])
55
+ }
56
+ return s
57
+ }
58
+
59
+ // Check implements Throttle. Both limits apply; the longer wait is returned.
60
+ func (t DBThrottle) Check(ctx context.Context, key string, ip netip.Addr) (time.Duration, error) {
61
+ keyWait, err := store.LockRemaining(ctx, t.DB, LockKey(key))
62
+ if err != nil {
63
+ return 0, err
64
+ }
65
+ if !ip.IsValid() {
66
+ return keyWait, nil
67
+ }
68
+ ipWait, err := store.IPRetryAfter(ctx, t.DB, ipString(ip), ipPolicy)
69
+ if err != nil {
70
+ return 0, err
71
+ }
72
+ return max(keyWait, ipWait), nil
73
+ }
74
+
75
+ // AuditOnce implements Throttle: the first throttled attempt of a lock or IP
76
+ // window claims the audit row; later ones in the same window do not. When both
77
+ // limits apply, the attempt is audited if either window is new.
78
+ func (t DBThrottle) AuditOnce(ctx context.Context, key string, ip netip.Addr) (bool, error) {
79
+ var first bool
80
+ if wait, err := store.LockRemaining(ctx, t.DB, LockKey(key)); err != nil {
81
+ return false, err
82
+ } else if wait > 0 {
83
+ won, err := store.ClaimThrottleAudit(ctx, t.DB, "key", LockKey(key), wait)
84
+ if err != nil {
85
+ return false, err
86
+ }
87
+ first = first || won
88
+ }
89
+ if ip.IsValid() {
90
+ wait, err := store.IPRetryAfter(ctx, t.DB, ipString(ip), ipPolicy)
91
+ if err != nil {
92
+ return false, err
93
+ }
94
+ if wait > 0 {
95
+ won, err := store.ClaimThrottleAudit(ctx, t.DB, "ip", ipString(ip), wait)
96
+ if err != nil {
97
+ return false, err
98
+ }
99
+ first = first || won
100
+ }
101
+ }
102
+ return first, nil
103
+ }
104
+
105
+ // Fail implements Throttle.
106
+ func (t DBThrottle) Fail(ctx context.Context, key string, ip netip.Addr) error {
107
+ if err := store.RecordFailure(ctx, t.DB, LockKey(key), lockPolicy); err != nil {
108
+ return err
109
+ }
110
+ if !ip.IsValid() {
111
+ return nil
112
+ }
113
+ return store.RecordIPFailure(ctx, t.DB, ipString(ip))
114
+ }
115
+
116
+ // Succeed implements Throttle. The IP window is not cleared: its failures stay
117
+ // until they leave the window.
118
+ func (t DBThrottle) Succeed(ctx context.Context, key string) error {
119
+ return store.ClearFailures(ctx, t.DB, LockKey(key))
120
+ }
121
+
122
+ func ipString(ip netip.Addr) string { return ip.Unmap().WithZone("").String() }
@@ -0,0 +1,80 @@
1
+ package deadline
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
10
+ )
11
+
12
+ // SubmissionGate is the one place that decides whether a state-limited write
13
+ // may proceed (PRD 5, D34, OI-18).
14
+ type SubmissionGate interface {
15
+ // Precheck is read-only. It runs before the request body is read, to keep
16
+ // the error precedence of PRD 6.1, and never replaces AssertOpenInTx.
17
+ Precheck(ctx context.Context, actor security.Principal) error
18
+ // AssertOpenInTx is the authoritative check. The tx parameter makes it
19
+ // impossible to call outside a write transaction (ADR G9).
20
+ AssertOpenInTx(ctx context.Context, tx *sql.Tx, actor security.Principal) error
21
+ }
22
+
23
+ // Gate implements SubmissionGate. Admins are exempt (PRD 5, OI-18).
24
+ type Gate struct{ Store Store }
25
+
26
+ var _ SubmissionGate = Gate{}
27
+
28
+ // Precheck reads the deadline outside any transaction.
29
+ func (g Gate) Precheck(ctx context.Context, actor security.Principal) error {
30
+ if actor.IsAdmin() {
31
+ return nil
32
+ }
33
+ snap, err := g.Store.Snapshot(ctx)
34
+ if err != nil {
35
+ return err
36
+ }
37
+ return check(snap.State())
38
+ }
39
+
40
+ // AssertOpenInTx reads the deadline and NOW(6) in one statement on tx, without
41
+ // a lock (PRD 5): a write racing a deadline change may land on either side.
42
+ func (g Gate) AssertOpenInTx(ctx context.Context, tx *sql.Tx, actor security.Principal) error {
43
+ if actor.IsAdmin() {
44
+ return nil
45
+ }
46
+ snap, err := TxStore{Tx: tx}.Snapshot(ctx)
47
+ if err != nil {
48
+ return err
49
+ }
50
+ return check(snap.State())
51
+ }
52
+
53
+ // TxStore reads the deadline through an open transaction.
54
+ type TxStore struct{ Tx *sql.Tx }
55
+
56
+ // Snapshot returns the deadline and NOW(6) from one statement.
57
+ func (s TxStore) Snapshot(ctx context.Context) (Snapshot, error) {
58
+ var snap Snapshot
59
+ var d sql.NullTime
60
+ err := s.Tx.QueryRowContext(ctx, `SELECT deadline, NOW(6) FROM contest_setting WHERE id = 1`).Scan(&d, &snap.Now)
61
+ if err != nil {
62
+ return Snapshot{}, fmt.Errorf("read contest_setting: %w", err)
63
+ }
64
+ if d.Valid {
65
+ snap.Deadline = &d.Time
66
+ }
67
+ return snap, nil
68
+ }
69
+
70
+ // check maps a state to the 403 a User write gets in it.
71
+ func check(s State) error {
72
+ switch s {
73
+ case "OPEN":
74
+ return nil
75
+ case "NOT_OPEN":
76
+ return web.NewError(web.CodeSubmissionNotOpen, "submissions are not open yet")
77
+ default:
78
+ return web.NewError(web.CodeSubmissionClosed, "the submission deadline has passed")
79
+ }
80
+ }