@johnnyboy11234/esg-idea-contest 0.2.1 → 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.
@@ -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,12 @@ 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
+ }
49
54
  si := server.API{
55
+ Auth: auth.Handler{CSRF: csrf, Log: log},
50
56
  Account: account.Handler{
51
57
  Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
52
58
  Fail: errs.Write,
@@ -55,5 +61,7 @@ func run() error {
55
61
  }
56
62
 
57
63
  var ready atomic.Bool
58
- return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si)), &ready, server.DBCheck(db))
64
+ return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si),
65
+ auth.Middleware(auth.New(db), server.IsPublic, log),
66
+ csrf.Middleware(errs.Write, server.ActionFor)), &ready, server.DBCheck(db))
59
67
  }
@@ -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
+ }
@@ -0,0 +1,281 @@
1
+ package auth_test
2
+
3
+ import (
4
+ "context"
5
+ "crypto/rand"
6
+ "encoding/base64"
7
+ "encoding/json"
8
+ "errors"
9
+ "log/slog"
10
+ "net/http"
11
+ "net/http/httptest"
12
+ "strings"
13
+ "testing"
14
+
15
+ "github.com/go-chi/chi/v5"
16
+ "github.com/google/uuid"
17
+
18
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
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
+ keyA = "0123456789abcdef0123456789abcdef"
25
+ keyB = "fedcba9876543210fedcba9876543210"
26
+ )
27
+
28
+ func sid(t *testing.T) string {
29
+ t.Helper()
30
+ b := make([]byte, 32)
31
+ if _, err := rand.Read(b); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ return base64.RawURLEncoding.EncodeToString(b)
35
+ }
36
+
37
+ func mustCSRF(t *testing.T, cur string, prev ...string) *auth.CSRF {
38
+ t.Helper()
39
+ c, err := auth.NewCSRF(cur, prev...)
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ return c
44
+ }
45
+
46
+ type denial struct{ action, reason string }
47
+
48
+ // csrfChain mirrors production order: session middleware, then CSRF, then router.
49
+ func csrfChain(c *auth.CSRF, v auth.Validator, got *denial) http.Handler {
50
+ r := chi.NewRouter()
51
+ r.NotFound(web.NotFound)
52
+ r.MethodNotAllowed(web.MethodNotAllowed)
53
+ ok := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
54
+ r.Get("/auth/csrf", auth.Handler{CSRF: c, Log: slog.New(slog.DiscardHandler)}.GetCsrfToken)
55
+ r.Post("/w", ok)
56
+ r.Put("/w", ok)
57
+ r.Delete("/w", ok)
58
+ r.Post("/auth/logout", ok)
59
+ fail := func(w http.ResponseWriter, req *http.Request, err error) {
60
+ var e *web.Error
61
+ if errors.As(err, &e) && got != nil {
62
+ *got = denial{e.AuditAction, e.AuditReason}
63
+ }
64
+ web.WriteError(w, req, err)
65
+ }
66
+ action := func(req *http.Request) string {
67
+ if req.URL.Path == "/auth/logout" {
68
+ return "AUTH_LOGOUT"
69
+ }
70
+ return ""
71
+ }
72
+ public := func(req *http.Request) bool { return req.URL.Path == "/auth/csrf" || req.URL.Path == "/auth/logout" }
73
+ log := slog.New(slog.DiscardHandler)
74
+ return web.RequestID(auth.Middleware(v, public, log)(c.Middleware(fail, action)(r)))
75
+ }
76
+
77
+ func fetch(t *testing.T, h http.Handler, session string) string {
78
+ t.Helper()
79
+ req := httptest.NewRequest("GET", "/auth/csrf", nil)
80
+ if session != "" {
81
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
82
+ }
83
+ rec := httptest.NewRecorder()
84
+ h.ServeHTTP(rec, req)
85
+ if rec.Code != 200 {
86
+ t.Fatalf("GET /auth/csrf = %d %s", rec.Code, rec.Body)
87
+ }
88
+ var body struct{ Token string }
89
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
90
+ t.Fatalf("body %s: %v", rec.Body, err)
91
+ }
92
+ var ck *http.Cookie
93
+ for _, c := range rec.Result().Cookies() {
94
+ if c.Name == auth.CSRFCookie {
95
+ ck = c
96
+ }
97
+ }
98
+ if ck == nil || ck.Value != body.Token {
99
+ t.Fatalf("cookie %v does not match body token", ck)
100
+ }
101
+ if !ck.HttpOnly || !ck.Secure || ck.Path != "/" || ck.Domain != "" || ck.SameSite != http.SameSiteLaxMode {
102
+ t.Errorf("cookie attributes: %+v", ck)
103
+ }
104
+ if rec.Header().Get("Cache-Control") != "no-store" {
105
+ t.Errorf("Cache-Control = %q", rec.Header().Get("Cache-Control"))
106
+ }
107
+ return body.Token
108
+ }
109
+
110
+ func write(method, path, session, cookie, header string) *http.Request {
111
+ req := httptest.NewRequest(method, path, strings.NewReader("ignored"))
112
+ if session != "" {
113
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
114
+ }
115
+ if cookie != "" {
116
+ req.AddCookie(&http.Cookie{Name: auth.CSRFCookie, Value: cookie})
117
+ }
118
+ if header != "" {
119
+ req.Header.Set(auth.CSRFHeader, header)
120
+ }
121
+ return req
122
+ }
123
+
124
+ func TestCSRFWrites(t *testing.T) {
125
+ c := mustCSRF(t, keyA)
126
+ s1, s2 := sid(t), sid(t)
127
+ user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
128
+ v := &fakeValidator{live: map[string]security.Principal{s1: user, s2: user}}
129
+ h := csrfChain(c, v, nil)
130
+
131
+ anon, tok1, tok2 := fetch(t, h, ""), fetch(t, h, s1), fetch(t, h, s2)
132
+ other := mustCSRF(t, keyB)
133
+ foreign, _ := other.Issue([]byte("x"))
134
+
135
+ tests := []struct {
136
+ name string
137
+ method, path string
138
+ session, cookie, header string
139
+ status int
140
+ }{
141
+ {"session token, own session", "POST", "/w", s1, tok1, tok1, 204},
142
+ {"anonymous token, no session", "POST", "/auth/logout", "", anon, anon, 204},
143
+ {"PUT and DELETE are checked", "PUT", "/w", s1, "", "", 403},
144
+ {"DELETE with token", "DELETE", "/w", s1, tok1, tok1, 204},
145
+ {"no header", "POST", "/w", s1, tok1, "", 403},
146
+ {"no cookie", "POST", "/w", s1, "", tok1, 403},
147
+ {"cookie and header differ", "POST", "/w", s1, tok1, tok2, 403},
148
+ {"other session's token", "POST", "/w", s1, tok2, tok2, 403},
149
+ {"anonymous token against a session", "POST", "/w", s1, anon, anon, 403},
150
+ {"session token without a session (logout)", "POST", "/auth/logout", "", tok1, tok1, 403},
151
+ {"session token, stale session cookie (logout)", "POST", "/auth/logout", sid(t), tok1, tok1, 403},
152
+ {"signed by an unknown key", "POST", "/w", s1, foreign, foreign, 403},
153
+ {"garbage", "POST", "/w", s1, "abc", "abc", 403},
154
+ {"truncated signature", "POST", "/w", s1, tok1[:len(tok1)-2], tok1[:len(tok1)-2], 403},
155
+ {"no dot", "POST", "/w", s1, strings.ReplaceAll(tok1, ".", ""), strings.ReplaceAll(tok1, ".", ""), 403},
156
+ {"403 beats 404 on unknown path", "POST", "/nope", s1, "", "", 403},
157
+ {"unknown path, valid token, is 404", "POST", "/nope", s1, tok1, tok1, 404},
158
+ {"401 beats 403", "POST", "/w", "", "", "", 401},
159
+ }
160
+ for _, tc := range tests {
161
+ t.Run(tc.name, func(t *testing.T) {
162
+ rec := httptest.NewRecorder()
163
+ h.ServeHTTP(rec, write(tc.method, tc.path, tc.session, tc.cookie, tc.header))
164
+ if rec.Code != tc.status {
165
+ t.Fatalf("status = %d, want %d (%s)", rec.Code, tc.status, rec.Body)
166
+ }
167
+ if tc.status == 403 && !strings.Contains(rec.Body.String(), `"code":"CSRF_INVALID"`) {
168
+ t.Errorf("body %s", rec.Body)
169
+ }
170
+ })
171
+ }
172
+ }
173
+
174
+ func TestCSRFSafeMethodsAreNotChecked(t *testing.T) {
175
+ h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
176
+ for _, m := range []string{"GET", "HEAD", "OPTIONS"} {
177
+ rec := httptest.NewRecorder()
178
+ h.ServeHTTP(rec, httptest.NewRequest(m, "/auth/csrf", nil))
179
+ if rec.Code == 403 {
180
+ t.Errorf("%s was CSRF-checked", m)
181
+ }
182
+ }
183
+ }
184
+
185
+ func TestCSRFNeverReadsBody(t *testing.T) {
186
+ h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
187
+ req := write("POST", "/auth/logout", "", "", "")
188
+ req.Body = failReader{t}
189
+ h.ServeHTTP(httptest.NewRecorder(), req)
190
+ }
191
+
192
+ type failReader struct{ t *testing.T }
193
+
194
+ func (f failReader) Read([]byte) (int, error) { f.t.Error("body was read"); return 0, context.Canceled }
195
+ func (failReader) Close() error { return nil }
196
+
197
+ func TestCSRFDenialAuditFields(t *testing.T) {
198
+ c := mustCSRF(t, keyA)
199
+ anon := fetch(t, csrfChain(c, &fakeValidator{}, nil), "")
200
+ for _, tc := range []struct{ name, cookie, header, reason string }{
201
+ {"missing", "", "", "csrf_missing"},
202
+ {"mismatch", anon, anon + "x", "csrf_mismatch"},
203
+ {"bad signature", "a.b", "a.b", "csrf_invalid_binding"},
204
+ } {
205
+ var d denial
206
+ rec := httptest.NewRecorder()
207
+ csrfChain(c, &fakeValidator{}, &d).ServeHTTP(rec, write("POST", "/auth/logout", "", tc.cookie, tc.header))
208
+ if rec.Code != 403 || d != (denial{"AUTH_LOGOUT", tc.reason}) {
209
+ t.Errorf("%s: status %d, audit %+v", tc.name, rec.Code, d)
210
+ }
211
+ }
212
+ // Unrouted write: denied, but nothing to audit.
213
+ var d denial
214
+ csrfChain(c, &fakeValidator{live: map[string]security.Principal{"x": {}}}, &d).
215
+ ServeHTTP(httptest.NewRecorder(), write("POST", "/w", "", "", ""))
216
+ if d != (denial{}) {
217
+ t.Errorf("unknown action was audited: %+v", d)
218
+ }
219
+ }
220
+
221
+ func TestCSRFTokensAreFresh(t *testing.T) {
222
+ h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
223
+ first, second := fetch(t, h, ""), fetch(t, h, "")
224
+ if first == second {
225
+ t.Error("two tokens are equal")
226
+ }
227
+ }
228
+
229
+ func TestCSRFKeyRotation(t *testing.T) {
230
+ b := []byte("binding")
231
+ old := mustCSRF(t, keyA)
232
+ tok, _ := old.Issue(b)
233
+
234
+ rotated := mustCSRF(t, keyB, keyA)
235
+ if !rotated.Verify(tok, b) {
236
+ t.Error("previous key must still verify")
237
+ }
238
+ if fresh, _ := rotated.Issue(b); !mustCSRF(t, keyB).Verify(fresh, b) || old.Verify(fresh, b) {
239
+ t.Error("new tokens must be signed by the current key only")
240
+ }
241
+ if mustCSRF(t, keyB).Verify(tok, b) {
242
+ t.Error("after the previous key is dropped the old token must fail")
243
+ }
244
+ if !mustCSRF(t, keyB, "").Verify(mustTok(t, mustCSRF(t, keyB), b), b) {
245
+ t.Error("empty previous key means none")
246
+ }
247
+ if old.Verify(tok, []byte("other")) {
248
+ t.Error("binding must be part of the signature")
249
+ }
250
+ }
251
+
252
+ func mustTok(t *testing.T, c *auth.CSRF, b []byte) string {
253
+ t.Helper()
254
+ tok, err := c.Issue(b)
255
+ if err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ return tok
259
+ }
260
+
261
+ func TestNewCSRFRejectsShortKeys(t *testing.T) {
262
+ if _, err := auth.NewCSRF("short"); err == nil {
263
+ t.Error("short current key accepted")
264
+ }
265
+ if _, err := auth.NewCSRF(keyA, "short"); err == nil {
266
+ t.Error("short previous key accepted")
267
+ }
268
+ }
269
+
270
+ func TestCSRFSessionWithUnbindableIDIs500(t *testing.T) {
271
+ // A validator that accepts an ID Binding cannot parse is a wiring bug: fail closed.
272
+ v := &fakeValidator{live: map[string]security.Principal{"not-an-id": {Role: security.RoleUser}}}
273
+ h := csrfChain(mustCSRF(t, keyA), v, nil)
274
+ req := httptest.NewRequest("GET", "/auth/csrf", nil)
275
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: "not-an-id"})
276
+ rec := httptest.NewRecorder()
277
+ h.ServeHTTP(rec, req)
278
+ if rec.Code != 500 {
279
+ t.Errorf("status = %d", rec.Code)
280
+ }
281
+ }
@@ -0,0 +1,66 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "log/slog"
6
+ "net/http"
7
+
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
10
+ )
11
+
12
+ // SessionCookie is the session cookie name (openapi sessionCookie). The
13
+ // __Host- prefix forces Secure, Path=/ and no Domain.
14
+ const SessionCookie = "__Host-session"
15
+
16
+ // Validator resolves a cookie value to its caller. *Sessions implements it.
17
+ type Validator interface {
18
+ Validate(ctx context.Context, id string) (p security.Principal, ok bool, err error)
19
+ }
20
+
21
+ // Public reports whether a request may be served without a session. It is
22
+ // decided on method and path before routing, so unknown paths are not public.
23
+ type Public func(r *http.Request) bool
24
+
25
+ // Middleware is the session middleware (Backend.md §3, §6). It must wrap the
26
+ // router, not sit inside it, so that an unknown path without a session answers
27
+ // 401 and only a caller with a session learns it is a 404 (PRD §6.1).
28
+ //
29
+ // - Public request: a valid session is still resolved (the principal is then
30
+ // in the context for the audit actor and the session-bound CSRF token); a
31
+ // missing or invalid one is simply anonymous.
32
+ // - Any other request: no valid session → 401 UNAUTHENTICATED. With a valid
33
+ // session the principal is stored in the context and routing continues, so
34
+ // 404 and 405 come from the router.
35
+ //
36
+ // It never reads the body. A database failure answers 500, never a guess.
37
+ func Middleware(v Validator, public Public, log *slog.Logger) func(http.Handler) http.Handler {
38
+ return func(next http.Handler) http.Handler {
39
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40
+ isPublic := public != nil && public(r)
41
+
42
+ var (
43
+ p security.Principal
44
+ ok bool
45
+ )
46
+ if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" {
47
+ var verr error
48
+ if p, ok, verr = v.Validate(r.Context(), c.Value); verr != nil {
49
+ log.ErrorContext(r.Context(), "session validation failed",
50
+ "requestId", web.RequestIDFrom(r.Context()), "error", verr)
51
+ web.WriteProblem(w, r, web.NewError(web.CodeInternalError, ""))
52
+ return
53
+ }
54
+ }
55
+
56
+ switch {
57
+ case ok:
58
+ r = r.WithContext(security.WithPrincipal(r.Context(), p))
59
+ case !isPublic:
60
+ web.WriteProblem(w, r, web.NewError(web.CodeUnauthenticated, "Authentication required."))
61
+ return
62
+ }
63
+ next.ServeHTTP(w, r)
64
+ })
65
+ }
66
+ }