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