@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,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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package deadline_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
12
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
13
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// TestGateAssertOpenInTx runs the authoritative check inside a real write
|
|
19
|
+
// transaction for NOT_OPEN, OPEN and CLOSED, for a User and an Admin.
|
|
20
|
+
func TestGateAssertOpenInTx(t *testing.T) {
|
|
21
|
+
ctx := context.Background()
|
|
22
|
+
sdb := dbtest.New(t)
|
|
23
|
+
g := deadline.Gate{Store: deadline.SQLStore{DB: sdb}}
|
|
24
|
+
user := security.Principal{Role: security.RoleUser}
|
|
25
|
+
admin := security.Principal{Role: security.RoleAdmin}
|
|
26
|
+
|
|
27
|
+
assert := func(actor security.Principal) error {
|
|
28
|
+
_, err := db.InTx(ctx, sdb, func(ctx context.Context, tx *sql.Tx) error {
|
|
29
|
+
return g.AssertOpenInTx(ctx, tx, actor)
|
|
30
|
+
})
|
|
31
|
+
return err
|
|
32
|
+
}
|
|
33
|
+
set := func(t *testing.T, expr string) {
|
|
34
|
+
t.Helper()
|
|
35
|
+
if _, err := sdb.ExecContext(ctx, "UPDATE contest_setting SET deadline = "+expr+" WHERE id = 1"); err != nil {
|
|
36
|
+
t.Fatal(err)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
code := func(err error) web.ErrorCode {
|
|
40
|
+
var e *web.Error
|
|
41
|
+
if errors.As(err, &e) {
|
|
42
|
+
return e.Code
|
|
43
|
+
}
|
|
44
|
+
return ""
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if got := code(assert(user)); got != web.CodeSubmissionNotOpen {
|
|
48
|
+
t.Fatalf("NOT_OPEN user: %q", got)
|
|
49
|
+
}
|
|
50
|
+
if err := assert(admin); err != nil {
|
|
51
|
+
t.Fatalf("NOT_OPEN admin: %v", err)
|
|
52
|
+
}
|
|
53
|
+
set(t, "NOW() + INTERVAL 1 HOUR")
|
|
54
|
+
if err := assert(user); err != nil {
|
|
55
|
+
t.Fatalf("OPEN user: %v", err)
|
|
56
|
+
}
|
|
57
|
+
set(t, "NOW() - INTERVAL 1 HOUR")
|
|
58
|
+
if got := code(assert(user)); got != web.CodeSubmissionClosed {
|
|
59
|
+
t.Fatalf("CLOSED user: %q", got)
|
|
60
|
+
}
|
|
61
|
+
if err := assert(admin); err != nil {
|
|
62
|
+
t.Fatalf("CLOSED admin: %v", err)
|
|
63
|
+
}
|
|
64
|
+
if got := code(g.Precheck(ctx, user)); got != web.CodeSubmissionClosed {
|
|
65
|
+
t.Fatalf("CLOSED precheck: %q", got)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package deadline_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
10
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
11
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestGatePrecheck(t *testing.T) {
|
|
15
|
+
now := time.Date(2026, 11, 8, 12, 0, 0, 0, time.UTC)
|
|
16
|
+
future, past := now.Add(time.Hour), now.Add(-time.Hour)
|
|
17
|
+
user := security.Principal{Role: security.RoleUser}
|
|
18
|
+
admin := security.Principal{Role: security.RoleAdmin}
|
|
19
|
+
|
|
20
|
+
tests := []struct {
|
|
21
|
+
name string
|
|
22
|
+
d *time.Time
|
|
23
|
+
actor security.Principal
|
|
24
|
+
want web.ErrorCode
|
|
25
|
+
}{
|
|
26
|
+
{"user NOT_OPEN", nil, user, web.CodeSubmissionNotOpen},
|
|
27
|
+
{"user OPEN", &future, user, ""},
|
|
28
|
+
{"user CLOSED", &past, user, web.CodeSubmissionClosed},
|
|
29
|
+
{"user at the deadline instant is CLOSED", &now, user, web.CodeSubmissionClosed},
|
|
30
|
+
{"admin NOT_OPEN", nil, admin, ""},
|
|
31
|
+
{"admin CLOSED", &past, admin, ""},
|
|
32
|
+
}
|
|
33
|
+
for _, tc := range tests {
|
|
34
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
35
|
+
g := deadline.Gate{Store: fakeStore{snap: deadline.Snapshot{Deadline: tc.d, Now: now}}}
|
|
36
|
+
err := g.Precheck(context.Background(), tc.actor)
|
|
37
|
+
if tc.want == "" {
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("got %v", err)
|
|
40
|
+
}
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
var e *web.Error
|
|
44
|
+
if !errors.As(err, &e) || e.Code != tc.want || e.Status != 403 {
|
|
45
|
+
t.Fatalf("got %v, want %s/403", err, tc.want)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestGatePrecheckStoreError(t *testing.T) {
|
|
52
|
+
boom := errors.New("db down")
|
|
53
|
+
g := deadline.Gate{Store: fakeStore{err: boom}}
|
|
54
|
+
if err := g.Precheck(context.Background(), security.Principal{Role: security.RoleUser}); !errors.Is(err, boom) {
|
|
55
|
+
t.Fatalf("got %v", err)
|
|
56
|
+
}
|
|
57
|
+
// Admin is exempt, so the store is not even read.
|
|
58
|
+
if err := g.Precheck(context.Background(), security.Principal{Role: security.RoleAdmin}); err != nil {
|
|
59
|
+
t.Fatalf("admin: %v", err)
|
|
60
|
+
}
|
|
61
|
+
}
|