@johnnyboy11234/esg-idea-contest 0.2.2 → 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 +10 -2
- package/backend/internal/account/logging_integration_test.go +70 -0
- package/backend/internal/api/api.gen.go +125 -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/session.go +20 -1
- package/backend/internal/server/api.go +14 -0
- package/backend/internal/server/public.go +3 -1
- package/backend/internal/shared/web/problem.go +9 -0
- package/backend/internal/shared/web/retryafter_test.go +31 -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 +53 -22
- 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/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]
|
|
@@ -51,8 +51,16 @@ func run() error {
|
|
|
51
51
|
if err != nil {
|
|
52
52
|
return err
|
|
53
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
|
+
}
|
|
54
59
|
si := server.API{
|
|
55
|
-
Auth:
|
|
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},
|
|
56
64
|
Account: account.Handler{
|
|
57
65
|
Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
|
|
58
66
|
Fail: errs.Write,
|
|
@@ -62,6 +70,6 @@ func run() error {
|
|
|
62
70
|
|
|
63
71
|
var ready atomic.Bool
|
|
64
72
|
return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si),
|
|
65
|
-
auth.Middleware(
|
|
73
|
+
auth.Middleware(sessions, server.IsPublic, log),
|
|
66
74
|
csrf.Middleware(errs.Write, server.ActionFor)), &ready, server.DBCheck(db))
|
|
67
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
|
})
|
|
@@ -20,9 +20,14 @@ type Limits struct {
|
|
|
20
20
|
RefreshSeconds int
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Execer is *sql.DB or *sql.Tx.
|
|
24
|
+
type Execer interface {
|
|
25
|
+
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
// Insert stores a new session row. hash is SHA-256 of the session ID.
|
|
24
|
-
func Insert(ctx context.Context,
|
|
25
|
-
_, err :=
|
|
29
|
+
func Insert(ctx context.Context, x Execer, hash []byte, userID uuid.UUID) error {
|
|
30
|
+
_, err := x.ExecContext(ctx,
|
|
26
31
|
`INSERT INTO session (id_hash, user_id) VALUES (?, ?)`, hash, userID.String())
|
|
27
32
|
if err != nil {
|
|
28
33
|
return fmt.Errorf("insert session: %w", err)
|
|
@@ -77,9 +82,10 @@ func Principal(ctx context.Context, db *sql.DB, hash []byte) (p security.Princip
|
|
|
77
82
|
return p, true, nil
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
// Delete removes one session; deleting a missing one is not an error.
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
// Delete removes one session; deleting a missing one is not an error. x is the
|
|
86
|
+
// *sql.DB, or the caller's *sql.Tx so logout commits with its audit row.
|
|
87
|
+
func Delete(ctx context.Context, x Execer, hash []byte) error {
|
|
88
|
+
if _, err := x.ExecContext(ctx, `DELETE FROM session WHERE id_hash = ?`, hash); err != nil {
|
|
83
89
|
return fmt.Errorf("delete session: %w", err)
|
|
84
90
|
}
|
|
85
91
|
return nil
|
|
@@ -109,3 +115,55 @@ func DeleteExpired(ctx context.Context, db *sql.DB, l Limits) (int64, error) {
|
|
|
109
115
|
}
|
|
110
116
|
return n, nil
|
|
111
117
|
}
|
|
118
|
+
|
|
119
|
+
// Account is the login view of app_user: what is needed to check a password
|
|
120
|
+
// and answer the login response. PasswordHash never leaves the auth module.
|
|
121
|
+
type Account struct {
|
|
122
|
+
ID uuid.UUID
|
|
123
|
+
EmpNo string
|
|
124
|
+
Name string
|
|
125
|
+
Email *string // NULL for Admin
|
|
126
|
+
PasswordHash string
|
|
127
|
+
Role security.Role
|
|
128
|
+
Enabled bool
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// AccountByEmpNo reads one account by employee ID; found is false when none
|
|
132
|
+
// exists.
|
|
133
|
+
func AccountByEmpNo(ctx context.Context, db *sql.DB, empNo string) (a Account, found bool, err error) {
|
|
134
|
+
var id, role string
|
|
135
|
+
err = db.QueryRowContext(ctx,
|
|
136
|
+
`SELECT id, emp_no, name, email, password_hash, role, enabled
|
|
137
|
+
FROM app_user WHERE emp_no = ?`, empNo).
|
|
138
|
+
Scan(&id, &a.EmpNo, &a.Name, &a.Email, &a.PasswordHash, &role, &a.Enabled)
|
|
139
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
140
|
+
return Account{}, false, nil
|
|
141
|
+
}
|
|
142
|
+
if err != nil {
|
|
143
|
+
return Account{}, false, fmt.Errorf("read account: %w", err)
|
|
144
|
+
}
|
|
145
|
+
if a.ID, err = uuid.Parse(id); err != nil {
|
|
146
|
+
return Account{}, false, fmt.Errorf("parse user id %q: %w", id, err)
|
|
147
|
+
}
|
|
148
|
+
a.Role = security.Role(role)
|
|
149
|
+
return a, true, nil
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// AccountByID reads one account by its user ID; found is false when none
|
|
153
|
+
// exists.
|
|
154
|
+
func AccountByID(ctx context.Context, db *sql.DB, id uuid.UUID) (a Account, found bool, err error) {
|
|
155
|
+
var sid, role string
|
|
156
|
+
err = db.QueryRowContext(ctx,
|
|
157
|
+
`SELECT id, emp_no, name, email, password_hash, role, enabled
|
|
158
|
+
FROM app_user WHERE id = ?`, id.String()).
|
|
159
|
+
Scan(&sid, &a.EmpNo, &a.Name, &a.Email, &a.PasswordHash, &role, &a.Enabled)
|
|
160
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
161
|
+
return Account{}, false, nil
|
|
162
|
+
}
|
|
163
|
+
if err != nil {
|
|
164
|
+
return Account{}, false, fmt.Errorf("read account by id: %w", err)
|
|
165
|
+
}
|
|
166
|
+
a.ID = id
|
|
167
|
+
a.Role = security.Role(role)
|
|
168
|
+
return a, true, nil
|
|
169
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package auth_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bytes"
|
|
7
|
+
"log/slog"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync"
|
|
12
|
+
"testing"
|
|
13
|
+
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type syncBuf struct {
|
|
18
|
+
mu sync.Mutex
|
|
19
|
+
b bytes.Buffer
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func (s *syncBuf) Write(p []byte) (int, error) {
|
|
23
|
+
s.mu.Lock()
|
|
24
|
+
defer s.mu.Unlock()
|
|
25
|
+
return s.b.Write(p)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func (s *syncBuf) String() string {
|
|
29
|
+
s.mu.Lock()
|
|
30
|
+
defer s.mu.Unlock()
|
|
31
|
+
return s.b.String()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TestLogsNeverContainSecrets drives login (good and bad), csrf, me, logout and
|
|
35
|
+
// a rejected write at DEBUG level and asserts no secret reaches any log line
|
|
36
|
+
// (PRD §6.5 / §8.2: no password, session ID, CSRF token or Cookie in logs).
|
|
37
|
+
func TestLogsNeverContainSecrets(t *testing.T) {
|
|
38
|
+
buf := &syncBuf{}
|
|
39
|
+
e := newLoginEnvLog(t, slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
40
|
+
e.addUser(t, "123456", "USER", true)
|
|
41
|
+
|
|
42
|
+
const wrongPW = "Wr0ng-Secret-Passw0rd!"
|
|
43
|
+
secrets := []string{password, wrongPW}
|
|
44
|
+
|
|
45
|
+
e.login(t, creds("123456", wrongPW))
|
|
46
|
+
e.login(t, creds("999999", wrongPW))
|
|
47
|
+
e.login(t, `{"empNo":"123456","password":"`+wrongPW+`"`) // malformed JSON echoing the password
|
|
48
|
+
|
|
49
|
+
rec := e.login(t, creds("123456", password))
|
|
50
|
+
sess := findCookie(rec, auth.SessionCookie)
|
|
51
|
+
if sess == nil {
|
|
52
|
+
t.Fatalf("login failed: %d", rec.Code)
|
|
53
|
+
}
|
|
54
|
+
secrets = append(secrets, sess.Value)
|
|
55
|
+
|
|
56
|
+
tok, ck := e.csrf(t, sess)
|
|
57
|
+
secrets = append(secrets, tok, ck.Value)
|
|
58
|
+
|
|
59
|
+
if rec := e.me(t, true, sess.Value); rec.Code != 200 {
|
|
60
|
+
t.Fatalf("me: %d", rec.Code)
|
|
61
|
+
}
|
|
62
|
+
// Write with a bad CSRF token: denied, and the submitted token must not be logged.
|
|
63
|
+
bad := httptest.NewRequest("POST", "/api/v1/auth/logout", nil)
|
|
64
|
+
bad.Header.Set("X-CSRF-Token", "forged-token-value-123")
|
|
65
|
+
bad.AddCookie(sess)
|
|
66
|
+
bad.AddCookie(&http.Cookie{Name: "__Host-csrf", Value: "forged-cookie-value-456"})
|
|
67
|
+
e.do(bad)
|
|
68
|
+
secrets = append(secrets, "forged-token-value-123", "forged-cookie-value-456")
|
|
69
|
+
|
|
70
|
+
if rec := e.logout(tok, ck, sess); rec.Code != 204 {
|
|
71
|
+
t.Fatalf("logout: %d", rec.Code)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
out := buf.String()
|
|
75
|
+
if strings.TrimSpace(out) == "" {
|
|
76
|
+
t.Fatal("no log output captured; the test would prove nothing")
|
|
77
|
+
}
|
|
78
|
+
for _, s := range secrets {
|
|
79
|
+
if s != "" && strings.Contains(out, s) {
|
|
80
|
+
t.Errorf("log contains secret %q", s)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for _, name := range []string{"Set-Cookie", "set-cookie", "__Host-session=", "X-CSRF-Token"} {
|
|
84
|
+
if strings.Contains(out, name) {
|
|
85
|
+
t.Errorf("log contains header material %q", name)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|