@johnnyboy11234/esg-idea-contest 0.2.0 → 0.2.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.
Files changed (45) hide show
  1. package/backend/api/openapi.yaml +44 -0
  2. package/backend/cmd/server/main.go +20 -2
  3. package/backend/db/migration/V004__session.sql +19 -0
  4. package/backend/go.mod +1 -1
  5. package/backend/internal/account/account_test.go +191 -0
  6. package/backend/internal/account/handler.go +58 -0
  7. package/backend/internal/account/integration_test.go +268 -0
  8. package/backend/internal/account/internal/store/store.go +35 -0
  9. package/backend/internal/account/service.go +153 -0
  10. package/backend/internal/account/validate.go +100 -0
  11. package/backend/internal/api/api.gen.go +82 -0
  12. package/backend/internal/audit/integration_test.go +1 -1
  13. package/backend/internal/auth/csrf.go +212 -0
  14. package/backend/internal/auth/csrf_test.go +281 -0
  15. package/backend/internal/auth/internal/store/session.go +111 -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 +113 -0
  19. package/backend/internal/auth/session_integration_test.go +245 -0
  20. package/backend/internal/auth/session_test.go +36 -0
  21. package/backend/internal/server/api.go +12 -0
  22. package/backend/internal/server/errors.go +10 -25
  23. package/backend/internal/server/errors_test.go +18 -0
  24. package/backend/internal/server/public.go +35 -0
  25. package/backend/internal/server/public_test.go +73 -0
  26. package/backend/internal/server/server.go +7 -3
  27. package/backend/internal/shared/config/config.go +23 -4
  28. package/backend/internal/shared/config/config_test.go +14 -8
  29. package/backend/internal/shared/db/intx_test.go +25 -0
  30. package/backend/internal/shared/db/mysqlerr.go +26 -0
  31. package/backend/internal/shared/security/security.go +6 -2
  32. package/backend/internal/shared/security/security_test.go +6 -4
  33. package/backend/internal/shared/web/problem.go +6 -0
  34. package/backend/internal/shared/web/request.go +27 -0
  35. package/frontend/src/app/App.test.tsx +13 -0
  36. package/frontend/src/app/App.tsx +4 -0
  37. package/frontend/src/app/session.ts +28 -6
  38. package/frontend/src/shared/api/csrf.ts +51 -0
  39. package/frontend/src/shared/api/http.test.tsx +209 -0
  40. package/frontend/src/shared/api/http.ts +91 -0
  41. package/frontend/src/shared/api/index.ts +3 -0
  42. package/frontend/src/shared/text/index.ts +93 -0
  43. package/frontend/src/shared/text/text.test.ts +22 -0
  44. package/package.json +1 -1
  45. package/frontend/src/shared/text/.gitkeep +0 -0
@@ -0,0 +1,153 @@
1
+ // Package account owns app_user: registration today; profile, password change
2
+ // and admin sync land with their own PRs. Backend.md §2.
3
+ package account
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "encoding/json"
9
+ "fmt"
10
+ "net/http"
11
+ "net/netip"
12
+ "strings"
13
+
14
+ "github.com/google/uuid"
15
+ "golang.org/x/crypto/bcrypt"
16
+
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account/internal/store"
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
20
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/text"
21
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
22
+ )
23
+
24
+ // bcryptCost is the only cost the table accepts (PRD §6.4, OI-13).
25
+ const bcryptCost = 10
26
+
27
+ // Recorder writes the success audit row in the registration transaction.
28
+ // *audit.Service implements it.
29
+ type Recorder interface {
30
+ Record(ctx context.Context, tx *sql.Tx, e audit.Event) error
31
+ }
32
+
33
+ // Service implements the account use cases.
34
+ type Service struct {
35
+ DB *sql.DB
36
+ Audit Recorder
37
+ Policy PasswordPolicy
38
+ }
39
+
40
+ // RegisterCommand is the raw, unvalidated input.
41
+ type RegisterCommand struct {
42
+ EmpNo, Name, Email, Password string
43
+ }
44
+
45
+ // Request is the transport facts the audit row needs.
46
+ type Request struct {
47
+ Method, Route, UserAgent string
48
+ IP netip.Addr
49
+ }
50
+
51
+ // Registered is the stored account (never the hash).
52
+ type Registered struct {
53
+ ID uuid.UUID
54
+ EmpNo, Name, Email string
55
+ }
56
+
57
+ // Register creates a USER. Order (PRD §6.3): format → password policy →
58
+ // uniqueness. Every failure is a *web.Error carrying the AUTH_REGISTER FAILED
59
+ // audit fields, so the central adapter writes the track-B row; a success
60
+ // writes its SUCCESS row in the same transaction as the INSERT (fail-closed).
61
+ func (s *Service) Register(ctx context.Context, cmd RegisterCommand, req Request) (Registered, error) {
62
+ empNoOK := checkEmpNo(cmd.EmpNo) == text.None
63
+
64
+ var fields []web.FieldError
65
+ add := func(field string, p text.Problem) {
66
+ if p != text.None {
67
+ fields = append(fields, web.FieldError{Field: field, Code: web.ErrorCode(p)})
68
+ }
69
+ }
70
+ add(fieldEmpNo, checkEmpNo(cmd.EmpNo))
71
+ name, p := text.CheckName(cmd.Name, maxNameLen)
72
+ add(fieldName, p)
73
+ email, p := checkEmail(cmd.Email)
74
+ add(fieldEmail, p)
75
+
76
+ policyEmpNo := ""
77
+ if empNoOK {
78
+ policyEmpNo = cmd.EmpNo
79
+ }
80
+ fields = append(fields, s.Policy.Check(cmd.Password, policyEmpNo)...)
81
+
82
+ if len(fields) > 0 {
83
+ e := web.NewError(topCode(fields), "the request has invalid fields")
84
+ e.Fields = fields
85
+ return Registered{}, failed(e, "validation", cmd.EmpNo, empNoOK)
86
+ }
87
+
88
+ hash, err := bcrypt.GenerateFromPassword([]byte(cmd.Password), bcryptCost)
89
+ if err != nil {
90
+ return Registered{}, fmt.Errorf("hash password: %w", err)
91
+ }
92
+
93
+ u := Registered{ID: uuid.New(), EmpNo: cmd.EmpNo, Name: name, Email: email}
94
+ after, _ := json.Marshal(map[string]string{"empNo": u.EmpNo, "name": u.Name}) // no email (PRD §6.11)
95
+
96
+ _, err = db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
97
+ if err := store.InsertUser(ctx, tx, store.NewUser{
98
+ ID: u.ID, EmpNo: u.EmpNo, Name: u.Name, Email: u.Email, PasswordHash: string(hash),
99
+ }); err != nil {
100
+ return err
101
+ }
102
+ return s.Audit.Record(ctx, tx, audit.Event{
103
+ Action: audit.AuthRegister, Outcome: audit.Success,
104
+ TargetType: audit.TargetUser, TargetID: u.ID,
105
+ Method: req.Method, Route: req.Route, Status: http.StatusCreated,
106
+ IP: req.IP, UserAgent: req.UserAgent,
107
+ After: after,
108
+ })
109
+ })
110
+ if err != nil {
111
+ if e := duplicate(err); e != nil {
112
+ return Registered{}, failed(e, e.AuditReason, cmd.EmpNo, true)
113
+ }
114
+ return Registered{}, err
115
+ }
116
+ return u, nil
117
+ }
118
+
119
+ // duplicate maps a unique-key violation to 409. emp_no is checked first by the
120
+ // server (it is the first unique key), so a row clashing on both says EMP_NO_TAKEN.
121
+ func duplicate(err error) *web.Error {
122
+ key, ok := db.DuplicateKey(err)
123
+ if !ok {
124
+ return nil
125
+ }
126
+ var e *web.Error
127
+ switch {
128
+ case strings.Contains(key, "emp_no"):
129
+ e = web.NewError(web.CodeEmpNoTaken, "employee ID is already registered")
130
+ e.AuditReason = "emp_no_taken"
131
+ case strings.Contains(key, "email"):
132
+ e = web.NewError(web.CodeEmailTaken, "email is already registered")
133
+ e.AuditReason = "email_taken"
134
+ default:
135
+ return nil
136
+ }
137
+ e.Cause = err
138
+ return e
139
+ }
140
+
141
+ // failed marks e as an AUTH_REGISTER FAILED attempt (PRD §6.11): the typed
142
+ // employee ID goes into the dedicated column (sanitized by audit), and into
143
+ // after_json only when well-formed.
144
+ func failed(e *web.Error, reason, empNo string, wellFormed bool) *web.Error {
145
+ e.AuditReason = reason
146
+ e.AuditAction = string(audit.AuthRegister)
147
+ e.AuditOutcome = string(audit.Failed)
148
+ e.AuditSubmittedEmpNo = empNo
149
+ if wellFormed {
150
+ e.AuditDetail = map[string]string{"empNo": empNo}
151
+ }
152
+ return e
153
+ }
@@ -0,0 +1,100 @@
1
+ package account
2
+
3
+ import (
4
+ "strings"
5
+
6
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/text"
7
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
8
+ )
9
+
10
+ // Field limits (PRD §6.3).
11
+ const (
12
+ empNoLen = 6
13
+ maxNameLen = 50
14
+ maxEmailLen = 254
15
+ maxPassBytes = 72 // bcrypt silently ignores (x/crypto: rejects) anything longer
16
+ )
17
+
18
+ // Field names as they appear in errors[]; the order is the form order.
19
+ const (
20
+ fieldEmpNo = "empNo"
21
+ fieldName = "name"
22
+ fieldEmail = "email"
23
+ fieldPassword = "password"
24
+ )
25
+
26
+ // checkEmpNo: exactly six ASCII digits, nothing is trimmed (PRD §6.3).
27
+ // Empty is REQUIRED, anything else wrong is INVALID_FORMAT.
28
+ func checkEmpNo(s string) text.Problem {
29
+ if text.Trim(s) == "" {
30
+ return text.Required
31
+ }
32
+ if len(s) != empNoLen {
33
+ return text.InvalidFormat
34
+ }
35
+ for i := range len(s) {
36
+ if s[i] < '0' || s[i] > '9' {
37
+ return text.InvalidFormat
38
+ }
39
+ }
40
+ return text.None
41
+ }
42
+
43
+ // checkEmail trims, then requires printable ASCII with no whitespace, exactly
44
+ // one '@' with non-empty sides and at most 254 characters; the result is
45
+ // lower-cased (PRD §6.3, OI-14). Report order: REQUIRED, INVALID_FORMAT,
46
+ // OUT_OF_RANGE.
47
+ func checkEmail(s string) (string, text.Problem) {
48
+ e := text.Trim(s)
49
+ if e == "" {
50
+ return "", text.Required
51
+ }
52
+ for _, r := range e {
53
+ if r < 0x21 || r > 0x7E { // also rejects whitespace and control characters
54
+ return e, text.InvalidFormat
55
+ }
56
+ }
57
+ local, domain, ok := strings.Cut(e, "@")
58
+ if !ok || local == "" || domain == "" || strings.Contains(domain, "@") {
59
+ return e, text.InvalidFormat
60
+ }
61
+ if len(e) > maxEmailLen { // ASCII only, so bytes = code points
62
+ return e, text.OutOfRange
63
+ }
64
+ return strings.ToLower(e), text.None
65
+ }
66
+
67
+ // PasswordPolicy checks a candidate password and lists every violation, in
68
+ // PRD §6.4 table order. empNo is the submitted employee ID when it is
69
+ // well-formed and "" otherwise (the CONTAINS_EMP_NO rule is then skipped).
70
+ type PasswordPolicy interface {
71
+ Check(password, empNo string) []web.FieldError
72
+ }
73
+
74
+ // StubPolicy is the placeholder until B1b-01 delivers the §6.4 rules. It only
75
+ // refuses what the account cannot store: an empty password and one bcrypt
76
+ // cannot hash (longer than 72 bytes). Everything else is accepted.
77
+ type StubPolicy struct{}
78
+
79
+ // Check implements PasswordPolicy.
80
+ func (StubPolicy) Check(password, _ string) []web.FieldError {
81
+ switch {
82
+ case password == "":
83
+ return []web.FieldError{{Field: fieldPassword, Code: web.CodeRequired}}
84
+ case len(password) > maxPassBytes:
85
+ return []web.FieldError{{Field: fieldPassword, Code: web.CodeOutOfRange}}
86
+ }
87
+ return nil
88
+ }
89
+
90
+ // topCode is the response code of a 422: the first field error's code when it
91
+ // is a specific registered code, else VALIDATION_FAILED (error-codes.md §2).
92
+ func topCode(fields []web.FieldError) web.ErrorCode {
93
+ if len(fields) > 0 {
94
+ switch c := fields[0].Code; c {
95
+ case web.CodeInvalidNameChars, web.CodePasswordPolicy:
96
+ return c
97
+ }
98
+ }
99
+ return web.CodeValidationFailed
100
+ }
@@ -160,6 +160,26 @@ type Problem struct {
160
160
  Type string `json:"type"`
161
161
  }
162
162
 
163
+ // RegisterRequest defines model for RegisterRequest.
164
+ type RegisterRequest struct {
165
+ // Email Printable ASCII with exactly one @, at most 254 characters; stored lower-cased
166
+ Email string `json:"email"`
167
+
168
+ // EmpNo Exactly 6 ASCII digits
169
+ EmpNo string `json:"empNo"`
170
+
171
+ // Name 1-50 code points after clean-up
172
+ Name string `json:"name"`
173
+ Password string `json:"password"`
174
+ }
175
+
176
+ // RegisterResponse defines model for RegisterResponse.
177
+ type RegisterResponse struct {
178
+ Email string `json:"email"`
179
+ EmpNo string `json:"empNo"`
180
+ Name string `json:"name"`
181
+ }
182
+
163
183
  // CsrfToken defines model for CsrfToken.
164
184
  type CsrfToken = string
165
185
 
@@ -172,12 +192,21 @@ type LogoutParams struct {
172
192
  XCSRFToken *CsrfToken `json:"X-CSRF-Token,omitempty"`
173
193
  }
174
194
 
195
+ // RegisterParams defines parameters for Register.
196
+ type RegisterParams struct {
197
+ // 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).
198
+ XCSRFToken *CsrfToken `json:"X-CSRF-Token,omitempty"`
199
+ }
200
+
175
201
  // ListIdeasParams defines parameters for ListIdeas.
176
202
  type ListIdeasParams struct {
177
203
  Page *int `form:"page,omitempty" json:"page,omitempty"`
178
204
  Size *int `form:"size,omitempty" json:"size,omitempty"`
179
205
  }
180
206
 
207
+ // RegisterJSONRequestBody defines body for Register for application/json ContentType.
208
+ type RegisterJSONRequestBody = RegisterRequest
209
+
181
210
  // ServerInterface represents all server handlers.
182
211
  type ServerInterface interface {
183
212
  // GetCsrfToken Session-bound CSRF token (anonymous before login)
@@ -186,6 +215,9 @@ type ServerInterface interface {
186
215
  // Logout End the session
187
216
  // (POST /auth/logout)
188
217
  Logout(w http.ResponseWriter, r *http.Request, params LogoutParams)
218
+ // Register Self-service registration (no session is created)
219
+ // (POST /auth/register)
220
+ Register(w http.ResponseWriter, r *http.Request, params RegisterParams)
189
221
  // GetDeadline Submission deadline, state and database server time
190
222
  // (GET /deadline)
191
223
  GetDeadline(w http.ResponseWriter, r *http.Request)
@@ -213,6 +245,12 @@ func (_ Unimplemented) Logout(w http.ResponseWriter, r *http.Request, params Log
213
245
  w.WriteHeader(http.StatusNotImplemented)
214
246
  }
215
247
 
248
+ // Register Self-service registration (no session is created)
249
+ // (POST /auth/register)
250
+ func (_ Unimplemented) Register(w http.ResponseWriter, r *http.Request, params RegisterParams) {
251
+ w.WriteHeader(http.StatusNotImplemented)
252
+ }
253
+
216
254
  // GetDeadline Submission deadline, state and database server time
217
255
  // (GET /deadline)
218
256
  func (_ Unimplemented) GetDeadline(w http.ResponseWriter, r *http.Request) {
@@ -295,6 +333,47 @@ func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request
295
333
  handler.ServeHTTP(w, r)
296
334
  }
297
335
 
336
+ // Register operation middleware
337
+ func (siw *ServerInterfaceWrapper) Register(w http.ResponseWriter, r *http.Request) {
338
+
339
+ var err error
340
+ _ = err
341
+
342
+ // Parameter object where we will unmarshal all parameters from the context
343
+ var params RegisterParams
344
+
345
+ headers := r.Header
346
+
347
+ // ------------- Optional header parameter "X-CSRF-Token" -------------
348
+ if valueList, found := headers[http.CanonicalHeaderKey("X-CSRF-Token")]; found {
349
+ var XCSRFToken CsrfToken
350
+ n := len(valueList)
351
+ if n != 1 {
352
+ siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "X-CSRF-Token", Count: n})
353
+ return
354
+ }
355
+
356
+ err = runtime.BindStyledParameterWithOptions("simple", "X-CSRF-Token", valueList[0], &XCSRFToken, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""})
357
+ if err != nil {
358
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "X-CSRF-Token", Err: err})
359
+ return
360
+ }
361
+
362
+ params.XCSRFToken = &XCSRFToken
363
+
364
+ }
365
+
366
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
367
+ siw.Handler.Register(w, r, params)
368
+ }))
369
+
370
+ for _, middleware := range siw.HandlerMiddlewares {
371
+ handler = middleware(handler)
372
+ }
373
+
374
+ handler.ServeHTTP(w, r)
375
+ }
376
+
298
377
  // GetDeadline operation middleware
299
378
  func (siw *ServerInterfaceWrapper) GetDeadline(w http.ResponseWriter, r *http.Request) {
300
379
 
@@ -500,6 +579,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
500
579
  r.Group(func(r chi.Router) {
501
580
  r.Post(options.BaseURL+"/auth/logout", wrapper.Logout)
502
581
  })
582
+ r.Group(func(r chi.Router) {
583
+ r.Post(options.BaseURL+"/auth/register", wrapper.Register)
584
+ })
503
585
  r.Group(func(r chi.Router) {
504
586
  r.Get(options.BaseURL+"/deadline", wrapper.GetDeadline)
505
587
  })
@@ -84,7 +84,7 @@ func TestAudit(t *testing.T) {
84
84
 
85
85
  t.Run("track A is written in the caller's transaction and carries every field", func(t *testing.T) {
86
86
  reset(t, d)
87
- ctx := ctxWithReq(&security.Principal{UserID: 7, EmpNo: "E0001", Role: security.RoleUser})
87
+ ctx := ctxWithReq(&security.Principal{UserID: uuid.New(), EmpNo: "E0001", Role: security.RoleUser})
88
88
  target := uuid.New()
89
89
  out, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
90
90
  return svc.Record(ctx, tx, audit.Event{
@@ -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
+ }