@johnnyboy11234/esg-idea-contest 0.2.4 → 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.
- package/backend/api/openapi.yaml +83 -0
- package/backend/cmd/server/main.go +35 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +125 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +224 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +352 -0
- package/backend/internal/idea/create_integration_test.go +650 -0
- package/backend/internal/idea/handler.go +148 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +53 -0
- package/backend/internal/server/api.go +17 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/file_test.go +52 -0
- package/backend/test/matrix/load_test.go +62 -0
- package/backend/test/matrix/matrix_integration_test.go +291 -0
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/frontend/public/auth-hero-login.jpg +0 -0
- package/frontend/public/auth-hero-register.jpg +0 -0
- package/frontend/src/app/App.test.tsx +2 -42
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/index.css +4 -4
- package/frontend/src/app/layouts.tsx +7 -135
- package/frontend/src/app/pages/JoinRequests.tsx +2 -2
- package/frontend/src/app/pages/NotFound.tsx +1 -1
- package/frontend/src/app/pages/admin.tsx +1 -1
- package/frontend/src/app/pages/auth.tsx +53 -25
- package/frontend/src/app/pages/ideas.tsx +7 -7
- package/frontend/src/app/pages/login.test.tsx +3 -54
- package/frontend/src/app/pages/register.test.tsx +1 -1
- package/frontend/src/app/router.tsx +2 -6
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +8 -1
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/i18n/locales/en.ts +1 -0
- package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +425 -0
- package/testdata/permission-matrix.json +157 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- package/testdata/schema/permission-matrix.schema.json +73 -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
|
-
|
|
220
|
-
|
|
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
|
+
}
|