@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,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,111 @@
1
+ // Package store holds the SQL of the auth module. Only the auth module may
2
+ // import it (Go's internal rule); no other module touches the session table.
3
+ package store
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "errors"
9
+ "fmt"
10
+
11
+ "github.com/google/uuid"
12
+
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
14
+ )
15
+
16
+ // Limits are the session timeouts in seconds (PRD §6.5).
17
+ type Limits struct {
18
+ IdleSeconds int
19
+ AbsoluteSeconds int
20
+ RefreshSeconds int
21
+ }
22
+
23
+ // Insert stores a new session row. hash is SHA-256 of the session ID.
24
+ func Insert(ctx context.Context, db *sql.DB, hash []byte, userID uuid.UUID) error {
25
+ _, err := db.ExecContext(ctx,
26
+ `INSERT INTO session (id_hash, user_id) VALUES (?, ?)`, hash, userID.String())
27
+ if err != nil {
28
+ return fmt.Errorf("insert session: %w", err)
29
+ }
30
+ return nil
31
+ }
32
+
33
+ // Touch validates and refreshes a session in ONE statement on database time.
34
+ // It reports true when the row matched: the session exists, is inside both
35
+ // timeouts and its account is enabled. RowsAffected counts matched rows
36
+ // (clientFoundRows=true in the DSN), so a row that needs no refresh still
37
+ // counts; a test locks this. last_access_at is written only when older than
38
+ // the refresh interval (OI-11).
39
+ func Touch(ctx context.Context, db *sql.DB, hash []byte, l Limits) (bool, error) {
40
+ res, err := db.ExecContext(ctx,
41
+ `UPDATE session s JOIN app_user u ON u.id = s.user_id
42
+ SET s.last_access_at = IF(s.last_access_at < NOW(6) - INTERVAL ? SECOND, NOW(6), s.last_access_at)
43
+ WHERE s.id_hash = ?
44
+ AND u.enabled
45
+ AND s.last_access_at > NOW(6) - INTERVAL ? SECOND
46
+ AND s.created_at > NOW(6) - INTERVAL ? SECOND`,
47
+ l.RefreshSeconds, hash, l.IdleSeconds, l.AbsoluteSeconds)
48
+ if err != nil {
49
+ return false, fmt.Errorf("touch session: %w", err)
50
+ }
51
+ n, err := res.RowsAffected()
52
+ if err != nil {
53
+ return false, fmt.Errorf("touch session rows: %w", err)
54
+ }
55
+ return n == 1, nil
56
+ }
57
+
58
+ // Principal reads the caller behind a session. The role comes from app_user,
59
+ // never from the session. found is false when the session vanished between
60
+ // Touch and this read (logout, revoke, purge).
61
+ func Principal(ctx context.Context, db *sql.DB, hash []byte) (p security.Principal, found bool, err error) {
62
+ var id, role string
63
+ err = db.QueryRowContext(ctx,
64
+ `SELECT u.id, u.emp_no, u.role
65
+ FROM session s JOIN app_user u ON u.id = s.user_id
66
+ WHERE s.id_hash = ? AND u.enabled`, hash).Scan(&id, &p.EmpNo, &role)
67
+ if errors.Is(err, sql.ErrNoRows) {
68
+ return security.Principal{}, false, nil
69
+ }
70
+ if err != nil {
71
+ return security.Principal{}, false, fmt.Errorf("read session principal: %w", err)
72
+ }
73
+ if p.UserID, err = uuid.Parse(id); err != nil {
74
+ return security.Principal{}, false, fmt.Errorf("parse user id %q: %w", id, err)
75
+ }
76
+ p.Role = security.Role(role)
77
+ return p, true, nil
78
+ }
79
+
80
+ // Delete removes one session; deleting a missing one is not an error.
81
+ func Delete(ctx context.Context, db *sql.DB, hash []byte) error {
82
+ if _, err := db.ExecContext(ctx, `DELETE FROM session WHERE id_hash = ?`, hash); err != nil {
83
+ return fmt.Errorf("delete session: %w", err)
84
+ }
85
+ return nil
86
+ }
87
+
88
+ // DeleteByUser removes every session of one account.
89
+ func DeleteByUser(ctx context.Context, db *sql.DB, userID uuid.UUID) error {
90
+ if _, err := db.ExecContext(ctx, `DELETE FROM session WHERE user_id = ?`, userID.String()); err != nil {
91
+ return fmt.Errorf("delete sessions of user: %w", err)
92
+ }
93
+ return nil
94
+ }
95
+
96
+ // DeleteExpired removes sessions past either timeout and returns how many.
97
+ func DeleteExpired(ctx context.Context, db *sql.DB, l Limits) (int64, error) {
98
+ res, err := db.ExecContext(ctx,
99
+ `DELETE FROM session
100
+ WHERE last_access_at <= NOW(6) - INTERVAL ? SECOND
101
+ OR created_at <= NOW(6) - INTERVAL ? SECOND`,
102
+ l.IdleSeconds, l.AbsoluteSeconds)
103
+ if err != nil {
104
+ return 0, fmt.Errorf("purge sessions: %w", err)
105
+ }
106
+ n, err := res.RowsAffected()
107
+ if err != nil {
108
+ return 0, fmt.Errorf("purge sessions rows: %w", err)
109
+ }
110
+ return n, nil
111
+ }
@@ -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
+ }
@@ -0,0 +1,153 @@
1
+ package auth_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "log/slog"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/go-chi/chi/v5"
13
+ "github.com/google/uuid"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
18
+ )
19
+
20
+ type fakeValidator struct {
21
+ live map[string]security.Principal
22
+ err error
23
+ calls int
24
+ }
25
+
26
+ func (f *fakeValidator) Validate(_ context.Context, id string) (security.Principal, bool, error) {
27
+ f.calls++
28
+ if f.err != nil {
29
+ return security.Principal{}, false, f.err
30
+ }
31
+ p, ok := f.live[id]
32
+ return p, ok, nil
33
+ }
34
+
35
+ func isPublic(r *http.Request) bool {
36
+ return r.Method == "GET" && r.URL.Path == "/open"
37
+ }
38
+
39
+ // chain mirrors production: middleware wraps the router, whose NotFound and
40
+ // MethodNotAllowed hooks are the web ones.
41
+ func chain(v auth.Validator, seen *security.Principal) http.Handler {
42
+ r := chi.NewRouter()
43
+ r.NotFound(web.NotFound)
44
+ r.MethodNotAllowed(web.MethodNotAllowed)
45
+ h := func(w http.ResponseWriter, req *http.Request) {
46
+ if p, ok := security.PrincipalFrom(req.Context()); ok && seen != nil {
47
+ *seen = p
48
+ }
49
+ w.WriteHeader(http.StatusNoContent)
50
+ }
51
+ r.Get("/open", h)
52
+ r.Get("/closed", h)
53
+ return web.RequestID(auth.Middleware(v, isPublic, slog.New(slog.DiscardHandler))(r))
54
+ }
55
+
56
+ func request(method, path, cookie string) *http.Request {
57
+ req := httptest.NewRequest(method, path, nil)
58
+ if cookie != "" {
59
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: cookie})
60
+ }
61
+ return req
62
+ }
63
+
64
+ func TestMiddlewareCombinations(t *testing.T) {
65
+ user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
66
+ v := &fakeValidator{live: map[string]security.Principal{"good": user}}
67
+
68
+ tests := []struct {
69
+ name string
70
+ method, path string
71
+ cookie string
72
+ status int
73
+ code string
74
+ principal bool
75
+ }{
76
+ {"public, no session", "GET", "/open", "", 204, "", false},
77
+ {"public, valid session", "GET", "/open", "good", 204, "", true},
78
+ {"public, invalid session is anonymous", "GET", "/open", "stale", 204, "", false},
79
+ {"public path, other method, no session", "POST", "/open", "", 401, "UNAUTHENTICATED", false},
80
+ {"public path, other method, session", "POST", "/open", "good", 405, "METHOD_NOT_ALLOWED", false},
81
+ {"protected, no session", "GET", "/closed", "", 401, "UNAUTHENTICATED", false},
82
+ {"protected, invalid session", "GET", "/closed", "stale", 401, "UNAUTHENTICATED", false},
83
+ {"protected, valid session", "GET", "/closed", "good", 204, "", true},
84
+ {"unknown path, no session", "GET", "/nope", "", 401, "UNAUTHENTICATED", false},
85
+ {"unknown path, invalid session", "GET", "/nope", "stale", 401, "UNAUTHENTICATED", false},
86
+ {"unknown path, session", "GET", "/nope", "good", 404, "NOT_FOUND", false},
87
+ }
88
+ for _, tc := range tests {
89
+ t.Run(tc.name, func(t *testing.T) {
90
+ var seen security.Principal
91
+ rec := httptest.NewRecorder()
92
+ chain(v, &seen).ServeHTTP(rec, request(tc.method, tc.path, tc.cookie))
93
+ if rec.Code != tc.status {
94
+ t.Fatalf("status = %d, want %d (%s)", rec.Code, tc.status, rec.Body)
95
+ }
96
+ if tc.code != "" && !strings.Contains(rec.Body.String(), `"code":"`+tc.code+`"`) {
97
+ t.Errorf("body %s lacks code %s", rec.Body, tc.code)
98
+ }
99
+ if tc.code != "" && rec.Header().Get("Content-Type") != web.ProblemContentType {
100
+ t.Errorf("Content-Type = %q", rec.Header().Get("Content-Type"))
101
+ }
102
+ if got := seen == user; got != tc.principal {
103
+ t.Errorf("principal in context = %v, want %v", got, tc.principal)
104
+ }
105
+ })
106
+ }
107
+ }
108
+
109
+ func TestMiddlewareNoCookieSkipsStore(t *testing.T) {
110
+ v := &fakeValidator{}
111
+ chain(v, nil).ServeHTTP(httptest.NewRecorder(), request("GET", "/open", ""))
112
+ // An empty cookie value is the same as none.
113
+ req := httptest.NewRequest("GET", "/open", nil)
114
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: ""})
115
+ chain(v, nil).ServeHTTP(httptest.NewRecorder(), req)
116
+ if v.calls != 0 {
117
+ t.Errorf("store called %d times without a cookie value", v.calls)
118
+ }
119
+ }
120
+
121
+ func TestMiddlewareIgnoresOtherCookies(t *testing.T) {
122
+ v := &fakeValidator{live: map[string]security.Principal{"good": {Role: security.RoleUser}}}
123
+ req := httptest.NewRequest("GET", "/closed", nil)
124
+ req.AddCookie(&http.Cookie{Name: "session", Value: "good"})
125
+ rec := httptest.NewRecorder()
126
+ chain(v, nil).ServeHTTP(rec, req)
127
+ if rec.Code != 401 || v.calls != 0 {
128
+ t.Errorf("status %d, store calls %d", rec.Code, v.calls)
129
+ }
130
+ }
131
+
132
+ func TestMiddlewareStoreFailureIs500NotPublicFallthrough(t *testing.T) {
133
+ v := &fakeValidator{err: errors.New("db down")}
134
+ for _, path := range []string{"/open", "/closed", "/nope"} {
135
+ rec := httptest.NewRecorder()
136
+ chain(v, nil).ServeHTTP(rec, request("GET", path, "good"))
137
+ if rec.Code != 500 || !strings.Contains(rec.Body.String(), "INTERNAL_ERROR") {
138
+ t.Errorf("%s: status %d body %s", path, rec.Code, rec.Body)
139
+ }
140
+ if strings.Contains(rec.Body.String(), "db down") {
141
+ t.Errorf("%s: cause leaked: %s", path, rec.Body)
142
+ }
143
+ }
144
+ }
145
+
146
+ func TestMiddlewareNilPublicMeansAllProtected(t *testing.T) {
147
+ h := auth.Middleware(&fakeValidator{}, nil, slog.New(slog.DiscardHandler))(http.NotFoundHandler())
148
+ rec := httptest.NewRecorder()
149
+ h.ServeHTTP(rec, request("GET", "/open", ""))
150
+ if rec.Code != 401 {
151
+ t.Errorf("status = %d", rec.Code)
152
+ }
153
+ }