@johnnyboy11234/esg-idea-contest 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +10 -2
  3. package/backend/internal/account/logging_integration_test.go +70 -0
  4. package/backend/internal/api/api.gen.go +125 -0
  5. package/backend/internal/auth/internal/store/session.go +63 -5
  6. package/backend/internal/auth/logging_integration_test.go +88 -0
  7. package/backend/internal/auth/login.go +252 -0
  8. package/backend/internal/auth/login_integration_test.go +421 -0
  9. package/backend/internal/auth/login_test.go +68 -0
  10. package/backend/internal/auth/logout.go +68 -0
  11. package/backend/internal/auth/logout_integration_test.go +124 -0
  12. package/backend/internal/auth/me.go +40 -0
  13. package/backend/internal/auth/me_integration_test.go +70 -0
  14. package/backend/internal/auth/session.go +20 -1
  15. package/backend/internal/server/api.go +14 -0
  16. package/backend/internal/server/public.go +3 -1
  17. package/backend/internal/shared/web/problem.go +9 -0
  18. package/backend/internal/shared/web/retryafter_test.go +31 -0
  19. package/frontend/src/app/index.css +102 -102
  20. package/frontend/src/app/layouts.tsx +75 -18
  21. package/frontend/src/app/pages/auth.tsx +52 -78
  22. package/frontend/src/app/pages/login.test.tsx +218 -0
  23. package/frontend/src/app/pages/register.test.tsx +231 -0
  24. package/frontend/src/app/router.tsx +83 -34
  25. package/frontend/src/app/session.ts +53 -22
  26. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  27. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  28. package/frontend/src/features/auth/login.ts +23 -0
  29. package/frontend/src/features/auth/register.ts +105 -0
  30. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  31. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  32. package/frontend/src/shared/text/index.ts +20 -0
  33. package/frontend/src/shared/text/text.test.ts +28 -1
  34. package/frontend/vite.config.ts +5 -1
  35. package/package.json +1 -1
@@ -0,0 +1,252 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "net/http"
9
+ "net/netip"
10
+ "regexp"
11
+ "time"
12
+
13
+ "golang.org/x/crypto/bcrypt"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth/internal/store"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
20
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
21
+ )
22
+
23
+ const (
24
+ // bcryptCost is the only cost the table accepts (PRD §6.4, OI-13); the
25
+ // dummy hash uses it too, so unknown and known accounts cost the same.
26
+ bcryptCost = 10
27
+ // bcryptMaxBytes is bcrypt's input limit. Longer input is cut for the dummy
28
+ // comparison and never matches (ADR G3).
29
+ bcryptMaxBytes = 72
30
+
31
+ maxLoginBody = 16 << 10 // error-codes.md §2: 16 KiB → 413
32
+ )
33
+
34
+ var empNoFormat = regexp.MustCompile(`^[0-9]{6}$`)
35
+
36
+ // Throttle is the login throttling hook (PRD §6.5). B1b-04 implements it on
37
+ // login_lock / login_attempt; until then NoThrottle is wired. Errors fail the
38
+ // login closed (500): an unreachable counter must not disable the limit.
39
+ type Throttle interface {
40
+ // Check runs before any password work. A positive retryAfter means the
41
+ // attempt is throttled: it answers 429, runs no bcrypt and is not counted.
42
+ // key is the submitted employee-ID string as typed; implementations strip
43
+ // control characters and cut it to 32 characters.
44
+ Check(ctx context.Context, key string, ip netip.Addr) (retryAfter time.Duration, err error)
45
+ // Fail counts one failed attempt (also for unknown accounts).
46
+ Fail(ctx context.Context, key string, ip netip.Addr) error
47
+ // Succeed clears the key's counter.
48
+ Succeed(ctx context.Context, key string) error
49
+ }
50
+
51
+ // NoThrottle never throttles.
52
+ type NoThrottle struct{}
53
+
54
+ // Check implements Throttle.
55
+ func (NoThrottle) Check(context.Context, string, netip.Addr) (time.Duration, error) { return 0, nil }
56
+
57
+ // Fail implements Throttle.
58
+ func (NoThrottle) Fail(context.Context, string, netip.Addr) error { return nil }
59
+
60
+ // Succeed implements Throttle.
61
+ func (NoThrottle) Succeed(context.Context, string) error { return nil }
62
+
63
+ // Recorder writes the success audit row in the login transaction.
64
+ // *audit.Service implements it.
65
+ type Recorder interface {
66
+ Record(ctx context.Context, tx *sql.Tx, e audit.Event) error
67
+ }
68
+
69
+ // Logins implements POST /auth/login.
70
+ type Logins struct {
71
+ Sessions *Sessions
72
+ Audit Recorder
73
+ Throttle Throttle
74
+
75
+ dummyHash []byte
76
+ }
77
+
78
+ // NewLogins builds the service and computes the dummy bcrypt hash once
79
+ // (ADR G3), so a request never pays for it and unknown accounts cost one
80
+ // cost-10 comparison like known ones.
81
+ func NewLogins(s *Sessions, rec Recorder, t Throttle) (*Logins, error) {
82
+ if t == nil {
83
+ t = NoThrottle{}
84
+ }
85
+ h, err := bcrypt.GenerateFromPassword([]byte("dummy password for timing"), bcryptCost)
86
+ if err != nil {
87
+ return nil, fmt.Errorf("dummy hash: %w", err)
88
+ }
89
+ return &Logins{Sessions: s, Audit: rec, Throttle: t, dummyHash: h}, nil
90
+ }
91
+
92
+ // LoginCommand is the raw input.
93
+ type LoginCommand struct{ EmpNo, Password string }
94
+
95
+ // Request is the transport facts the audit row needs.
96
+ type Request struct {
97
+ Method, Route, UserAgent string
98
+ IP netip.Addr
99
+ }
100
+
101
+ // LoggedIn is the result of a successful login.
102
+ type LoggedIn struct {
103
+ SessionID string
104
+ EmpNo string
105
+ Name string
106
+ Email *string
107
+ Role security.Role
108
+ }
109
+
110
+ // Login checks the credentials and opens a session. Every refusal is a
111
+ // *web.Error carrying the AUTH_LOGIN audit fields (FAILED or THROTTLED), so the
112
+ // central adapter writes the track-B row; success writes its row in the same
113
+ // transaction as the session (fail-closed).
114
+ //
115
+ // Unknown, badly formatted and disabled accounts and wrong passwords answer
116
+ // the same 401 after the same one bcrypt comparison.
117
+ func (l *Logins) Login(ctx context.Context, cmd LoginCommand, req Request) (LoggedIn, error) {
118
+ retry, err := l.Throttle.Check(ctx, cmd.EmpNo, req.IP)
119
+ if err != nil {
120
+ return LoggedIn{}, fmt.Errorf("login throttle check: %w", err)
121
+ }
122
+ if retry > 0 {
123
+ e := web.NewError(web.CodeLoginThrottled, "too many login attempts")
124
+ e.RetryAfter = retry
125
+ return LoggedIn{}, loginRefused(e, audit.Throttled, "throttled", cmd.EmpNo)
126
+ }
127
+
128
+ var (
129
+ acct store.Account
130
+ found bool
131
+ reason = "bad_format"
132
+ )
133
+ if empNoFormat.MatchString(cmd.EmpNo) {
134
+ if acct, found, err = store.AccountByEmpNo(ctx, l.Sessions.DB, cmd.EmpNo); err != nil {
135
+ return LoggedIn{}, err
136
+ }
137
+ reason = "unknown_account"
138
+ }
139
+ hash := l.dummyHash
140
+ if found {
141
+ hash, reason = []byte(acct.PasswordHash), ""
142
+ if !acct.Enabled {
143
+ hash, reason = l.dummyHash, "disabled"
144
+ }
145
+ }
146
+ ok := comparePassword(hash, cmd.Password)
147
+ switch {
148
+ case reason != "":
149
+ case len(cmd.Password) > bcryptMaxBytes:
150
+ reason = "password_too_long"
151
+ case !ok:
152
+ reason = "wrong_password"
153
+ }
154
+ if reason != "" {
155
+ if err := l.Throttle.Fail(ctx, cmd.EmpNo, req.IP); err != nil {
156
+ return LoggedIn{}, fmt.Errorf("login throttle fail: %w", err)
157
+ }
158
+ return LoggedIn{}, loginRefused(web.NewError(web.CodeInvalidCredentials, "invalid employee ID or password"),
159
+ audit.Failed, reason, cmd.EmpNo)
160
+ }
161
+
162
+ if err := l.Throttle.Succeed(ctx, cmd.EmpNo); err != nil {
163
+ return LoggedIn{}, fmt.Errorf("login throttle clear: %w", err)
164
+ }
165
+ var sid string
166
+ _, err = db.InTx(ctx, l.Sessions.DB, func(ctx context.Context, tx *sql.Tx) error {
167
+ var err error
168
+ if sid, err = l.Sessions.CreateIn(ctx, tx, acct.ID); err != nil {
169
+ return err
170
+ }
171
+ return l.Audit.Record(ctx, tx, audit.Event{
172
+ Action: audit.AuthLogin, Outcome: audit.Success,
173
+ ActorRole: security.Principal{Role: acct.Role}.ActorRole(), ActorEmpNo: acct.EmpNo,
174
+ TargetType: audit.TargetUser, TargetID: acct.ID,
175
+ Method: req.Method, Route: req.Route, Status: http.StatusOK,
176
+ IP: req.IP, UserAgent: req.UserAgent,
177
+ })
178
+ })
179
+ if err != nil {
180
+ return LoggedIn{}, err
181
+ }
182
+ return LoggedIn{SessionID: sid, EmpNo: acct.EmpNo, Name: acct.Name, Email: acct.Email, Role: acct.Role}, nil
183
+ }
184
+
185
+ // comparePassword runs exactly one cost-10 comparison. Input beyond bcrypt's
186
+ // 72 bytes is cut for the comparison against the dummy hash and never
187
+ // succeeds: newer x/crypto refuses such input before doing any work, which
188
+ // would make it fast and so distinguishable (ADR G3).
189
+ func comparePassword(hash []byte, password string) bool {
190
+ pw := []byte(password)
191
+ if len(pw) > bcryptMaxBytes {
192
+ _ = bcrypt.CompareHashAndPassword(hash, pw[:bcryptMaxBytes])
193
+ return false
194
+ }
195
+ return bcrypt.CompareHashAndPassword(hash, pw) == nil
196
+ }
197
+
198
+ func loginRefused(e *web.Error, outcome audit.Outcome, reason, submitted string) *web.Error {
199
+ e.AuditAction = string(audit.AuthLogin)
200
+ e.AuditOutcome = string(outcome)
201
+ e.AuditReason = reason
202
+ e.AuditSubmittedEmpNo = submitted
203
+ return e
204
+ }
205
+
206
+ // loginBody holds pointers so DecodeJSON can tell absent/null (400) from empty.
207
+ type loginBody struct {
208
+ EmpNo *string `json:"empNo"`
209
+ Password *string `json:"password"`
210
+ }
211
+
212
+ // LoginHandler is the HTTP adapter of Logins. Fail is the central error
213
+ // adapter (server.ErrorAdapter.Write); the module cannot import server.
214
+ type LoginHandler struct {
215
+ Logins *Logins
216
+ Fail func(w http.ResponseWriter, r *http.Request, err error)
217
+ }
218
+
219
+ // Login serves POST /auth/login: 200 {empNo, name, email, role} and the
220
+ // session cookie.
221
+ func (h LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
222
+ r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody)
223
+ var b loginBody
224
+ if err := web.DecodeJSON(r, &b); err != nil {
225
+ h.Fail(w, r, auditMalformed(err))
226
+ return
227
+ }
228
+ res, err := h.Logins.Login(r.Context(), LoginCommand{EmpNo: *b.EmpNo, Password: *b.Password}, Request{
229
+ Method: r.Method, Route: web.RoutePattern(r), UserAgent: r.UserAgent(), IP: web.ClientIP(r),
230
+ })
231
+ if err != nil {
232
+ h.Fail(w, r, err)
233
+ return
234
+ }
235
+ http.SetCookie(w, &http.Cookie{
236
+ Name: SessionCookie, Value: res.SessionID, Path: "/",
237
+ Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode,
238
+ })
239
+ web.WriteJSON(w, http.StatusOK, api.LoginResponse{
240
+ EmpNo: res.EmpNo, Name: res.Name, Email: res.Email, Role: api.LoginResponseRole(res.Role),
241
+ })
242
+ }
243
+
244
+ // auditMalformed marks a 400 as a FAILED login. Other transport errors (413,
245
+ // 415) are not audited.
246
+ func auditMalformed(err error) error {
247
+ var e *web.Error
248
+ if errors.As(err, &e) && e.Code == web.CodeMalformedRequest {
249
+ return loginRefused(e, audit.Failed, "malformed_request", "")
250
+ }
251
+ return err
252
+ }
@@ -0,0 +1,421 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "encoding/json"
9
+ "log/slog"
10
+ "net/http"
11
+ "net/http/httptest"
12
+ "net/netip"
13
+ "strings"
14
+ "sync"
15
+ "testing"
16
+ "time"
17
+
18
+ "github.com/google/uuid"
19
+ "golang.org/x/crypto/bcrypt"
20
+
21
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
22
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
23
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
24
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
25
+ "github.com/brian-huanggg/AwardRegistration/backend/test/apispec"
26
+ )
27
+
28
+ const password = "Correct-Horse-1"
29
+
30
+ type fakeThrottle struct {
31
+ mu sync.Mutex
32
+ retry time.Duration
33
+ err error
34
+ checks, fails, oks int
35
+ lastKey string
36
+ }
37
+
38
+ func (f *fakeThrottle) Check(_ context.Context, key string, _ netip.Addr) (time.Duration, error) {
39
+ f.mu.Lock()
40
+ defer f.mu.Unlock()
41
+ f.checks++
42
+ f.lastKey = key
43
+ return f.retry, f.err
44
+ }
45
+ func (f *fakeThrottle) Fail(context.Context, string, netip.Addr) error {
46
+ f.mu.Lock()
47
+ defer f.mu.Unlock()
48
+ f.fails++
49
+ return nil
50
+ }
51
+ func (f *fakeThrottle) Succeed(context.Context, string) error {
52
+ f.mu.Lock()
53
+ defer f.mu.Unlock()
54
+ f.oks++
55
+ return nil
56
+ }
57
+
58
+ type loginEnv struct {
59
+ db *sql.DB
60
+ h http.Handler
61
+ th *fakeThrottle
62
+ }
63
+
64
+ func newLoginEnv(t *testing.T) loginEnv {
65
+ t.Helper()
66
+ return newLoginEnvLog(t, slog.New(slog.DiscardHandler))
67
+ }
68
+
69
+ func newLoginEnvLog(t *testing.T, log *slog.Logger) loginEnv {
70
+ t.Helper()
71
+ db := dbtest.New(t)
72
+ auditSvc := audit.New(db, nil)
73
+ errs := server.ErrorAdapter{Audit: auditSvc}
74
+ sessions := auth.New(db)
75
+ th := &fakeThrottle{}
76
+ logins, err := auth.NewLogins(sessions, auditSvc, th)
77
+ if err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ csrf, err := auth.NewCSRF(strings.Repeat("k", 32))
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ si := server.API{
85
+ Auth: auth.Handler{CSRF: csrf, Log: log},
86
+ Logins: auth.LoginHandler{Logins: logins, Fail: errs.Write},
87
+ Logouts: auth.LogoutHandler{Logouts: &auth.Logouts{Sessions: sessions, Audit: auditSvc}, Fail: errs.Write},
88
+ Me: auth.MeHandler{Sessions: sessions, Fail: errs.Write},
89
+ }
90
+ chain := server.NewPublic(log, server.Mount(si),
91
+ auth.Middleware(sessions, server.IsPublic, log),
92
+ csrf.Middleware(errs.Write, server.ActionFor))
93
+ return loginEnv{db: db, h: apispec.Load(t).Validate(t, chain), th: th}
94
+ }
95
+
96
+ func (e loginEnv) addUser(t *testing.T, empNo, role string, enabled bool) uuid.UUID {
97
+ t.Helper()
98
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
99
+ if err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ id := uuid.New()
103
+ var email any = empNo + "@example.com"
104
+ if role == "ADMIN" {
105
+ email = nil
106
+ }
107
+ if _, err := e.db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role, enabled) VALUES (?,?,?,?,?,?,?)`,
108
+ id.String(), empNo, "N "+empNo, email, string(hash), role, enabled); err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ return id
112
+ }
113
+
114
+ func (e loginEnv) do(req *http.Request) *httptest.ResponseRecorder {
115
+ rec := httptest.NewRecorder()
116
+ e.h.ServeHTTP(rec, req)
117
+ return rec
118
+ }
119
+
120
+ // csrf fetches a token the way the client does, optionally with a session.
121
+ func (e loginEnv) csrf(t *testing.T, session *http.Cookie) (token string, cookie *http.Cookie) {
122
+ t.Helper()
123
+ req := httptest.NewRequest("GET", "/api/v1/auth/csrf", nil)
124
+ if session != nil {
125
+ req.AddCookie(session)
126
+ }
127
+ rec := e.do(req)
128
+ var b struct{ Token string }
129
+ if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil || rec.Code != 200 {
130
+ t.Fatalf("csrf: %d %s", rec.Code, rec.Body)
131
+ }
132
+ return b.Token, findCookie(rec, auth.CSRFCookie)
133
+ }
134
+
135
+ func findCookie(rec *httptest.ResponseRecorder, name string) *http.Cookie {
136
+ for _, c := range rec.Result().Cookies() {
137
+ if c.Name == name {
138
+ return c
139
+ }
140
+ }
141
+ return nil
142
+ }
143
+
144
+ func (e loginEnv) login(t *testing.T, body string) *httptest.ResponseRecorder {
145
+ t.Helper()
146
+ tok, ck := e.csrf(t, nil)
147
+ return e.loginWith(body, tok, ck)
148
+ }
149
+
150
+ func (e loginEnv) loginWith(body, tok string, ck *http.Cookie, extra ...*http.Cookie) *httptest.ResponseRecorder {
151
+ req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body))
152
+ req.Header.Set("Content-Type", "application/json")
153
+ req.Header.Set("X-CSRF-Token", tok)
154
+ req.Header.Set("User-Agent", "it/1")
155
+ req.RemoteAddr = "192.0.2.9:4000"
156
+ req.AddCookie(ck)
157
+ for _, c := range extra {
158
+ req.AddCookie(c)
159
+ }
160
+ return e.do(req)
161
+ }
162
+
163
+ func creds(empNo, pw string) string {
164
+ b, _ := json.Marshal(map[string]string{"empNo": empNo, "password": pw})
165
+ return string(b)
166
+ }
167
+
168
+ type row struct {
169
+ Action, Outcome, Actor, ErrorCode string
170
+ Empno, Submitted, After sql.NullString
171
+ Status sql.NullInt64
172
+ }
173
+
174
+ func (e loginEnv) audits(t *testing.T) []row {
175
+ t.Helper()
176
+ rs, err := e.db.Query(`SELECT action, outcome, actor_role, COALESCE(error_code,''), actor_emp_no,
177
+ submitted_emp_no, CAST(after_json AS CHAR), status FROM audit_log WHERE action='AUTH_LOGIN' ORDER BY id`)
178
+ if err != nil {
179
+ t.Fatal(err)
180
+ }
181
+ defer rs.Close()
182
+ var out []row
183
+ for rs.Next() {
184
+ var r row
185
+ if err := rs.Scan(&r.Action, &r.Outcome, &r.Actor, &r.ErrorCode, &r.Empno, &r.Submitted, &r.After, &r.Status); err != nil {
186
+ t.Fatal(err)
187
+ }
188
+ out = append(out, r)
189
+ }
190
+ return out
191
+ }
192
+
193
+ func (e loginEnv) sessions(t *testing.T) int {
194
+ t.Helper()
195
+ var n int
196
+ if err := e.db.QueryRow(`SELECT COUNT(*) FROM session`).Scan(&n); err != nil {
197
+ t.Fatal(err)
198
+ }
199
+ return n
200
+ }
201
+
202
+ func code(t *testing.T, rec *httptest.ResponseRecorder) string {
203
+ t.Helper()
204
+ var p struct{ Code string }
205
+ if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
206
+ t.Fatal(err)
207
+ }
208
+ return p.Code
209
+ }
210
+
211
+ func TestLoginSuccess(t *testing.T) {
212
+ e := newLoginEnv(t)
213
+ id := e.addUser(t, "123456", "USER", true)
214
+
215
+ rec := e.login(t, creds("123456", password))
216
+ if rec.Code != 200 {
217
+ t.Fatalf("status %d: %s", rec.Code, rec.Body)
218
+ }
219
+ var body map[string]any
220
+ _ = json.Unmarshal(rec.Body.Bytes(), &body)
221
+ if len(body) != 4 || body["empNo"] != "123456" || body["name"] != "N 123456" ||
222
+ body["email"] != "123456@example.com" || body["role"] != "USER" {
223
+ t.Errorf("body = %v", body)
224
+ }
225
+ c := findCookie(rec, auth.SessionCookie)
226
+ if c == nil || !c.Secure || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.Domain != "" {
227
+ t.Fatalf("session cookie = %+v", c)
228
+ }
229
+ if _, ok := auth.Binding(c.Value); !ok {
230
+ t.Errorf("cookie value %q is not a session ID", c.Value)
231
+ }
232
+ if e.sessions(t) != 1 {
233
+ t.Error("want exactly one session row")
234
+ }
235
+
236
+ rows := e.audits(t)
237
+ if len(rows) != 1 {
238
+ t.Fatalf("audit = %+v", rows)
239
+ }
240
+ r := rows[0]
241
+ if r.Outcome != "SUCCESS" || r.Actor != "USER" || r.Empno.String != "123456" || r.Status.Int64 != 200 || r.Submitted.Valid {
242
+ t.Errorf("audit row = %+v", r)
243
+ }
244
+ var target string
245
+ if err := e.db.QueryRow(`SELECT target_id FROM audit_log WHERE action='AUTH_LOGIN'`).Scan(&target); err != nil || target != id.String() {
246
+ t.Errorf("target %q err %v", target, err)
247
+ }
248
+ if e.th.oks != 1 || e.th.fails != 0 {
249
+ t.Errorf("throttle ok=%d fail=%d", e.th.oks, e.th.fails)
250
+ }
251
+ }
252
+
253
+ func TestLoginAdminHasNullEmail(t *testing.T) {
254
+ e := newLoginEnv(t)
255
+ e.addUser(t, "000001", "ADMIN", true)
256
+ rec := e.login(t, creds("000001", password))
257
+ if rec.Code != 200 {
258
+ t.Fatalf("status %d: %s", rec.Code, rec.Body)
259
+ }
260
+ var body map[string]any
261
+ _ = json.Unmarshal(rec.Body.Bytes(), &body)
262
+ if v, ok := body["email"]; !ok || v != nil || body["role"] != "ADMIN" {
263
+ t.Errorf("body = %v: want email null, role ADMIN", body)
264
+ }
265
+ if rows := e.audits(t); len(rows) != 1 || rows[0].Actor != "ADMIN" {
266
+ t.Errorf("audit = %+v", rows)
267
+ }
268
+ }
269
+
270
+ // Each login issues a new ID (no fixation), and the anonymous CSRF token that
271
+ // preceded it stops working once the session exists (B1-10 acceptance).
272
+ func TestLoginNewSessionIDAndOldCSRFInvalid(t *testing.T) {
273
+ e := newLoginEnv(t)
274
+ e.addUser(t, "123456", "USER", true)
275
+
276
+ tok, ck := e.csrf(t, nil)
277
+ first := e.loginWith(creds("123456", password), tok, ck)
278
+ s1 := findCookie(first, auth.SessionCookie)
279
+ if first.Code != 200 || s1 == nil {
280
+ t.Fatalf("first login: %d %s", first.Code, first.Body)
281
+ }
282
+
283
+ // The same anonymous token against the new session: bound to nothing now.
284
+ stale := e.loginWith(creds("123456", password), tok, ck, s1)
285
+ if stale.Code != 403 || code(t, stale) != "CSRF_INVALID" {
286
+ t.Errorf("old anonymous token with a session: %d %s", stale.Code, stale.Body)
287
+ }
288
+
289
+ // A session-bound token works and a second login yields another ID.
290
+ tok2, ck2 := e.csrf(t, s1)
291
+ second := e.loginWith(creds("123456", password), tok2, ck2, s1)
292
+ s2 := findCookie(second, auth.SessionCookie)
293
+ if second.Code != 200 || s2 == nil || s2.Value == s1.Value {
294
+ t.Fatalf("second login: %d, session IDs equal=%v", second.Code, s2 != nil && s2.Value == s1.Value)
295
+ }
296
+ if e.sessions(t) != 2 {
297
+ t.Error("concurrent sessions are allowed: want two rows")
298
+ }
299
+ }
300
+
301
+ // Every refusal is the same 401 with the same body, whatever the reason.
302
+ func TestLoginFailuresAreIndistinguishable(t *testing.T) {
303
+ e := newLoginEnv(t)
304
+ e.addUser(t, "123456", "USER", true)
305
+ e.addUser(t, "222222", "USER", false)
306
+
307
+ tests := []struct{ name, body, reason string }{
308
+ {"unknown account", creds("999999", password), "unknown_account"},
309
+ {"badly formatted ID", creds("12ab", password), "bad_format"},
310
+ {"empty ID", creds("", password), "bad_format"},
311
+ {"wrong password", creds("123456", "Wrong-Horse-99"), "wrong_password"},
312
+ {"empty password", creds("123456", ""), "wrong_password"},
313
+ {"disabled account, right password", creds("222222", password), "disabled"},
314
+ {"over 72 bytes, even with the right prefix", creds("123456", password+strings.Repeat("x", 70)), "password_too_long"},
315
+ }
316
+ var first string
317
+ for i, tc := range tests {
318
+ rec := e.login(t, tc.body)
319
+ if rec.Code != 401 || code(t, rec) != "INVALID_CREDENTIALS" {
320
+ t.Fatalf("%s: %d %s", tc.name, rec.Code, rec.Body)
321
+ }
322
+ var p map[string]any
323
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
324
+ delete(p, "requestId")
325
+ b, _ := json.Marshal(p)
326
+ if i == 0 {
327
+ first = string(b)
328
+ } else if string(b) != first {
329
+ t.Errorf("%s: body %s differs from %s", tc.name, b, first)
330
+ }
331
+ if rec.Header().Get("Set-Cookie") != "" {
332
+ t.Errorf("%s: sets a cookie", tc.name)
333
+ }
334
+ }
335
+ if e.sessions(t) != 0 {
336
+ t.Error("a failed login created a session")
337
+ }
338
+
339
+ rows := e.audits(t)
340
+ if len(rows) != len(tests) {
341
+ t.Fatalf("audit rows = %d", len(rows))
342
+ }
343
+ for i, tc := range tests {
344
+ r := rows[i]
345
+ if r.Outcome != "FAILED" || r.Actor != "ANONYMOUS" || r.ErrorCode != "INVALID_CREDENTIALS" || r.Status.Int64 != 401 {
346
+ t.Errorf("%s: row %+v", tc.name, r)
347
+ }
348
+ if !strings.Contains(r.After.String, `"`+tc.reason+`"`) {
349
+ t.Errorf("%s: after %q lacks reason %s", tc.name, r.After.String, tc.reason)
350
+ }
351
+ }
352
+ if rows[0].Submitted.String != "999999" {
353
+ t.Errorf("submitted = %q", rows[0].Submitted.String)
354
+ }
355
+ if strings.Contains(rows[3].After.String, password) || strings.Contains(rows[3].After.String, "Wrong-Horse") {
356
+ t.Error("audit row holds a password")
357
+ }
358
+ if e.th.fails != len(tests) {
359
+ t.Errorf("throttle fails = %d, want %d (unknown accounts count too)", e.th.fails, len(tests))
360
+ }
361
+ }
362
+
363
+ func TestLoginMalformedBodyIs400AndAudited(t *testing.T) {
364
+ e := newLoginEnv(t)
365
+ for _, body := range []string{`{"empNo":"123456"}`, `{"empNo":123456,"password":"x"}`, `{`, `{"empNo":null,"password":"x"}`} {
366
+ rec := e.login(t, body)
367
+ if rec.Code != 400 || code(t, rec) != "MALFORMED_REQUEST" {
368
+ t.Errorf("%s: %d %s", body, rec.Code, rec.Body)
369
+ }
370
+ }
371
+ rows := e.audits(t)
372
+ if len(rows) != 4 || rows[0].Outcome != "FAILED" {
373
+ t.Errorf("audit = %+v", rows)
374
+ }
375
+ }
376
+
377
+ func TestLoginThrottled(t *testing.T) {
378
+ e := newLoginEnv(t)
379
+ e.addUser(t, "123456", "USER", true)
380
+ e.th.retry = 90*time.Second + 300*time.Millisecond
381
+
382
+ rec := e.login(t, creds("123456", password)) // correct password still throttled
383
+ if rec.Code != 429 || code(t, rec) != "LOGIN_THROTTLED" {
384
+ t.Fatalf("%d %s", rec.Code, rec.Body)
385
+ }
386
+ if got := rec.Header().Get("Retry-After"); got != "91" {
387
+ t.Errorf("Retry-After = %q, want 91 (rounded up)", got)
388
+ }
389
+ if e.sessions(t) != 0 || e.th.fails != 0 || e.th.oks != 0 {
390
+ t.Errorf("throttled attempt: sessions=%d fails=%d oks=%d", e.sessions(t), e.th.fails, e.th.oks)
391
+ }
392
+ rows := e.audits(t)
393
+ if len(rows) != 1 || rows[0].Outcome != "THROTTLED" || rows[0].Status.Int64 != 429 || rows[0].Submitted.String != "123456" {
394
+ t.Errorf("audit = %+v", rows)
395
+ }
396
+ }
397
+
398
+ func TestLoginThrottleErrorFailsClosed(t *testing.T) {
399
+ e := newLoginEnv(t)
400
+ e.addUser(t, "123456", "USER", true)
401
+ e.th.err = context.DeadlineExceeded
402
+ rec := e.login(t, creds("123456", password))
403
+ if rec.Code != 500 || e.sessions(t) != 0 {
404
+ t.Errorf("status %d sessions %d: want 500 and no session", rec.Code, e.sessions(t))
405
+ }
406
+ }
407
+
408
+ // The session and its audit row commit together: when the audit insert fails,
409
+ // no session survives.
410
+ func TestLoginAuditFailureRollsBackSession(t *testing.T) {
411
+ e := newLoginEnv(t)
412
+ e.addUser(t, "123456", "USER", true)
413
+ if _, err := e.db.Exec(`CREATE TRIGGER no_login_audit BEFORE INSERT ON audit_log FOR EACH ROW
414
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit down'`); err != nil {
415
+ t.Fatal(err)
416
+ }
417
+ rec := e.login(t, creds("123456", password))
418
+ if rec.Code != 500 || e.sessions(t) != 0 {
419
+ t.Errorf("status %d sessions %d: want 500 and no session", rec.Code, e.sessions(t))
420
+ }
421
+ }