@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.
- package/backend/api/openapi.yaml +119 -0
- package/backend/cmd/server/main.go +36 -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/internal/passwords/NOTICE +36 -0
- package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
- package/backend/internal/account/internal/passwords/passwords.go +37 -0
- package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
- 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 +131 -0
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +262 -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 +356 -0
- package/backend/internal/idea/create_integration_test.go +680 -0
- package/backend/internal/idea/download.go +193 -0
- package/backend/internal/idea/download_integration_test.go +311 -0
- package/backend/internal/idea/download_test.go +25 -0
- package/backend/internal/idea/handler.go +151 -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 +54 -0
- package/backend/internal/server/api.go +22 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/server/slides_test.go +82 -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/transport.go +4 -3
- 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/arch/arch_test.go +5 -1
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/load_test.go +12 -0
- package/backend/test/matrix/matrix_integration_test.go +92 -5
- 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/docs/Architecture/Architecture.md +1 -1
- package/docs/Architecture/Backend.md +2 -1
- package/frontend/Dockerfile +2 -0
- package/frontend/package-lock.json +1 -0
- package/frontend/package.json +1 -0
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
- package/frontend/src/app/pages/ideas.tsx +23 -9
- package/frontend/src/app/pages/login.test.tsx +38 -1
- package/frontend/src/app/pages/register.test.tsx +32 -0
- 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 +21 -3
- package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
- package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
- package/frontend/src/features/auth/RegisterForm.tsx +11 -2
- package/frontend/src/features/auth/common-passwords.ts +31 -0
- package/frontend/src/features/auth/password-policy.test.ts +64 -0
- package/frontend/src/features/auth/password-policy.ts +168 -0
- package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
- package/frontend/src/features/auth/useCountdown.ts +28 -0
- package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
- package/frontend/src/features/idea/create.ts +123 -0
- 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/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +46 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
- package/frontend/src/shared/slides/index.ts +74 -0
- package/frontend/src/shared/slides/slides.test.ts +51 -0
- package/frontend/src/shared/ui/copy-button.tsx +52 -0
- package/frontend/src/shared/ui/native-select.tsx +27 -0
- package/frontend/src/shared/ui/textarea.tsx +19 -0
- package/frontend/src/test-setup.ts +37 -2
- package/frontend/vite.config.ts +3 -1
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +582 -0
- package/testdata/permission-matrix.json +71 -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 +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
|
+
}
|