@johnnyboy11234/esg-idea-contest 0.2.1 → 0.2.3

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 (49) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +17 -1
  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/csrf.go +212 -0
  6. package/backend/internal/auth/csrf_test.go +281 -0
  7. package/backend/internal/auth/internal/store/session.go +63 -5
  8. package/backend/internal/auth/logging_integration_test.go +88 -0
  9. package/backend/internal/auth/login.go +252 -0
  10. package/backend/internal/auth/login_integration_test.go +421 -0
  11. package/backend/internal/auth/login_test.go +68 -0
  12. package/backend/internal/auth/logout.go +68 -0
  13. package/backend/internal/auth/logout_integration_test.go +124 -0
  14. package/backend/internal/auth/me.go +40 -0
  15. package/backend/internal/auth/me_integration_test.go +70 -0
  16. package/backend/internal/auth/middleware.go +66 -0
  17. package/backend/internal/auth/middleware_test.go +153 -0
  18. package/backend/internal/auth/session.go +20 -1
  19. package/backend/internal/server/api.go +19 -0
  20. package/backend/internal/server/public.go +37 -0
  21. package/backend/internal/server/public_test.go +73 -0
  22. package/backend/internal/server/server.go +7 -3
  23. package/backend/internal/shared/config/config.go +23 -4
  24. package/backend/internal/shared/config/config_test.go +14 -8
  25. package/backend/internal/shared/web/problem.go +9 -0
  26. package/backend/internal/shared/web/retryafter_test.go +31 -0
  27. package/frontend/src/app/App.test.tsx +13 -0
  28. package/frontend/src/app/App.tsx +4 -0
  29. package/frontend/src/app/index.css +102 -102
  30. package/frontend/src/app/layouts.tsx +75 -18
  31. package/frontend/src/app/pages/auth.tsx +52 -78
  32. package/frontend/src/app/pages/login.test.tsx +218 -0
  33. package/frontend/src/app/pages/register.test.tsx +231 -0
  34. package/frontend/src/app/router.tsx +83 -34
  35. package/frontend/src/app/session.ts +71 -18
  36. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  38. package/frontend/src/features/auth/login.ts +23 -0
  39. package/frontend/src/features/auth/register.ts +105 -0
  40. package/frontend/src/shared/api/csrf.ts +51 -0
  41. package/frontend/src/shared/api/http.test.tsx +209 -0
  42. package/frontend/src/shared/api/http.ts +91 -0
  43. package/frontend/src/shared/api/index.ts +3 -0
  44. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  45. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  46. package/frontend/src/shared/text/index.ts +20 -0
  47. package/frontend/src/shared/text/text.test.ts +28 -1
  48. package/frontend/vite.config.ts +5 -1
  49. package/package.json +1 -1
@@ -29,6 +29,49 @@ paths:
29
29
  schema: { $ref: '#/components/schemas/CsrfTokenResponse' }
30
30
  '429': { $ref: '#/components/responses/Problem' }
31
31
  '500': { $ref: '#/components/responses/Problem' }
32
+ /auth/login:
33
+ post:
34
+ operationId: login
35
+ tags: [auth]
36
+ summary: Log in with employee ID and password (issues a new session)
37
+ description: >
38
+ Sets the session cookie and returns the caller. Unknown, malformed and disabled
39
+ accounts and wrong passwords all answer the same 401. The CSRF token changes
40
+ with the session, so the client refetches it.
41
+ security: []
42
+ parameters:
43
+ - $ref: '#/components/parameters/CsrfToken'
44
+ requestBody:
45
+ required: true
46
+ content:
47
+ application/json:
48
+ schema: { $ref: '#/components/schemas/LoginRequest' }
49
+ responses:
50
+ '200':
51
+ description: Logged in
52
+ headers:
53
+ Set-Cookie:
54
+ description: __Host-session=<opaque ID>; HttpOnly; Secure; SameSite=Lax; Path=/
55
+ schema: { type: string }
56
+ content:
57
+ application/json:
58
+ schema: { $ref: '#/components/schemas/LoginResponse' }
59
+ '400': { $ref: '#/components/responses/Problem' }
60
+ '401': { $ref: '#/components/responses/Problem' }
61
+ '403': { $ref: '#/components/responses/Problem' }
62
+ '413': { $ref: '#/components/responses/Problem' }
63
+ '415': { $ref: '#/components/responses/Problem' }
64
+ '429':
65
+ description: Throttled (LOGIN_THROTTLED)
66
+ headers:
67
+ Retry-After:
68
+ description: Seconds until a new attempt may succeed
69
+ schema: { type: integer, minimum: 1 }
70
+ content:
71
+ application/problem+json:
72
+ schema: { $ref: '#/components/schemas/Problem' }
73
+ '500': { $ref: '#/components/responses/Problem' }
74
+ '503': { $ref: '#/components/responses/Problem' }
32
75
  /auth/logout:
33
76
  post:
34
77
  operationId: logout
@@ -41,6 +84,19 @@ paths:
41
84
  '204': { description: Logged out }
42
85
  '403': { $ref: '#/components/responses/Problem' }
43
86
  '500': { $ref: '#/components/responses/Problem' }
87
+ /auth/me:
88
+ get:
89
+ operationId: getMe
90
+ tags: [auth]
91
+ summary: The caller (also the idle-timeout "continue" call)
92
+ responses:
93
+ '200':
94
+ description: The authenticated caller
95
+ content:
96
+ application/json:
97
+ schema: { $ref: '#/components/schemas/LoginResponse' }
98
+ '401': { $ref: '#/components/responses/Problem' }
99
+ '500': { $ref: '#/components/responses/Problem' }
44
100
  /auth/register:
45
101
  post:
46
102
  operationId: register
@@ -154,6 +210,20 @@ components:
154
210
  required: [token]
155
211
  properties:
156
212
  token: { type: string }
213
+ LoginRequest:
214
+ type: object
215
+ required: [empNo, password]
216
+ properties:
217
+ empNo: { type: string, description: 'Any string; a badly formatted ID is an ordinary login failure' }
218
+ password: { type: string, format: password }
219
+ LoginResponse:
220
+ type: object
221
+ required: [empNo, name, email, role]
222
+ properties:
223
+ empNo: { type: string }
224
+ name: { type: string }
225
+ email: { type: string, nullable: true, description: null for Admin }
226
+ role: { type: string, enum: [USER, ADMIN] }
157
227
  RegisterRequest:
158
228
  type: object
159
229
  required: [empNo, name, email, password]
@@ -15,6 +15,7 @@ import (
15
15
 
16
16
  "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
17
17
  "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
18
19
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
19
20
  "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
20
21
  "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/config"
@@ -46,7 +47,20 @@ func run() error {
46
47
 
47
48
  auditSvc := audit.New(db, log)
48
49
  errs := server.ErrorAdapter{Audit: auditSvc, Log: log}
50
+ csrf, err := auth.NewCSRF(cfg.CSRFKey, cfg.CSRFKeyPrevious)
51
+ if err != nil {
52
+ return err
53
+ }
54
+ sessions := auth.New(db)
55
+ logins, err := auth.NewLogins(sessions, auditSvc, auth.NoThrottle{}) // B1b-04 wires the real throttle
56
+ if err != nil {
57
+ return err
58
+ }
49
59
  si := server.API{
60
+ Auth: auth.Handler{CSRF: csrf, Log: log},
61
+ Logins: auth.LoginHandler{Logins: logins, Fail: errs.Write},
62
+ Logouts: auth.LogoutHandler{Logouts: &auth.Logouts{Sessions: sessions, Audit: auditSvc}, Fail: errs.Write},
63
+ Me: auth.MeHandler{Sessions: sessions, Fail: errs.Write},
50
64
  Account: account.Handler{
51
65
  Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
52
66
  Fail: errs.Write,
@@ -55,5 +69,7 @@ func run() error {
55
69
  }
56
70
 
57
71
  var ready atomic.Bool
58
- return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si)), &ready, server.DBCheck(db))
72
+ return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si),
73
+ auth.Middleware(sessions, server.IsPublic, log),
74
+ csrf.Middleware(errs.Write, server.ActionFor)), &ready, server.DBCheck(db))
59
75
  }
@@ -0,0 +1,70 @@
1
+ //go:build integration
2
+
3
+ package account_test
4
+
5
+ import (
6
+ "bytes"
7
+ "log/slog"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
14
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/test/apispec"
17
+ )
18
+
19
+ type syncBuf struct {
20
+ mu sync.Mutex
21
+ b bytes.Buffer
22
+ }
23
+
24
+ func (s *syncBuf) Write(p []byte) (int, error) {
25
+ s.mu.Lock()
26
+ defer s.mu.Unlock()
27
+ return s.b.Write(p)
28
+ }
29
+
30
+ func (s *syncBuf) String() string {
31
+ s.mu.Lock()
32
+ defer s.mu.Unlock()
33
+ return s.b.String()
34
+ }
35
+
36
+ // TestRegisterLogsNeverContainPassword drives register (created, duplicate,
37
+ // weak password, malformed JSON) at DEBUG level and asserts the submitted
38
+ // password never reaches a log line (PRD §6.5 / §8.2).
39
+ func TestRegisterLogsNeverContainPassword(t *testing.T) {
40
+ buf := &syncBuf{}
41
+ log := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
42
+ db := dbtest.New(t)
43
+ auditSvc := audit.New(db, log)
44
+ errs := server.ErrorAdapter{Audit: auditSvc, Log: log}
45
+ api := server.API{Account: account.Handler{
46
+ Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
47
+ Fail: errs.Write,
48
+ }}
49
+ e := env{db: db, h: apispec.Load(t).Validate(t, server.NewPublic(log, server.Mount(api)))}
50
+
51
+ const (
52
+ pw1 = "Sup3r-Secret-Horse-1"
53
+ pw2 = "An0ther-Secret-Horse-2"
54
+ pw3 = "Dup-Secret-Horse-3"
55
+ )
56
+ e.register(reg("089290", "Wang", "wang@example.com", pw1)) // 201
57
+ e.register(reg("089290", "Wang", "wang2@example.com", pw2)) // duplicate
58
+ e.register(reg("089291", "Lee", "lee@example.com", "short")) // weak
59
+ e.register(`{"empNo":"089292","name":"X","email":"x@example.com","password":"` + pw3 + `"`) // malformed
60
+
61
+ out := buf.String()
62
+ if strings.TrimSpace(out) == "" {
63
+ t.Fatal("no log output captured; the test would prove nothing")
64
+ }
65
+ for _, s := range []string{pw1, pw2, pw3} {
66
+ if strings.Contains(out, s) {
67
+ t.Errorf("log contains password %q", s)
68
+ }
69
+ }
70
+ }
@@ -53,6 +53,24 @@ func (e IdeaSummaryMyRelation) Valid() bool {
53
53
  }
54
54
  }
55
55
 
56
+ // Defines values for LoginResponseRole.
57
+ const (
58
+ ADMIN LoginResponseRole = "ADMIN"
59
+ USER LoginResponseRole = "USER"
60
+ )
61
+
62
+ // Valid indicates whether the value is a known member of the LoginResponseRole enum.
63
+ func (e LoginResponseRole) Valid() bool {
64
+ switch e {
65
+ case ADMIN:
66
+ return true
67
+ case USER:
68
+ return true
69
+ default:
70
+ return false
71
+ }
72
+ }
73
+
56
74
  // CsrfTokenResponse defines model for CsrfTokenResponse.
57
75
  type CsrfTokenResponse struct {
58
76
  Token string `json:"token"`
@@ -140,6 +158,25 @@ type IdeaSummary struct {
140
158
  // IdeaSummaryMyRelation null for Admin
141
159
  type IdeaSummaryMyRelation string
142
160
 
161
+ // LoginRequest defines model for LoginRequest.
162
+ type LoginRequest struct {
163
+ // EmpNo Any string; a badly formatted ID is an ordinary login failure
164
+ EmpNo string `json:"empNo"`
165
+ Password string `json:"password"`
166
+ }
167
+
168
+ // LoginResponse defines model for LoginResponse.
169
+ type LoginResponse struct {
170
+ // Email null for Admin
171
+ Email *string `json:"email"`
172
+ EmpNo string `json:"empNo"`
173
+ Name string `json:"name"`
174
+ Role LoginResponseRole `json:"role"`
175
+ }
176
+
177
+ // LoginResponseRole defines model for LoginResponse.Role.
178
+ type LoginResponseRole string
179
+
143
180
  // PersonRef defines model for PersonRef.
144
181
  type PersonRef struct {
145
182
  EmpNo string `json:"empNo"`
@@ -186,6 +223,12 @@ type CsrfToken = string
186
223
  // IdeaId defines model for IdeaId.
187
224
  type IdeaId = openapi_types.UUID
188
225
 
226
+ // LoginParams defines parameters for Login.
227
+ type LoginParams struct {
228
+ // XCSRFToken Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400).
229
+ XCSRFToken *CsrfToken `json:"X-CSRF-Token,omitempty"`
230
+ }
231
+
189
232
  // LogoutParams defines parameters for Logout.
190
233
  type LogoutParams struct {
191
234
  // XCSRFToken Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400).
@@ -204,6 +247,9 @@ type ListIdeasParams struct {
204
247
  Size *int `form:"size,omitempty" json:"size,omitempty"`
205
248
  }
206
249
 
250
+ // LoginJSONRequestBody defines body for Login for application/json ContentType.
251
+ type LoginJSONRequestBody = LoginRequest
252
+
207
253
  // RegisterJSONRequestBody defines body for Register for application/json ContentType.
208
254
  type RegisterJSONRequestBody = RegisterRequest
209
255
 
@@ -212,9 +258,15 @@ type ServerInterface interface {
212
258
  // GetCsrfToken Session-bound CSRF token (anonymous before login)
213
259
  // (GET /auth/csrf)
214
260
  GetCsrfToken(w http.ResponseWriter, r *http.Request)
261
+ // Login Log in with employee ID and password (issues a new session)
262
+ // (POST /auth/login)
263
+ Login(w http.ResponseWriter, r *http.Request, params LoginParams)
215
264
  // Logout End the session
216
265
  // (POST /auth/logout)
217
266
  Logout(w http.ResponseWriter, r *http.Request, params LogoutParams)
267
+ // GetMe The caller (also the idle-timeout "continue" call)
268
+ // (GET /auth/me)
269
+ GetMe(w http.ResponseWriter, r *http.Request)
218
270
  // Register Self-service registration (no session is created)
219
271
  // (POST /auth/register)
220
272
  Register(w http.ResponseWriter, r *http.Request, params RegisterParams)
@@ -239,12 +291,24 @@ func (_ Unimplemented) GetCsrfToken(w http.ResponseWriter, r *http.Request) {
239
291
  w.WriteHeader(http.StatusNotImplemented)
240
292
  }
241
293
 
294
+ // Login Log in with employee ID and password (issues a new session)
295
+ // (POST /auth/login)
296
+ func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request, params LoginParams) {
297
+ w.WriteHeader(http.StatusNotImplemented)
298
+ }
299
+
242
300
  // Logout End the session
243
301
  // (POST /auth/logout)
244
302
  func (_ Unimplemented) Logout(w http.ResponseWriter, r *http.Request, params LogoutParams) {
245
303
  w.WriteHeader(http.StatusNotImplemented)
246
304
  }
247
305
 
306
+ // GetMe The caller (also the idle-timeout "continue" call)
307
+ // (GET /auth/me)
308
+ func (_ Unimplemented) GetMe(w http.ResponseWriter, r *http.Request) {
309
+ w.WriteHeader(http.StatusNotImplemented)
310
+ }
311
+
248
312
  // Register Self-service registration (no session is created)
249
313
  // (POST /auth/register)
250
314
  func (_ Unimplemented) Register(w http.ResponseWriter, r *http.Request, params RegisterParams) {
@@ -292,6 +356,47 @@ func (siw *ServerInterfaceWrapper) GetCsrfToken(w http.ResponseWriter, r *http.R
292
356
  handler.ServeHTTP(w, r)
293
357
  }
294
358
 
359
+ // Login operation middleware
360
+ func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) {
361
+
362
+ var err error
363
+ _ = err
364
+
365
+ // Parameter object where we will unmarshal all parameters from the context
366
+ var params LoginParams
367
+
368
+ headers := r.Header
369
+
370
+ // ------------- Optional header parameter "X-CSRF-Token" -------------
371
+ if valueList, found := headers[http.CanonicalHeaderKey("X-CSRF-Token")]; found {
372
+ var XCSRFToken CsrfToken
373
+ n := len(valueList)
374
+ if n != 1 {
375
+ siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "X-CSRF-Token", Count: n})
376
+ return
377
+ }
378
+
379
+ err = runtime.BindStyledParameterWithOptions("simple", "X-CSRF-Token", valueList[0], &XCSRFToken, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""})
380
+ if err != nil {
381
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "X-CSRF-Token", Err: err})
382
+ return
383
+ }
384
+
385
+ params.XCSRFToken = &XCSRFToken
386
+
387
+ }
388
+
389
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
390
+ siw.Handler.Login(w, r, params)
391
+ }))
392
+
393
+ for _, middleware := range siw.HandlerMiddlewares {
394
+ handler = middleware(handler)
395
+ }
396
+
397
+ handler.ServeHTTP(w, r)
398
+ }
399
+
295
400
  // Logout operation middleware
296
401
  func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request) {
297
402
 
@@ -333,6 +438,20 @@ func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request
333
438
  handler.ServeHTTP(w, r)
334
439
  }
335
440
 
441
+ // GetMe operation middleware
442
+ func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) {
443
+
444
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
445
+ siw.Handler.GetMe(w, r)
446
+ }))
447
+
448
+ for _, middleware := range siw.HandlerMiddlewares {
449
+ handler = middleware(handler)
450
+ }
451
+
452
+ handler.ServeHTTP(w, r)
453
+ }
454
+
336
455
  // Register operation middleware
337
456
  func (siw *ServerInterfaceWrapper) Register(w http.ResponseWriter, r *http.Request) {
338
457
 
@@ -576,9 +695,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
576
695
  r.Group(func(r chi.Router) {
577
696
  r.Get(options.BaseURL+"/auth/csrf", wrapper.GetCsrfToken)
578
697
  })
698
+ r.Group(func(r chi.Router) {
699
+ r.Post(options.BaseURL+"/auth/login", wrapper.Login)
700
+ })
579
701
  r.Group(func(r chi.Router) {
580
702
  r.Post(options.BaseURL+"/auth/logout", wrapper.Logout)
581
703
  })
704
+ r.Group(func(r chi.Router) {
705
+ r.Get(options.BaseURL+"/auth/me", wrapper.GetMe)
706
+ })
582
707
  r.Group(func(r chi.Router) {
583
708
  r.Post(options.BaseURL+"/auth/register", wrapper.Register)
584
709
  })
@@ -0,0 +1,212 @@
1
+ package auth
2
+
3
+ import (
4
+ "crypto/hmac"
5
+ "crypto/rand"
6
+ "crypto/sha256"
7
+ "crypto/subtle"
8
+ "encoding/base64"
9
+ "errors"
10
+ "fmt"
11
+ "log/slog"
12
+ "net/http"
13
+ "strings"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
18
+ )
19
+
20
+ // CSRF names and sizes (PRD §6.5, Backend.md §6, D40).
21
+ const (
22
+ // CSRFCookie is the double-submit cookie. The __Host- prefix forces Secure,
23
+ // Path=/ and no Domain; HttpOnly keeps it away from script, which reads the
24
+ // token from the GET /auth/csrf body instead.
25
+ CSRFCookie = "__Host-csrf"
26
+ // CSRFHeader carries the token on every write.
27
+ CSRFHeader = "X-CSRF-Token"
28
+
29
+ // MinCSRFKeyBytes is the shortest accepted HMAC key (the SHA-256 output size).
30
+ MinCSRFKeyBytes = 32
31
+
32
+ nonceBytes = 16
33
+ )
34
+
35
+ var anonymousBinding = []byte{0x00, 'a', 'n', 'o', 'n'}
36
+
37
+ // CSRF signs and verifies session-bound double-submit tokens:
38
+ //
39
+ // base64url(nonce) "." base64url(HMAC-SHA256(key, binding ‖ nonce))
40
+ //
41
+ // The binding is 0x01 ‖ SHA-256(session ID) with a session, a fixed anonymous
42
+ // value without one. A token minted anonymously, or for another session,
43
+ // therefore fails against a victim's session (D40).
44
+ //
45
+ // The first key signs; every key verifies. Rotation: make the new key current
46
+ // and the old one previous, and drop the previous key after 8 h (Backend.md §6).
47
+ // Tokens carry no expiry, so the operator's removal of the old key is the bound.
48
+ type CSRF struct {
49
+ keys [][]byte
50
+ }
51
+
52
+ // NewCSRF returns a signer. current signs; previous (optional) only verifies.
53
+ func NewCSRF(current string, previous ...string) (*CSRF, error) {
54
+ c := &CSRF{}
55
+ for i, k := range append([]string{current}, previous...) {
56
+ if i > 0 && k == "" {
57
+ continue // an unset previous key means "no rotation in progress"
58
+ }
59
+ if len(k) < MinCSRFKeyBytes {
60
+ return nil, fmt.Errorf("csrf key %d: need at least %d bytes", i, MinCSRFKeyBytes)
61
+ }
62
+ c.keys = append(c.keys, []byte(k))
63
+ }
64
+ return c, nil
65
+ }
66
+
67
+ // bindingFor returns the binding for a request: the session's when the session
68
+ // middleware resolved one, else the anonymous value. ok is false only when a
69
+ // validated session carries an ID that cannot be bound (cannot happen unless
70
+ // the validator and Binding disagree).
71
+ func bindingFor(r *http.Request) (b []byte, ok bool) {
72
+ if _, has := security.PrincipalFrom(r.Context()); !has {
73
+ return anonymousBinding, true
74
+ }
75
+ c, err := r.Cookie(SessionCookie)
76
+ if err != nil {
77
+ return nil, false
78
+ }
79
+ h, ok := Binding(c.Value)
80
+ if !ok {
81
+ return nil, false
82
+ }
83
+ return append([]byte{0x01}, h...), true
84
+ }
85
+
86
+ func mac(key, binding, nonce []byte) []byte {
87
+ m := hmac.New(sha256.New, key)
88
+ m.Write(binding)
89
+ m.Write(nonce)
90
+ return m.Sum(nil)
91
+ }
92
+
93
+ // Issue returns a fresh token for binding.
94
+ func (c *CSRF) Issue(binding []byte) (string, error) {
95
+ nonce := make([]byte, nonceBytes)
96
+ if _, err := rand.Read(nonce); err != nil {
97
+ return "", err
98
+ }
99
+ return b64(nonce) + "." + b64(mac(c.keys[0], binding, nonce)), nil
100
+ }
101
+
102
+ // Verify reports whether token was issued by one of the keys for binding.
103
+ func (c *CSRF) Verify(token string, binding []byte) bool {
104
+ n, s, found := strings.Cut(token, ".")
105
+ if !found {
106
+ return false
107
+ }
108
+ nonce, err1 := base64.RawURLEncoding.DecodeString(n)
109
+ sig, err2 := base64.RawURLEncoding.DecodeString(s)
110
+ if err1 != nil || err2 != nil || len(nonce) != nonceBytes {
111
+ return false
112
+ }
113
+ ok := false
114
+ for _, k := range c.keys { // no early exit: time does not tell which key matched
115
+ if hmac.Equal(sig, mac(k, binding, nonce)) {
116
+ ok = true
117
+ }
118
+ }
119
+ return ok
120
+ }
121
+
122
+ func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
123
+
124
+ // safeMethod is the set that is never CSRF-checked (RFC 9110 §9.2.1).
125
+ func safeMethod(m string) bool {
126
+ return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
127
+ }
128
+
129
+ // ActionFor names the audit action of the write r attempts, or "" when it is
130
+ // unknown (unrouted path). A rejected attempt is audited only with an action.
131
+ type ActionFor func(r *http.Request) string
132
+
133
+ // Middleware enforces the CSRF check on every non-safe method. It runs after
134
+ // the session middleware (so 401 wins) and before routing (so 403 beats 404),
135
+ // reads only the header and the cookie and never the body. Accepted only if
136
+ // cookie and header are both present and equal, the signature verifies, and
137
+ // the binding matches the request's session (or anonymous without one).
138
+ //
139
+ // fail is the central error adapter; it audits the denial when the *web.Error
140
+ // carries an AuditReason and an action.
141
+ func (c *CSRF) Middleware(fail func(http.ResponseWriter, *http.Request, error), action ActionFor) func(http.Handler) http.Handler {
142
+ return func(next http.Handler) http.Handler {
143
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144
+ if safeMethod(r.Method) {
145
+ next.ServeHTTP(w, r)
146
+ return
147
+ }
148
+ if reason := c.check(r); reason != "" {
149
+ e := web.NewError(web.CodeCSRFInvalid, "CSRF token missing or invalid.")
150
+ if action != nil {
151
+ if a := action(r); a != "" {
152
+ e.AuditAction, e.AuditReason = a, reason
153
+ }
154
+ }
155
+ fail(w, r, e)
156
+ return
157
+ }
158
+ next.ServeHTTP(w, r)
159
+ })
160
+ }
161
+ }
162
+
163
+ // check returns "" when r passes, else the audit reason (never sent to the client).
164
+ func (c *CSRF) check(r *http.Request) string {
165
+ hdr := r.Header.Get(CSRFHeader)
166
+ ck, err := r.Cookie(CSRFCookie)
167
+ switch {
168
+ case hdr == "" || err != nil || ck.Value == "":
169
+ return "csrf_missing"
170
+ case subtle.ConstantTimeCompare([]byte(hdr), []byte(ck.Value)) != 1:
171
+ return "csrf_mismatch"
172
+ }
173
+ b, ok := bindingFor(r)
174
+ if !ok || !c.Verify(hdr, b) {
175
+ return "csrf_invalid_binding"
176
+ }
177
+ return ""
178
+ }
179
+
180
+ // Handler serves GET /auth/csrf: a token for the caller's current binding in
181
+ // the body and in the cookie. It is public; with a session the token is bound
182
+ // to it. Login and logout change the binding, so the client refetches then.
183
+ type Handler struct {
184
+ CSRF *CSRF
185
+ Log *slog.Logger
186
+ }
187
+
188
+ // GetCsrfToken serves GET /auth/csrf.
189
+ func (h Handler) GetCsrfToken(w http.ResponseWriter, r *http.Request) {
190
+ b, ok := bindingFor(r)
191
+ if !ok {
192
+ h.fail(w, r, errors.New("session without bindable ID"))
193
+ return
194
+ }
195
+ tok, err := h.CSRF.Issue(b)
196
+ if err != nil {
197
+ h.fail(w, r, err)
198
+ return
199
+ }
200
+ http.SetCookie(w, &http.Cookie{
201
+ Name: CSRFCookie, Value: tok, Path: "/",
202
+ Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode,
203
+ })
204
+ w.Header().Set("Cache-Control", "no-store")
205
+ web.WriteJSON(w, http.StatusOK, api.CsrfTokenResponse{Token: tok})
206
+ }
207
+
208
+ func (h Handler) fail(w http.ResponseWriter, r *http.Request, err error) {
209
+ h.Log.ErrorContext(r.Context(), "csrf token issue failed",
210
+ "requestId", web.RequestIDFrom(r.Context()), "error", err)
211
+ web.WriteProblem(w, r, web.NewError(web.CodeInternalError, ""))
212
+ }