@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,113 @@
1
+ // Package auth owns login, sessions, CSRF and throttling (Backend.md §6).
2
+ // This file is the session store (B1-07); the middleware (B1-08) builds on it.
3
+ package auth
4
+
5
+ import (
6
+ "context"
7
+ "crypto/rand"
8
+ "crypto/sha256"
9
+ "database/sql"
10
+ "encoding/base64"
11
+ "fmt"
12
+
13
+ "github.com/google/uuid"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth/internal/store"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
17
+ )
18
+
19
+ // Session timeouts (PRD §6.5). Refresh is written at most once per 60 s
20
+ // (OI-11), so the effective idle timeout is 29-30 minutes.
21
+ const (
22
+ IdleTimeoutSeconds = 30 * 60
23
+ AbsoluteTimeoutSeconds = 8 * 60 * 60
24
+ RefreshSeconds = 60
25
+
26
+ idBytes = 32
27
+ )
28
+
29
+ var limits = store.Limits{
30
+ IdleSeconds: IdleTimeoutSeconds,
31
+ AbsoluteSeconds: AbsoluteTimeoutSeconds,
32
+ RefreshSeconds: RefreshSeconds,
33
+ }
34
+
35
+ // Sessions is the server-side session store. It never sees or stores a raw
36
+ // session ID beyond the call that receives it; the table holds SHA-256 only.
37
+ type Sessions struct {
38
+ DB *sql.DB
39
+ }
40
+
41
+ // New returns a session store on db.
42
+ func New(db *sql.DB) *Sessions { return &Sessions{DB: db} }
43
+
44
+ // Create issues a new session for the user and returns the ID for the cookie
45
+ // (32 random bytes, base64url without padding). Every login calls it, so an ID
46
+ // is never reused (no fixation).
47
+ func (s *Sessions) Create(ctx context.Context, userID uuid.UUID) (string, error) {
48
+ raw := make([]byte, idBytes)
49
+ if _, err := rand.Read(raw); err != nil {
50
+ return "", fmt.Errorf("session id: %w", err)
51
+ }
52
+ if err := store.Insert(ctx, s.DB, hash(raw), userID); err != nil {
53
+ return "", err
54
+ }
55
+ return base64.RawURLEncoding.EncodeToString(raw), nil
56
+ }
57
+
58
+ // Validate checks the cookie value and refreshes the session in one atomic
59
+ // statement, then returns the caller. ok is false for anything that is not a
60
+ // live session of an enabled account: malformed, unknown, idle or absolutely
61
+ // expired, disabled user, or removed meanwhile. err is for database failures
62
+ // only.
63
+ func (s *Sessions) Validate(ctx context.Context, id string) (p security.Principal, ok bool, err error) {
64
+ h, valid := parse(id)
65
+ if !valid {
66
+ return security.Principal{}, false, nil
67
+ }
68
+ if ok, err = store.Touch(ctx, s.DB, h, limits); err != nil || !ok {
69
+ return security.Principal{}, false, err
70
+ }
71
+ return store.Principal(ctx, s.DB, h)
72
+ }
73
+
74
+ // Revoke deletes one session. It is idempotent: an unknown or malformed ID is
75
+ // not an error (logout returns 204 either way).
76
+ func (s *Sessions) Revoke(ctx context.Context, id string) error {
77
+ h, valid := parse(id)
78
+ if !valid {
79
+ return nil
80
+ }
81
+ return store.Delete(ctx, s.DB, h)
82
+ }
83
+
84
+ // RevokeAll deletes every session of the user. It implements
85
+ // account.SessionRevoker (wired in main.go; account never imports auth).
86
+ func (s *Sessions) RevokeAll(ctx context.Context, userID uuid.UUID) error {
87
+ return store.DeleteByUser(ctx, s.DB, userID)
88
+ }
89
+
90
+ // Purge deletes expired sessions and returns how many; a background ticker
91
+ // calls it. Validation never depends on it: expired rows already fail Touch.
92
+ func (s *Sessions) Purge(ctx context.Context) (int64, error) {
93
+ return store.DeleteExpired(ctx, s.DB, limits)
94
+ }
95
+
96
+ // Binding returns SHA-256 of the session ID, the value the CSRF token is bound
97
+ // to (D40). ok is false for a malformed ID.
98
+ func Binding(id string) (b []byte, ok bool) { return parse(id) }
99
+
100
+ // parse decodes a cookie value and returns its SHA-256. Anything that is not
101
+ // exactly 32 bytes of base64url is rejected without a database call.
102
+ func parse(id string) ([]byte, bool) {
103
+ raw, err := base64.RawURLEncoding.DecodeString(id)
104
+ if err != nil || len(raw) != idBytes {
105
+ return nil, false
106
+ }
107
+ return hash(raw), true
108
+ }
109
+
110
+ func hash(raw []byte) []byte {
111
+ sum := sha256.Sum256(raw)
112
+ return sum[:]
113
+ }
@@ -0,0 +1,245 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "context"
7
+ "crypto/sha256"
8
+ "database/sql"
9
+ "encoding/base64"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/google/uuid"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
18
+ )
19
+
20
+ const bcryptHash = "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234" // shape only; satisfies ck_app_user_hash
21
+
22
+ func addUser(t *testing.T, db *sql.DB, empNo, role string, enabled bool) uuid.UUID {
23
+ t.Helper()
24
+ id := uuid.New()
25
+ var email any = empNo + "@example.com"
26
+ if role == "ADMIN" {
27
+ email = nil
28
+ }
29
+ if _, err := db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role, enabled) VALUES (?,?,?,?,?,?,?)`,
30
+ id.String(), empNo, "N "+empNo, email, bcryptHash, role, enabled); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ return id
34
+ }
35
+
36
+ // age moves a session's timestamps into the past (DB time is the only clock,
37
+ // so tests change the rows rather than the clock).
38
+ func age(t *testing.T, db *sql.DB, id string, created, lastAccess time.Duration) {
39
+ t.Helper()
40
+ h, _ := auth.Binding(id)
41
+ if _, err := db.Exec(`UPDATE session SET created_at = NOW(6) - INTERVAL ? SECOND, last_access_at = NOW(6) - INTERVAL ? SECOND WHERE id_hash = ?`,
42
+ int(created.Seconds()), int(lastAccess.Seconds()), h); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ }
46
+
47
+ func lastAccess(t *testing.T, db *sql.DB, id string) time.Time {
48
+ t.Helper()
49
+ h, _ := auth.Binding(id)
50
+ var ts time.Time
51
+ if err := db.QueryRow(`SELECT last_access_at FROM session WHERE id_hash = ?`, h).Scan(&ts); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ return ts
55
+ }
56
+
57
+ func TestSessions(t *testing.T) {
58
+ db := dbtest.New(t)
59
+ ctx := context.Background()
60
+ s := auth.New(db)
61
+ user := addUser(t, db, "100001", "USER", true)
62
+
63
+ create := func(t *testing.T, u uuid.UUID) string {
64
+ t.Helper()
65
+ id, err := s.Create(ctx, u)
66
+ if err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ return id
70
+ }
71
+ valid := func(t *testing.T, id string) bool {
72
+ t.Helper()
73
+ _, ok, err := s.Validate(ctx, id)
74
+ if err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ return ok
78
+ }
79
+
80
+ t.Run("create stores only the SHA-256 and returns a 32-byte ID", func(t *testing.T) {
81
+ id := create(t, user)
82
+ raw, err := base64.RawURLEncoding.DecodeString(id)
83
+ if err != nil || len(raw) != 32 {
84
+ t.Fatalf("id %q: %d bytes, %v", id, len(raw), err)
85
+ }
86
+ sum := sha256.Sum256(raw)
87
+ var n int
88
+ if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, sum[:]).Scan(&n); err != nil || n != 1 {
89
+ t.Fatalf("hash row count = %d, %v", n, err)
90
+ }
91
+ if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, raw).Scan(&n); err != nil || n != 0 {
92
+ t.Fatalf("raw ID stored: %d, %v", n, err)
93
+ }
94
+ })
95
+
96
+ t.Run("every login gets a new ID", func(t *testing.T) {
97
+ if a, b := create(t, user), create(t, user); a == b {
98
+ t.Fatal("same ID twice")
99
+ }
100
+ })
101
+
102
+ t.Run("validate returns the principal with the role from app_user", func(t *testing.T) {
103
+ admin := addUser(t, db, "200001", "ADMIN", true)
104
+ p, ok, err := s.Validate(ctx, create(t, admin))
105
+ if err != nil || !ok {
106
+ t.Fatalf("validate: %v %v", ok, err)
107
+ }
108
+ want := security.Principal{UserID: admin, EmpNo: "200001", Role: security.RoleAdmin}
109
+ if p != want {
110
+ t.Fatalf("principal = %+v, want %+v", p, want)
111
+ }
112
+ })
113
+
114
+ t.Run("unknown and malformed IDs are not valid", func(t *testing.T) {
115
+ unknown := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
116
+ for _, id := range []string{unknown, "", "garbage"} {
117
+ if valid(t, id) {
118
+ t.Errorf("%q accepted", id)
119
+ }
120
+ }
121
+ })
122
+
123
+ t.Run("matched rows count: a valid session that needs no refresh still validates", func(t *testing.T) {
124
+ id := create(t, user)
125
+ before := lastAccess(t, db, id)
126
+ for i := 0; i < 2; i++ {
127
+ if !valid(t, id) {
128
+ t.Fatalf("validate #%d failed although the row matched", i+1)
129
+ }
130
+ }
131
+ if after := lastAccess(t, db, id); !after.Equal(before) {
132
+ t.Fatalf("last_access_at moved inside the 60 s window: %v -> %v", before, after)
133
+ }
134
+ })
135
+
136
+ t.Run("refresh happens only when last access is older than 60 s", func(t *testing.T) {
137
+ id := create(t, user)
138
+ age(t, db, id, 0, 59*time.Second)
139
+ before := lastAccess(t, db, id)
140
+ if !valid(t, id) || !lastAccess(t, db, id).Equal(before) {
141
+ t.Fatal("59 s old: must validate without writing")
142
+ }
143
+ age(t, db, id, 0, 61*time.Second)
144
+ before = lastAccess(t, db, id)
145
+ if !valid(t, id) || !lastAccess(t, db, id).After(before) {
146
+ t.Fatal("61 s old: must validate and refresh")
147
+ }
148
+ })
149
+
150
+ t.Run("idle timeout is 30 minutes", func(t *testing.T) {
151
+ id := create(t, user)
152
+ age(t, db, id, 0, 29*time.Minute+50*time.Second)
153
+ if !valid(t, id) {
154
+ t.Fatal("29m50s idle must still be valid")
155
+ }
156
+ age(t, db, id, 0, 30*time.Minute+5*time.Second)
157
+ if valid(t, id) {
158
+ t.Fatal("30m05s idle must be expired")
159
+ }
160
+ })
161
+
162
+ t.Run("absolute timeout is 8 hours, even for an active session", func(t *testing.T) {
163
+ id := create(t, user)
164
+ age(t, db, id, 7*time.Hour+59*time.Minute, 0)
165
+ if !valid(t, id) {
166
+ t.Fatal("7h59m old must still be valid")
167
+ }
168
+ age(t, db, id, 8*time.Hour+time.Minute, 0)
169
+ if valid(t, id) {
170
+ t.Fatal("8h01m old must be expired although just used")
171
+ }
172
+ })
173
+
174
+ t.Run("disabled user is rejected on the next request", func(t *testing.T) {
175
+ u := addUser(t, db, "100002", "USER", true)
176
+ id := create(t, u)
177
+ if !valid(t, id) {
178
+ t.Fatal("enabled user must validate")
179
+ }
180
+ if _, err := db.Exec(`UPDATE app_user SET enabled = FALSE WHERE id = ?`, u.String()); err != nil {
181
+ t.Fatal(err)
182
+ }
183
+ if valid(t, id) {
184
+ t.Fatal("disabled user must not validate")
185
+ }
186
+ })
187
+
188
+ t.Run("revoke is idempotent and ends only that session", func(t *testing.T) {
189
+ a, b := create(t, user), create(t, user)
190
+ for i := 0; i < 2; i++ {
191
+ if err := s.Revoke(ctx, a); err != nil {
192
+ t.Fatal(err)
193
+ }
194
+ }
195
+ if err := s.Revoke(ctx, "garbage"); err != nil {
196
+ t.Fatal(err)
197
+ }
198
+ if valid(t, a) || !valid(t, b) {
199
+ t.Fatal("revoke must end a and leave b")
200
+ }
201
+ })
202
+
203
+ t.Run("revoke all ends every session of the user only", func(t *testing.T) {
204
+ u, other := addUser(t, db, "100003", "USER", true), addUser(t, db, "100004", "USER", true)
205
+ a, b, o := create(t, u), create(t, u), create(t, other)
206
+ if err := s.RevokeAll(ctx, u); err != nil {
207
+ t.Fatal(err)
208
+ }
209
+ if valid(t, a) || valid(t, b) || !valid(t, o) {
210
+ t.Fatal("RevokeAll must end the user's sessions and no others")
211
+ }
212
+ })
213
+
214
+ t.Run("purge removes expired sessions only", func(t *testing.T) {
215
+ u := addUser(t, db, "100005", "USER", true)
216
+ live, idle, old := create(t, u), create(t, u), create(t, u)
217
+ age(t, db, idle, time.Hour, time.Hour)
218
+ age(t, db, old, 9*time.Hour, 0)
219
+ n, err := s.Purge(ctx)
220
+ if err != nil || n < 2 {
221
+ t.Fatalf("purged %d, %v; want at least 2", n, err)
222
+ }
223
+ if !valid(t, live) {
224
+ t.Fatal("live session purged")
225
+ }
226
+ for _, id := range []string{idle, old} {
227
+ h, _ := auth.Binding(id)
228
+ var c int
229
+ if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, h).Scan(&c); err != nil || c != 0 {
230
+ t.Fatalf("expired row still present: %d, %v", c, err)
231
+ }
232
+ }
233
+ })
234
+
235
+ t.Run("deleting a user deletes the sessions", func(t *testing.T) {
236
+ u := addUser(t, db, "100006", "USER", true)
237
+ id := create(t, u)
238
+ if _, err := db.Exec(`DELETE FROM app_user WHERE id = ?`, u.String()); err != nil {
239
+ t.Fatal(err)
240
+ }
241
+ if valid(t, id) {
242
+ t.Fatal("session survived its user")
243
+ }
244
+ })
245
+ }
@@ -0,0 +1,36 @@
1
+ package auth_test
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/base64"
6
+ "testing"
7
+
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
9
+ )
10
+
11
+ func TestBindingIsSHA256OfDecodedID(t *testing.T) {
12
+ raw := make([]byte, 32)
13
+ for i := range raw {
14
+ raw[i] = byte(i)
15
+ }
16
+ want := sha256.Sum256(raw)
17
+ got, ok := auth.Binding(base64.RawURLEncoding.EncodeToString(raw))
18
+ if !ok || string(got) != string(want[:]) {
19
+ t.Fatalf("Binding = %x, %v; want %x", got, ok, want)
20
+ }
21
+ }
22
+
23
+ func TestBindingRejectsMalformedIDs(t *testing.T) {
24
+ for name, id := range map[string]string{
25
+ "empty": "",
26
+ "not base64": "!!!",
27
+ "too short": base64.RawURLEncoding.EncodeToString(make([]byte, 31)),
28
+ "too long": base64.RawURLEncoding.EncodeToString(make([]byte, 33)),
29
+ "padded": base64.URLEncoding.EncodeToString(make([]byte, 32)),
30
+ "std alpha": base64.StdEncoding.EncodeToString([]byte{0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff}),
31
+ } {
32
+ if _, ok := auth.Binding(id); ok {
33
+ t.Errorf("%s: %q accepted", name, id)
34
+ }
35
+ }
36
+ }
@@ -3,7 +3,9 @@ package server
3
3
  import (
4
4
  "net/http"
5
5
 
6
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
6
7
  "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
7
9
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
8
10
  )
9
11
 
@@ -11,8 +13,18 @@ import (
11
13
  // module has not landed yet stay api.Unimplemented (501).
12
14
  type API struct {
13
15
  api.Unimplemented
16
+ Auth auth.Handler
17
+ Account account.Handler
14
18
  Deadline deadline.Handler
15
19
  }
16
20
 
17
21
  // GetDeadline serves GET /deadline.
18
22
  func (a API) GetDeadline(w http.ResponseWriter, r *http.Request) { a.Deadline.GetDeadline(w, r) }
23
+
24
+ // Register serves POST /auth/register.
25
+ func (a API) Register(w http.ResponseWriter, r *http.Request, _ api.RegisterParams) {
26
+ a.Account.Register(w, r)
27
+ }
28
+
29
+ // GetCsrfToken serves GET /auth/csrf.
30
+ func (a API) GetCsrfToken(w http.ResponseWriter, r *http.Request) { a.Auth.GetCsrfToken(w, r) }
@@ -5,10 +5,8 @@ import (
5
5
  "encoding/json"
6
6
  "errors"
7
7
  "log/slog"
8
+ "maps"
8
9
  "net/http"
9
- "net/netip"
10
-
11
- "github.com/go-chi/chi/v5"
12
10
 
13
11
  "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
14
12
  "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
@@ -59,37 +57,24 @@ func (a ErrorAdapter) record(r *http.Request, e *web.Error) {
59
57
  if outcome == "" {
60
58
  outcome = audit.Denied
61
59
  }
62
- after, _ := json.Marshal(map[string]string{"reason": e.AuditReason}) // map of strings cannot fail
60
+ detail := map[string]string{}
61
+ maps.Copy(detail, e.AuditDetail)
62
+ detail["reason"] = e.AuditReason // the reason always wins over a detail key
63
+ after, _ := json.Marshal(detail) // map of strings cannot fail
63
64
  ev := audit.Event{
64
65
  Action: audit.Action(e.AuditAction),
65
66
  Outcome: outcome,
66
67
  TargetType: audit.TargetType(e.AuditTargetType),
67
68
  TargetID: e.AuditTargetID,
68
69
  Method: r.Method,
69
- Route: routeTemplate(r),
70
+ Route: web.RoutePattern(r),
70
71
  Status: e.Status,
71
72
  ErrorCode: string(e.Code),
72
- IP: remoteIP(r),
73
+ IP: web.ClientIP(r),
73
74
  UserAgent: r.UserAgent(),
74
- After: after,
75
- }
76
- a.Audit.RecordRejected(r.Context(), ev)
77
- }
78
75
 
79
- // routeTemplate is the matched chi pattern (/api/v1/ideas/{id}), never the raw
80
- // path, so IDs do not leak into the log and rows group by route.
81
- func routeTemplate(r *http.Request) string {
82
- if rc := chi.RouteContext(r.Context()); rc != nil {
83
- return rc.RoutePattern()
76
+ SubmittedEmpNo: e.AuditSubmittedEmpNo,
77
+ After: after,
84
78
  }
85
- return ""
86
- }
87
-
88
- // remoteIP is the TCP peer. Trusted-proxy handling is not decided yet.
89
- func remoteIP(r *http.Request) netip.Addr {
90
- ap, err := netip.ParseAddrPort(r.RemoteAddr)
91
- if err != nil {
92
- return netip.Addr{}
93
- }
94
- return ap.Addr().Unmap()
79
+ a.Audit.RecordRejected(r.Context(), ev)
95
80
  }
@@ -96,3 +96,21 @@ func TestAdapterReasonWithoutActionStillAnswers(t *testing.T) {
96
96
  }
97
97
 
98
98
  func contains(s, sub string) bool { return strings.Contains(s, sub) }
99
+
100
+ func TestAdapterCarriesSubmittedEmpNoAndDetail(t *testing.T) {
101
+ e := web.NewError(web.CodeEmpNoTaken, "taken")
102
+ e.AuditReason, e.AuditAction, e.AuditOutcome = "emp_no_taken", "AUTH_REGISTER", "FAILED"
103
+ e.AuditSubmittedEmpNo = "089290"
104
+ e.AuditDetail = map[string]string{"empNo": "089290", "reason": "must not override"}
105
+ f := &fakeRejecter{}
106
+
107
+ rec := serve(t, server.ErrorAdapter{Audit: f}, e)
108
+
109
+ if rec.Code != 409 || len(f.got) != 1 {
110
+ t.Fatalf("status %d rows %d", rec.Code, len(f.got))
111
+ }
112
+ g := f.got[0]
113
+ if g.Outcome != audit.Failed || g.SubmittedEmpNo != "089290" || string(g.After) != `{"empNo":"089290","reason":"emp_no_taken"}` {
114
+ t.Fatalf("event = %+v (after %s)", g, g.After)
115
+ }
116
+ }
@@ -0,0 +1,35 @@
1
+ package server
2
+
3
+ import "net/http"
4
+
5
+ // PublicRoutes is the explicit list of operations that need no session
6
+ // (Backend.md §3). It must equal the operations marked `security: []` in
7
+ // api/openapi.yaml; a test locks that. B1-10 adds POST /auth/login to both. Paths include the /api/v1 prefix.
8
+ var PublicRoutes = []struct{ Method, Path string }{
9
+ {http.MethodGet, "/api/v1/auth/csrf"},
10
+ {http.MethodPost, "/api/v1/auth/register"},
11
+ {http.MethodPost, "/api/v1/auth/logout"},
12
+ {http.MethodGet, "/api/v1/deadline"},
13
+ }
14
+
15
+ // IsPublic reports whether r is exactly one of PublicRoutes. A public path with
16
+ // another method is not public: without a session it answers 401, with one 405.
17
+ func IsPublic(r *http.Request) bool {
18
+ for _, p := range PublicRoutes {
19
+ if r.Method == p.Method && r.URL.Path == p.Path {
20
+ return true
21
+ }
22
+ }
23
+ return false
24
+ }
25
+
26
+ // WriteActions maps the writes that exist today to their audit action, so a
27
+ // CSRF denial is recorded (PRD §6.11). Later PRs add their routes here, with
28
+ // the same exact-path rule; a write on an unknown path is not audited.
29
+ var WriteActions = map[string]string{
30
+ http.MethodPost + " /api/v1/auth/register": "AUTH_REGISTER",
31
+ http.MethodPost + " /api/v1/auth/logout": "AUTH_LOGOUT",
32
+ }
33
+
34
+ // ActionFor returns the audit action of r, or "" when unknown.
35
+ func ActionFor(r *http.Request) string { return WriteActions[r.Method+" "+r.URL.Path] }
@@ -0,0 +1,73 @@
1
+ package server_test
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "slices"
8
+ "testing"
9
+
10
+ "github.com/getkin/kin-openapi/openapi3"
11
+
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
13
+ )
14
+
15
+ // The public list in code must equal the operations the spec marks
16
+ // `security: []`; otherwise the contract and the middleware disagree.
17
+ func TestPublicRoutesMatchOpenAPI(t *testing.T) {
18
+ doc, err := openapi3.NewLoader().LoadFromFile("../../api/openapi.yaml")
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ var want []string
23
+ for path, item := range doc.Paths.Map() {
24
+ for method, op := range item.Operations() {
25
+ if op.Security != nil && len(*op.Security) == 0 {
26
+ want = append(want, method+" /api/v1"+path)
27
+ }
28
+ }
29
+ }
30
+ var got []string
31
+ for _, p := range server.PublicRoutes {
32
+ got = append(got, p.Method+" "+p.Path)
33
+ }
34
+ slices.Sort(want)
35
+ slices.Sort(got)
36
+ if !slices.Equal(want, got) {
37
+ t.Errorf("PublicRoutes\n got %v\n want %v (spec, security: [])", got, want)
38
+ }
39
+ }
40
+
41
+ func TestIsPublicNeedsExactMethodAndPath(t *testing.T) {
42
+ tests := []struct {
43
+ method, path string
44
+ want bool
45
+ }{
46
+ {"GET", "/api/v1/auth/csrf", true},
47
+ {"POST", "/api/v1/auth/logout", true},
48
+ {"POST", "/api/v1/auth/csrf", false},
49
+ {"HEAD", "/api/v1/deadline", false},
50
+ {"GET", "/api/v1/auth/csrf/", false},
51
+ {"GET", "/api/v1/auth/me", false},
52
+ {"GET", "/api/v1/auth/../ideas", false},
53
+ {"GET", "/auth/csrf", false},
54
+ }
55
+ for _, tc := range tests {
56
+ req := httptest.NewRequestWithContext(context.Background(), tc.method, "http://x"+tc.path, nil)
57
+ if got := server.IsPublic(req); got != tc.want {
58
+ t.Errorf("%s %s = %v, want %v", tc.method, tc.path, got, tc.want)
59
+ }
60
+ }
61
+ }
62
+
63
+ func TestActionForCoversPublicWrites(t *testing.T) {
64
+ for _, p := range server.PublicRoutes {
65
+ if p.Method == http.MethodGet {
66
+ continue
67
+ }
68
+ r := httptest.NewRequest(p.Method, p.Path, nil)
69
+ if server.ActionFor(r) == "" {
70
+ t.Errorf("%s %s has no audit action for a CSRF denial", p.Method, p.Path)
71
+ }
72
+ }
73
+ }
@@ -22,8 +22,8 @@ const (
22
22
 
23
23
  // NewPublic builds the public router. Module routes are mounted by `mount`
24
24
  // (nil in the skeleton) under the chain: request ID → access log → recover →
25
- // Accept check.
26
- func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
25
+ // extra (the session middleware, so 401 beats 406) → Accept check.
26
+ func NewPublic(log *slog.Logger, mount func(chi.Router), extra ...func(http.Handler) http.Handler) http.Handler {
27
27
  r := chi.NewRouter()
28
28
  r.NotFound(web.NotFound)
29
29
  r.MethodNotAllowed(web.MethodNotAllowed)
@@ -32,7 +32,11 @@ func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
32
32
  }
33
33
  // The chain wraps the router instead of using r.Use: chi skips r.Use
34
34
  // middleware (also for NotFound) while no route is registered.
35
- return web.RequestID(web.AccessLog(log)(web.Recover(log)(web.AcceptCheck(r))))
35
+ h := web.AcceptCheck(r)
36
+ for i := len(extra) - 1; i >= 0; i-- {
37
+ h = extra[i](h)
38
+ }
39
+ return web.RequestID(web.AccessLog(log)(web.Recover(log)(h)))
36
40
  }
37
41
 
38
42
  // Check reports whether one dependency is usable; a non-nil error makes
@@ -18,8 +18,16 @@ type Config struct {
18
18
  LogLevel slog.Level
19
19
  ShutdownTimeout time.Duration // must stay below terminationGracePeriodSeconds
20
20
  ReadyDrainDelay time.Duration // time readiness stays false before Shutdown starts
21
+ CSRFKey string // HMAC key that signs CSRF tokens (>= 32 bytes)
22
+ CSRFKeyPrevious string // optional: still verifies for 8 h after a rotation
21
23
  }
22
24
 
25
+ // DefaultCSRFKey is the throwaway key of the local profile only; every other
26
+ // profile must set ESG_CSRF_KEY.
27
+ const DefaultCSRFKey = "local-dev-csrf-key-not-a-secret-0000"
28
+
29
+ const minCSRFKeyBytes = 32
30
+
23
31
  // DefaultDSN matches docker-compose.yml (throwaway local credentials). The query
24
32
  // pins UTC and matched-row counts (Backend.md §2.3).
25
33
  const DefaultDSN = "esg:esg@tcp(localhost:3306)/esg?parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27&clientFoundRows=true"
@@ -33,16 +41,27 @@ func Load(getenv func(string) string) (Config, error) {
33
41
  return def
34
42
  }
35
43
  c := Config{
36
- Profile: get("ESG_PROFILE", "local"),
37
- PublicAddr: get("ESG_PUBLIC_ADDR", ":8080"),
38
- ManagementAddr: get("ESG_MANAGEMENT_ADDR", ":8081"),
39
- DBDSN: get("ESG_DB_DSN", DefaultDSN),
44
+ Profile: get("ESG_PROFILE", "local"),
45
+ PublicAddr: get("ESG_PUBLIC_ADDR", ":8080"),
46
+ ManagementAddr: get("ESG_MANAGEMENT_ADDR", ":8081"),
47
+ DBDSN: get("ESG_DB_DSN", DefaultDSN),
48
+ CSRFKey: getenv("ESG_CSRF_KEY"),
49
+ CSRFKeyPrevious: getenv("ESG_CSRF_KEY_PREVIOUS"),
40
50
  }
41
51
  switch c.Profile {
42
52
  case "local", "k3s", "dev":
43
53
  default:
44
54
  return Config{}, fmt.Errorf("ESG_PROFILE %q: want local, k3s or dev", c.Profile)
45
55
  }
56
+ if c.CSRFKey == "" && c.Profile == "local" {
57
+ c.CSRFKey = DefaultCSRFKey
58
+ }
59
+ if len(c.CSRFKey) < minCSRFKeyBytes {
60
+ return Config{}, fmt.Errorf("ESG_CSRF_KEY: need at least %d bytes", minCSRFKeyBytes)
61
+ }
62
+ if c.CSRFKeyPrevious != "" && len(c.CSRFKeyPrevious) < minCSRFKeyBytes {
63
+ return Config{}, fmt.Errorf("ESG_CSRF_KEY_PREVIOUS: need at least %d bytes", minCSRFKeyBytes)
64
+ }
46
65
  if err := c.LogLevel.UnmarshalText([]byte(strings.ToUpper(get("ESG_LOG_LEVEL", "info")))); err != nil {
47
66
  return Config{}, fmt.Errorf("ESG_LOG_LEVEL: %w", err)
48
67
  }