@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/load_test.go +12 -0
- package/backend/test/matrix/matrix_integration_test.go +86 -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/frontend/src/app/App.tsx +11 -2
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -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/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 +48 -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 +21 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package store
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// IPPolicy is the per-IP sliding-window rule (PRD §6.5).
|
|
12
|
+
type IPPolicy struct {
|
|
13
|
+
MaxFailures int
|
|
14
|
+
WindowSeconds int
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// RecordIPFailure stores one failed attempt of ip at database time.
|
|
18
|
+
func RecordIPFailure(ctx context.Context, db *sql.DB, ip string) error {
|
|
19
|
+
if _, err := db.ExecContext(ctx,
|
|
20
|
+
`INSERT INTO login_attempt (ip, attempted_at) VALUES (?, NOW(6))`, ip); err != nil {
|
|
21
|
+
return fmt.Errorf("record login attempt: %w", err)
|
|
22
|
+
}
|
|
23
|
+
return nil
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// IPRetryAfter returns how long ip stays over the limit, 0 when it is not.
|
|
27
|
+
// The address is over the limit while at least MaxFailures failures lie inside
|
|
28
|
+
// the window; it is free again when the MaxFailures-th newest one leaves it,
|
|
29
|
+
// so that row's age decides (with exactly MaxFailures rows this is the oldest).
|
|
30
|
+
func IPRetryAfter(ctx context.Context, db *sql.DB, ip string, p IPPolicy) (time.Duration, error) {
|
|
31
|
+
var us int64
|
|
32
|
+
err := db.QueryRowContext(ctx,
|
|
33
|
+
`SELECT TIMESTAMPDIFF(MICROSECOND, NOW(6), attempted_at + INTERVAL ? SECOND)
|
|
34
|
+
FROM login_attempt
|
|
35
|
+
WHERE ip = ? AND attempted_at > NOW(6) - INTERVAL ? SECOND
|
|
36
|
+
ORDER BY attempted_at DESC
|
|
37
|
+
LIMIT 1 OFFSET ?`, p.WindowSeconds, ip, p.WindowSeconds, p.MaxFailures-1).Scan(&us)
|
|
38
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
39
|
+
return 0, nil
|
|
40
|
+
}
|
|
41
|
+
if err != nil {
|
|
42
|
+
return 0, fmt.Errorf("read login attempts: %w", err)
|
|
43
|
+
}
|
|
44
|
+
return time.Duration(us) * time.Microsecond, nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// DeleteOldAttempts removes at most limit login_attempt rows outside the
|
|
48
|
+
// window; IPRetryAfter only reads rows inside it.
|
|
49
|
+
func DeleteOldAttempts(ctx context.Context, db *sql.DB, p IPPolicy, limit int) (int64, error) {
|
|
50
|
+
return deleteRows(ctx, db, "purge login attempts",
|
|
51
|
+
`DELETE FROM login_attempt WHERE attempted_at <= NOW(6) - INTERVAL ? SECOND LIMIT ?`,
|
|
52
|
+
p.WindowSeconds, limit)
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package store
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// LockPolicy is the key-lock rule (PRD §6.5), in seconds and failures.
|
|
12
|
+
type LockPolicy struct {
|
|
13
|
+
MaxFailures int
|
|
14
|
+
WindowSeconds int // a failure older than this no longer counts as consecutive
|
|
15
|
+
LockSeconds int // fixed lock length
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// RecordFailure counts one failed attempt for key in ONE statement on database
|
|
19
|
+
// time. The assignments run left to right in MariaDB, so later columns see the
|
|
20
|
+
// new fail_count. A lock that is still active is never extended or re-counted
|
|
21
|
+
// (a concurrent attempt that passed Check before the lock was set); an expired
|
|
22
|
+
// lock or a stale window starts again at 1.
|
|
23
|
+
func RecordFailure(ctx context.Context, db *sql.DB, key string, p LockPolicy) error {
|
|
24
|
+
_, err := db.ExecContext(ctx,
|
|
25
|
+
`INSERT INTO login_lock (lock_key, fail_count, last_fail_at, locked_until)
|
|
26
|
+
VALUES (?, 1, NOW(6), NULL)
|
|
27
|
+
ON DUPLICATE KEY UPDATE
|
|
28
|
+
fail_count = IF(locked_until > NOW(6), fail_count,
|
|
29
|
+
IF(locked_until IS NOT NULL OR last_fail_at <= NOW(6) - INTERVAL ? SECOND, 1, fail_count + 1)),
|
|
30
|
+
locked_until = CASE WHEN locked_until > NOW(6) THEN locked_until
|
|
31
|
+
WHEN fail_count >= ? THEN NOW(6) + INTERVAL ? SECOND
|
|
32
|
+
ELSE NULL END,
|
|
33
|
+
last_fail_at = NOW(6)`,
|
|
34
|
+
key, p.WindowSeconds, p.MaxFailures, p.LockSeconds)
|
|
35
|
+
if err != nil {
|
|
36
|
+
return fmt.Errorf("record login failure: %w", err)
|
|
37
|
+
}
|
|
38
|
+
return nil
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// LockRemaining returns how long key stays locked, 0 when it is not locked.
|
|
42
|
+
func LockRemaining(ctx context.Context, db *sql.DB, key string) (time.Duration, error) {
|
|
43
|
+
var us sql.NullInt64
|
|
44
|
+
err := db.QueryRowContext(ctx,
|
|
45
|
+
`SELECT TIMESTAMPDIFF(MICROSECOND, NOW(6), locked_until)
|
|
46
|
+
FROM login_lock WHERE lock_key = ? AND locked_until > NOW(6)`, key).Scan(&us)
|
|
47
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
48
|
+
return 0, nil
|
|
49
|
+
}
|
|
50
|
+
if err != nil {
|
|
51
|
+
return 0, fmt.Errorf("read login lock: %w", err)
|
|
52
|
+
}
|
|
53
|
+
return time.Duration(us.Int64) * time.Microsecond, nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ClearFailures resets the counter after a successful login. An active lock is
|
|
57
|
+
// left alone (a success cannot shorten it).
|
|
58
|
+
func ClearFailures(ctx context.Context, db *sql.DB, key string) error {
|
|
59
|
+
_, err := db.ExecContext(ctx,
|
|
60
|
+
`DELETE FROM login_lock WHERE lock_key = ? AND (locked_until IS NULL OR locked_until <= NOW(6))`, key)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return fmt.Errorf("clear login failures: %w", err)
|
|
63
|
+
}
|
|
64
|
+
return nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// DeleteStaleLocks removes at most limit login_lock rows that no longer
|
|
68
|
+
// matter: no active lock and the last failure outside the window. For such a
|
|
69
|
+
// row RecordFailure would start again at 1, exactly as for a missing row.
|
|
70
|
+
func DeleteStaleLocks(ctx context.Context, db *sql.DB, p LockPolicy, limit int) (int64, error) {
|
|
71
|
+
return deleteRows(ctx, db, "purge login locks",
|
|
72
|
+
`DELETE FROM login_lock
|
|
73
|
+
WHERE last_fail_at <= NOW(6) - INTERVAL ? SECOND
|
|
74
|
+
AND (locked_until IS NULL OR locked_until <= NOW(6))
|
|
75
|
+
LIMIT ?`,
|
|
76
|
+
p.WindowSeconds, limit)
|
|
77
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package store
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"fmt"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// ClaimThrottleAudit reports whether the caller may write the THROTTLED audit
|
|
11
|
+
// row of (scope, subject): true for the first throttled attempt of a window,
|
|
12
|
+
// false while an earlier claim is still running. A winning claim holds until
|
|
13
|
+
// database now + hold. The claim is atomic, so concurrent attempts on several
|
|
14
|
+
// replicas produce exactly one winner.
|
|
15
|
+
func ClaimThrottleAudit(ctx context.Context, db *sql.DB, scope, subject string, hold time.Duration) (bool, error) {
|
|
16
|
+
us := hold.Microseconds()
|
|
17
|
+
res, err := db.ExecContext(ctx,
|
|
18
|
+
`UPDATE login_throttle_audit SET audited_until = NOW(6) + INTERVAL ? MICROSECOND
|
|
19
|
+
WHERE scope = ? AND subject = ? AND audited_until <= NOW(6)`, us, scope, subject)
|
|
20
|
+
if err != nil {
|
|
21
|
+
return false, fmt.Errorf("claim throttle audit: %w", err)
|
|
22
|
+
}
|
|
23
|
+
if n, _ := res.RowsAffected(); n > 0 {
|
|
24
|
+
return true, nil
|
|
25
|
+
}
|
|
26
|
+
res, err = db.ExecContext(ctx,
|
|
27
|
+
`INSERT IGNORE INTO login_throttle_audit (scope, subject, audited_until)
|
|
28
|
+
VALUES (?, ?, NOW(6) + INTERVAL ? MICROSECOND)`, scope, subject, us)
|
|
29
|
+
if err != nil {
|
|
30
|
+
return false, fmt.Errorf("claim throttle audit: %w", err)
|
|
31
|
+
}
|
|
32
|
+
n, _ := res.RowsAffected()
|
|
33
|
+
return n > 0, nil
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// DeleteExpiredThrottleAudits removes at most limit claims that have run out.
|
|
37
|
+
// ClaimThrottleAudit treats a missing row like an expired one (the next
|
|
38
|
+
// throttled attempt wins), so deleting it changes no outcome.
|
|
39
|
+
func DeleteExpiredThrottleAudits(ctx context.Context, db *sql.DB, limit int) (int64, error) {
|
|
40
|
+
return deleteRows(ctx, db, "purge throttle audits",
|
|
41
|
+
`DELETE FROM login_throttle_audit WHERE audited_until <= NOW(6) LIMIT ?`, limit)
|
|
42
|
+
}
|
|
@@ -99,19 +99,27 @@ func DeleteByUser(ctx context.Context, db *sql.DB, userID uuid.UUID) error {
|
|
|
99
99
|
return nil
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// DeleteExpired removes sessions past either timeout and
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
// DeleteExpired removes at most limit sessions past either timeout and
|
|
103
|
+
// returns how many. The condition is the exact complement of Touch's, so a row
|
|
104
|
+
// it deletes could no longer be validated anyway.
|
|
105
|
+
func DeleteExpired(ctx context.Context, db *sql.DB, l Limits, limit int) (int64, error) {
|
|
106
|
+
return deleteRows(ctx, db, "purge sessions",
|
|
105
107
|
`DELETE FROM session
|
|
106
108
|
WHERE last_access_at <= NOW(6) - INTERVAL ? SECOND
|
|
107
|
-
OR created_at <= NOW(6) - INTERVAL ? SECOND
|
|
108
|
-
|
|
109
|
+
OR created_at <= NOW(6) - INTERVAL ? SECOND
|
|
110
|
+
LIMIT ?`,
|
|
111
|
+
l.IdleSeconds, l.AbsoluteSeconds, limit)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// deleteRows runs one DELETE and returns the affected row count.
|
|
115
|
+
func deleteRows(ctx context.Context, db *sql.DB, what, query string, args ...any) (int64, error) {
|
|
116
|
+
res, err := db.ExecContext(ctx, query, args...)
|
|
109
117
|
if err != nil {
|
|
110
|
-
return 0, fmt.Errorf("
|
|
118
|
+
return 0, fmt.Errorf("%s: %w", what, err)
|
|
111
119
|
}
|
|
112
120
|
n, err := res.RowsAffected()
|
|
113
121
|
if err != nil {
|
|
114
|
-
return 0, fmt.Errorf("
|
|
122
|
+
return 0, fmt.Errorf("%s rows: %w", what, err)
|
|
115
123
|
}
|
|
116
124
|
return n, nil
|
|
117
125
|
}
|
|
@@ -33,15 +33,20 @@ const (
|
|
|
33
33
|
|
|
34
34
|
var empNoFormat = regexp.MustCompile(`^[0-9]{6}$`)
|
|
35
35
|
|
|
36
|
-
// Throttle is the login throttling hook (PRD §6.5)
|
|
37
|
-
// login_lock
|
|
38
|
-
//
|
|
36
|
+
// Throttle is the login throttling hook (PRD §6.5); DBThrottle implements it on
|
|
37
|
+
// login_lock. Errors fail the login closed (500): an unreachable counter must
|
|
38
|
+
// not disable the limit.
|
|
39
39
|
type Throttle interface {
|
|
40
40
|
// Check runs before any password work. A positive retryAfter means the
|
|
41
41
|
// attempt is throttled: it answers 429, runs no bcrypt and is not counted.
|
|
42
42
|
// key is the submitted employee-ID string as typed; implementations strip
|
|
43
43
|
// control characters and cut it to 32 characters.
|
|
44
44
|
Check(ctx context.Context, key string, ip netip.Addr) (retryAfter time.Duration, err error)
|
|
45
|
+
// AuditOnce is asked after Check reported a wait. It returns true for the
|
|
46
|
+
// first throttled attempt of a window and false for the rest, so a throttled
|
|
47
|
+
// key or address is audited once per window (PRD §6.11). Its error is not
|
|
48
|
+
// fatal: the attempt is audited anyway.
|
|
49
|
+
AuditOnce(ctx context.Context, key string, ip netip.Addr) (bool, error)
|
|
45
50
|
// Fail counts one failed attempt (also for unknown accounts).
|
|
46
51
|
Fail(ctx context.Context, key string, ip netip.Addr) error
|
|
47
52
|
// Succeed clears the key's counter.
|
|
@@ -54,6 +59,9 @@ type NoThrottle struct{}
|
|
|
54
59
|
// Check implements Throttle.
|
|
55
60
|
func (NoThrottle) Check(context.Context, string, netip.Addr) (time.Duration, error) { return 0, nil }
|
|
56
61
|
|
|
62
|
+
// AuditOnce implements Throttle.
|
|
63
|
+
func (NoThrottle) AuditOnce(context.Context, string, netip.Addr) (bool, error) { return true, nil }
|
|
64
|
+
|
|
57
65
|
// Fail implements Throttle.
|
|
58
66
|
func (NoThrottle) Fail(context.Context, string, netip.Addr) error { return nil }
|
|
59
67
|
|
|
@@ -122,7 +130,11 @@ func (l *Logins) Login(ctx context.Context, cmd LoginCommand, req Request) (Logg
|
|
|
122
130
|
if retry > 0 {
|
|
123
131
|
e := web.NewError(web.CodeLoginThrottled, "too many login attempts")
|
|
124
132
|
e.RetryAfter = retry
|
|
125
|
-
|
|
133
|
+
if first, err := l.Throttle.AuditOnce(ctx, cmd.EmpNo, req.IP); err != nil || first {
|
|
134
|
+
// An error audits anyway: a duplicate row beats a missing one.
|
|
135
|
+
return LoggedIn{}, loginRefused(e, audit.Throttled, "throttled", cmd.EmpNo)
|
|
136
|
+
}
|
|
137
|
+
return LoggedIn{}, e
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
var (
|
|
@@ -42,6 +42,9 @@ func (f *fakeThrottle) Check(_ context.Context, key string, _ netip.Addr) (time.
|
|
|
42
42
|
f.lastKey = key
|
|
43
43
|
return f.retry, f.err
|
|
44
44
|
}
|
|
45
|
+
func (f *fakeThrottle) AuditOnce(context.Context, string, netip.Addr) (bool, error) {
|
|
46
|
+
return true, nil
|
|
47
|
+
}
|
|
45
48
|
func (f *fakeThrottle) Fail(context.Context, string, netip.Addr) error {
|
|
46
49
|
f.mu.Lock()
|
|
47
50
|
defer f.mu.Unlock()
|
|
@@ -67,13 +70,23 @@ func newLoginEnv(t *testing.T) loginEnv {
|
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
func newLoginEnvLog(t *testing.T, log *slog.Logger) loginEnv {
|
|
73
|
+
t.Helper()
|
|
74
|
+
return newLoginEnvWith(t, log, nil)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// newLoginEnvWith wires mk(db) as the throttle; nil keeps the fake.
|
|
78
|
+
func newLoginEnvWith(t *testing.T, log *slog.Logger, mk func(*sql.DB) auth.Throttle) loginEnv {
|
|
70
79
|
t.Helper()
|
|
71
80
|
db := dbtest.New(t)
|
|
72
81
|
auditSvc := audit.New(db, nil)
|
|
73
82
|
errs := server.ErrorAdapter{Audit: auditSvc}
|
|
74
83
|
sessions := auth.New(db)
|
|
75
84
|
th := &fakeThrottle{}
|
|
76
|
-
|
|
85
|
+
var thr auth.Throttle = th
|
|
86
|
+
if mk != nil {
|
|
87
|
+
thr = mk(db)
|
|
88
|
+
}
|
|
89
|
+
logins, err := auth.NewLogins(sessions, auditSvc, thr)
|
|
77
90
|
if err != nil {
|
|
78
91
|
t.Fatal(err)
|
|
79
92
|
}
|
|
@@ -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
|
+
}
|