@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.
- package/backend/api/openapi.yaml +44 -0
- package/backend/cmd/server/main.go +20 -2
- package/backend/db/migration/V004__session.sql +19 -0
- package/backend/go.mod +1 -1
- package/backend/internal/account/account_test.go +191 -0
- package/backend/internal/account/handler.go +58 -0
- package/backend/internal/account/integration_test.go +268 -0
- package/backend/internal/account/internal/store/store.go +35 -0
- package/backend/internal/account/service.go +153 -0
- package/backend/internal/account/validate.go +100 -0
- package/backend/internal/api/api.gen.go +82 -0
- package/backend/internal/audit/integration_test.go +1 -1
- 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 +111 -0
- package/backend/internal/auth/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -0
- package/backend/internal/auth/session.go +113 -0
- package/backend/internal/auth/session_integration_test.go +245 -0
- package/backend/internal/auth/session_test.go +36 -0
- package/backend/internal/server/api.go +12 -0
- package/backend/internal/server/errors.go +10 -25
- package/backend/internal/server/errors_test.go +18 -0
- package/backend/internal/server/public.go +35 -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/db/intx_test.go +25 -0
- package/backend/internal/shared/db/mysqlerr.go +26 -0
- package/backend/internal/shared/security/security.go +6 -2
- package/backend/internal/shared/security/security_test.go +6 -4
- package/backend/internal/shared/web/problem.go +6 -0
- package/backend/internal/shared/web/request.go +27 -0
- package/frontend/src/app/App.test.tsx +13 -0
- package/frontend/src/app/App.tsx +4 -0
- package/frontend/src/app/session.ts +28 -6
- 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/text/index.ts +93 -0
- package/frontend/src/shared/text/text.test.ts +22 -0
- package/package.json +1 -1
- package/frontend/src/shared/text/.gitkeep +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Package account owns app_user: registration today; profile, password change
|
|
2
|
+
// and admin sync land with their own PRs. Backend.md §2.
|
|
3
|
+
package account
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"fmt"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/netip"
|
|
12
|
+
"strings"
|
|
13
|
+
|
|
14
|
+
"github.com/google/uuid"
|
|
15
|
+
"golang.org/x/crypto/bcrypt"
|
|
16
|
+
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/account/internal/store"
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
19
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/text"
|
|
21
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// bcryptCost is the only cost the table accepts (PRD §6.4, OI-13).
|
|
25
|
+
const bcryptCost = 10
|
|
26
|
+
|
|
27
|
+
// Recorder writes the success audit row in the registration transaction.
|
|
28
|
+
// *audit.Service implements it.
|
|
29
|
+
type Recorder interface {
|
|
30
|
+
Record(ctx context.Context, tx *sql.Tx, e audit.Event) error
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Service implements the account use cases.
|
|
34
|
+
type Service struct {
|
|
35
|
+
DB *sql.DB
|
|
36
|
+
Audit Recorder
|
|
37
|
+
Policy PasswordPolicy
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// RegisterCommand is the raw, unvalidated input.
|
|
41
|
+
type RegisterCommand struct {
|
|
42
|
+
EmpNo, Name, Email, Password string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Request is the transport facts the audit row needs.
|
|
46
|
+
type Request struct {
|
|
47
|
+
Method, Route, UserAgent string
|
|
48
|
+
IP netip.Addr
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Registered is the stored account (never the hash).
|
|
52
|
+
type Registered struct {
|
|
53
|
+
ID uuid.UUID
|
|
54
|
+
EmpNo, Name, Email string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Register creates a USER. Order (PRD §6.3): format → password policy →
|
|
58
|
+
// uniqueness. Every failure is a *web.Error carrying the AUTH_REGISTER FAILED
|
|
59
|
+
// audit fields, so the central adapter writes the track-B row; a success
|
|
60
|
+
// writes its SUCCESS row in the same transaction as the INSERT (fail-closed).
|
|
61
|
+
func (s *Service) Register(ctx context.Context, cmd RegisterCommand, req Request) (Registered, error) {
|
|
62
|
+
empNoOK := checkEmpNo(cmd.EmpNo) == text.None
|
|
63
|
+
|
|
64
|
+
var fields []web.FieldError
|
|
65
|
+
add := func(field string, p text.Problem) {
|
|
66
|
+
if p != text.None {
|
|
67
|
+
fields = append(fields, web.FieldError{Field: field, Code: web.ErrorCode(p)})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
add(fieldEmpNo, checkEmpNo(cmd.EmpNo))
|
|
71
|
+
name, p := text.CheckName(cmd.Name, maxNameLen)
|
|
72
|
+
add(fieldName, p)
|
|
73
|
+
email, p := checkEmail(cmd.Email)
|
|
74
|
+
add(fieldEmail, p)
|
|
75
|
+
|
|
76
|
+
policyEmpNo := ""
|
|
77
|
+
if empNoOK {
|
|
78
|
+
policyEmpNo = cmd.EmpNo
|
|
79
|
+
}
|
|
80
|
+
fields = append(fields, s.Policy.Check(cmd.Password, policyEmpNo)...)
|
|
81
|
+
|
|
82
|
+
if len(fields) > 0 {
|
|
83
|
+
e := web.NewError(topCode(fields), "the request has invalid fields")
|
|
84
|
+
e.Fields = fields
|
|
85
|
+
return Registered{}, failed(e, "validation", cmd.EmpNo, empNoOK)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
hash, err := bcrypt.GenerateFromPassword([]byte(cmd.Password), bcryptCost)
|
|
89
|
+
if err != nil {
|
|
90
|
+
return Registered{}, fmt.Errorf("hash password: %w", err)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
u := Registered{ID: uuid.New(), EmpNo: cmd.EmpNo, Name: name, Email: email}
|
|
94
|
+
after, _ := json.Marshal(map[string]string{"empNo": u.EmpNo, "name": u.Name}) // no email (PRD §6.11)
|
|
95
|
+
|
|
96
|
+
_, err = db.InTx(ctx, s.DB, func(ctx context.Context, tx *sql.Tx) error {
|
|
97
|
+
if err := store.InsertUser(ctx, tx, store.NewUser{
|
|
98
|
+
ID: u.ID, EmpNo: u.EmpNo, Name: u.Name, Email: u.Email, PasswordHash: string(hash),
|
|
99
|
+
}); err != nil {
|
|
100
|
+
return err
|
|
101
|
+
}
|
|
102
|
+
return s.Audit.Record(ctx, tx, audit.Event{
|
|
103
|
+
Action: audit.AuthRegister, Outcome: audit.Success,
|
|
104
|
+
TargetType: audit.TargetUser, TargetID: u.ID,
|
|
105
|
+
Method: req.Method, Route: req.Route, Status: http.StatusCreated,
|
|
106
|
+
IP: req.IP, UserAgent: req.UserAgent,
|
|
107
|
+
After: after,
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
if err != nil {
|
|
111
|
+
if e := duplicate(err); e != nil {
|
|
112
|
+
return Registered{}, failed(e, e.AuditReason, cmd.EmpNo, true)
|
|
113
|
+
}
|
|
114
|
+
return Registered{}, err
|
|
115
|
+
}
|
|
116
|
+
return u, nil
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// duplicate maps a unique-key violation to 409. emp_no is checked first by the
|
|
120
|
+
// server (it is the first unique key), so a row clashing on both says EMP_NO_TAKEN.
|
|
121
|
+
func duplicate(err error) *web.Error {
|
|
122
|
+
key, ok := db.DuplicateKey(err)
|
|
123
|
+
if !ok {
|
|
124
|
+
return nil
|
|
125
|
+
}
|
|
126
|
+
var e *web.Error
|
|
127
|
+
switch {
|
|
128
|
+
case strings.Contains(key, "emp_no"):
|
|
129
|
+
e = web.NewError(web.CodeEmpNoTaken, "employee ID is already registered")
|
|
130
|
+
e.AuditReason = "emp_no_taken"
|
|
131
|
+
case strings.Contains(key, "email"):
|
|
132
|
+
e = web.NewError(web.CodeEmailTaken, "email is already registered")
|
|
133
|
+
e.AuditReason = "email_taken"
|
|
134
|
+
default:
|
|
135
|
+
return nil
|
|
136
|
+
}
|
|
137
|
+
e.Cause = err
|
|
138
|
+
return e
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// failed marks e as an AUTH_REGISTER FAILED attempt (PRD §6.11): the typed
|
|
142
|
+
// employee ID goes into the dedicated column (sanitized by audit), and into
|
|
143
|
+
// after_json only when well-formed.
|
|
144
|
+
func failed(e *web.Error, reason, empNo string, wellFormed bool) *web.Error {
|
|
145
|
+
e.AuditReason = reason
|
|
146
|
+
e.AuditAction = string(audit.AuthRegister)
|
|
147
|
+
e.AuditOutcome = string(audit.Failed)
|
|
148
|
+
e.AuditSubmittedEmpNo = empNo
|
|
149
|
+
if wellFormed {
|
|
150
|
+
e.AuditDetail = map[string]string{"empNo": empNo}
|
|
151
|
+
}
|
|
152
|
+
return e
|
|
153
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
package account
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
|
|
6
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/text"
|
|
7
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Field limits (PRD §6.3).
|
|
11
|
+
const (
|
|
12
|
+
empNoLen = 6
|
|
13
|
+
maxNameLen = 50
|
|
14
|
+
maxEmailLen = 254
|
|
15
|
+
maxPassBytes = 72 // bcrypt silently ignores (x/crypto: rejects) anything longer
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// Field names as they appear in errors[]; the order is the form order.
|
|
19
|
+
const (
|
|
20
|
+
fieldEmpNo = "empNo"
|
|
21
|
+
fieldName = "name"
|
|
22
|
+
fieldEmail = "email"
|
|
23
|
+
fieldPassword = "password"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// checkEmpNo: exactly six ASCII digits, nothing is trimmed (PRD §6.3).
|
|
27
|
+
// Empty is REQUIRED, anything else wrong is INVALID_FORMAT.
|
|
28
|
+
func checkEmpNo(s string) text.Problem {
|
|
29
|
+
if text.Trim(s) == "" {
|
|
30
|
+
return text.Required
|
|
31
|
+
}
|
|
32
|
+
if len(s) != empNoLen {
|
|
33
|
+
return text.InvalidFormat
|
|
34
|
+
}
|
|
35
|
+
for i := range len(s) {
|
|
36
|
+
if s[i] < '0' || s[i] > '9' {
|
|
37
|
+
return text.InvalidFormat
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return text.None
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// checkEmail trims, then requires printable ASCII with no whitespace, exactly
|
|
44
|
+
// one '@' with non-empty sides and at most 254 characters; the result is
|
|
45
|
+
// lower-cased (PRD §6.3, OI-14). Report order: REQUIRED, INVALID_FORMAT,
|
|
46
|
+
// OUT_OF_RANGE.
|
|
47
|
+
func checkEmail(s string) (string, text.Problem) {
|
|
48
|
+
e := text.Trim(s)
|
|
49
|
+
if e == "" {
|
|
50
|
+
return "", text.Required
|
|
51
|
+
}
|
|
52
|
+
for _, r := range e {
|
|
53
|
+
if r < 0x21 || r > 0x7E { // also rejects whitespace and control characters
|
|
54
|
+
return e, text.InvalidFormat
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
local, domain, ok := strings.Cut(e, "@")
|
|
58
|
+
if !ok || local == "" || domain == "" || strings.Contains(domain, "@") {
|
|
59
|
+
return e, text.InvalidFormat
|
|
60
|
+
}
|
|
61
|
+
if len(e) > maxEmailLen { // ASCII only, so bytes = code points
|
|
62
|
+
return e, text.OutOfRange
|
|
63
|
+
}
|
|
64
|
+
return strings.ToLower(e), text.None
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// PasswordPolicy checks a candidate password and lists every violation, in
|
|
68
|
+
// PRD §6.4 table order. empNo is the submitted employee ID when it is
|
|
69
|
+
// well-formed and "" otherwise (the CONTAINS_EMP_NO rule is then skipped).
|
|
70
|
+
type PasswordPolicy interface {
|
|
71
|
+
Check(password, empNo string) []web.FieldError
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// StubPolicy is the placeholder until B1b-01 delivers the §6.4 rules. It only
|
|
75
|
+
// refuses what the account cannot store: an empty password and one bcrypt
|
|
76
|
+
// cannot hash (longer than 72 bytes). Everything else is accepted.
|
|
77
|
+
type StubPolicy struct{}
|
|
78
|
+
|
|
79
|
+
// Check implements PasswordPolicy.
|
|
80
|
+
func (StubPolicy) Check(password, _ string) []web.FieldError {
|
|
81
|
+
switch {
|
|
82
|
+
case password == "":
|
|
83
|
+
return []web.FieldError{{Field: fieldPassword, Code: web.CodeRequired}}
|
|
84
|
+
case len(password) > maxPassBytes:
|
|
85
|
+
return []web.FieldError{{Field: fieldPassword, Code: web.CodeOutOfRange}}
|
|
86
|
+
}
|
|
87
|
+
return nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// topCode is the response code of a 422: the first field error's code when it
|
|
91
|
+
// is a specific registered code, else VALIDATION_FAILED (error-codes.md §2).
|
|
92
|
+
func topCode(fields []web.FieldError) web.ErrorCode {
|
|
93
|
+
if len(fields) > 0 {
|
|
94
|
+
switch c := fields[0].Code; c {
|
|
95
|
+
case web.CodeInvalidNameChars, web.CodePasswordPolicy:
|
|
96
|
+
return c
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return web.CodeValidationFailed
|
|
100
|
+
}
|
|
@@ -160,6 +160,26 @@ type Problem struct {
|
|
|
160
160
|
Type string `json:"type"`
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// RegisterRequest defines model for RegisterRequest.
|
|
164
|
+
type RegisterRequest struct {
|
|
165
|
+
// Email Printable ASCII with exactly one @, at most 254 characters; stored lower-cased
|
|
166
|
+
Email string `json:"email"`
|
|
167
|
+
|
|
168
|
+
// EmpNo Exactly 6 ASCII digits
|
|
169
|
+
EmpNo string `json:"empNo"`
|
|
170
|
+
|
|
171
|
+
// Name 1-50 code points after clean-up
|
|
172
|
+
Name string `json:"name"`
|
|
173
|
+
Password string `json:"password"`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// RegisterResponse defines model for RegisterResponse.
|
|
177
|
+
type RegisterResponse struct {
|
|
178
|
+
Email string `json:"email"`
|
|
179
|
+
EmpNo string `json:"empNo"`
|
|
180
|
+
Name string `json:"name"`
|
|
181
|
+
}
|
|
182
|
+
|
|
163
183
|
// CsrfToken defines model for CsrfToken.
|
|
164
184
|
type CsrfToken = string
|
|
165
185
|
|
|
@@ -172,12 +192,21 @@ type LogoutParams struct {
|
|
|
172
192
|
XCSRFToken *CsrfToken `json:"X-CSRF-Token,omitempty"`
|
|
173
193
|
}
|
|
174
194
|
|
|
195
|
+
// RegisterParams defines parameters for Register.
|
|
196
|
+
type RegisterParams struct {
|
|
197
|
+
// 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).
|
|
198
|
+
XCSRFToken *CsrfToken `json:"X-CSRF-Token,omitempty"`
|
|
199
|
+
}
|
|
200
|
+
|
|
175
201
|
// ListIdeasParams defines parameters for ListIdeas.
|
|
176
202
|
type ListIdeasParams struct {
|
|
177
203
|
Page *int `form:"page,omitempty" json:"page,omitempty"`
|
|
178
204
|
Size *int `form:"size,omitempty" json:"size,omitempty"`
|
|
179
205
|
}
|
|
180
206
|
|
|
207
|
+
// RegisterJSONRequestBody defines body for Register for application/json ContentType.
|
|
208
|
+
type RegisterJSONRequestBody = RegisterRequest
|
|
209
|
+
|
|
181
210
|
// ServerInterface represents all server handlers.
|
|
182
211
|
type ServerInterface interface {
|
|
183
212
|
// GetCsrfToken Session-bound CSRF token (anonymous before login)
|
|
@@ -186,6 +215,9 @@ type ServerInterface interface {
|
|
|
186
215
|
// Logout End the session
|
|
187
216
|
// (POST /auth/logout)
|
|
188
217
|
Logout(w http.ResponseWriter, r *http.Request, params LogoutParams)
|
|
218
|
+
// Register Self-service registration (no session is created)
|
|
219
|
+
// (POST /auth/register)
|
|
220
|
+
Register(w http.ResponseWriter, r *http.Request, params RegisterParams)
|
|
189
221
|
// GetDeadline Submission deadline, state and database server time
|
|
190
222
|
// (GET /deadline)
|
|
191
223
|
GetDeadline(w http.ResponseWriter, r *http.Request)
|
|
@@ -213,6 +245,12 @@ func (_ Unimplemented) Logout(w http.ResponseWriter, r *http.Request, params Log
|
|
|
213
245
|
w.WriteHeader(http.StatusNotImplemented)
|
|
214
246
|
}
|
|
215
247
|
|
|
248
|
+
// Register Self-service registration (no session is created)
|
|
249
|
+
// (POST /auth/register)
|
|
250
|
+
func (_ Unimplemented) Register(w http.ResponseWriter, r *http.Request, params RegisterParams) {
|
|
251
|
+
w.WriteHeader(http.StatusNotImplemented)
|
|
252
|
+
}
|
|
253
|
+
|
|
216
254
|
// GetDeadline Submission deadline, state and database server time
|
|
217
255
|
// (GET /deadline)
|
|
218
256
|
func (_ Unimplemented) GetDeadline(w http.ResponseWriter, r *http.Request) {
|
|
@@ -295,6 +333,47 @@ func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request
|
|
|
295
333
|
handler.ServeHTTP(w, r)
|
|
296
334
|
}
|
|
297
335
|
|
|
336
|
+
// Register operation middleware
|
|
337
|
+
func (siw *ServerInterfaceWrapper) Register(w http.ResponseWriter, r *http.Request) {
|
|
338
|
+
|
|
339
|
+
var err error
|
|
340
|
+
_ = err
|
|
341
|
+
|
|
342
|
+
// Parameter object where we will unmarshal all parameters from the context
|
|
343
|
+
var params RegisterParams
|
|
344
|
+
|
|
345
|
+
headers := r.Header
|
|
346
|
+
|
|
347
|
+
// ------------- Optional header parameter "X-CSRF-Token" -------------
|
|
348
|
+
if valueList, found := headers[http.CanonicalHeaderKey("X-CSRF-Token")]; found {
|
|
349
|
+
var XCSRFToken CsrfToken
|
|
350
|
+
n := len(valueList)
|
|
351
|
+
if n != 1 {
|
|
352
|
+
siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "X-CSRF-Token", Count: n})
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
err = runtime.BindStyledParameterWithOptions("simple", "X-CSRF-Token", valueList[0], &XCSRFToken, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""})
|
|
357
|
+
if err != nil {
|
|
358
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "X-CSRF-Token", Err: err})
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
params.XCSRFToken = &XCSRFToken
|
|
363
|
+
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
367
|
+
siw.Handler.Register(w, r, params)
|
|
368
|
+
}))
|
|
369
|
+
|
|
370
|
+
for _, middleware := range siw.HandlerMiddlewares {
|
|
371
|
+
handler = middleware(handler)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
handler.ServeHTTP(w, r)
|
|
375
|
+
}
|
|
376
|
+
|
|
298
377
|
// GetDeadline operation middleware
|
|
299
378
|
func (siw *ServerInterfaceWrapper) GetDeadline(w http.ResponseWriter, r *http.Request) {
|
|
300
379
|
|
|
@@ -500,6 +579,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
|
|
500
579
|
r.Group(func(r chi.Router) {
|
|
501
580
|
r.Post(options.BaseURL+"/auth/logout", wrapper.Logout)
|
|
502
581
|
})
|
|
582
|
+
r.Group(func(r chi.Router) {
|
|
583
|
+
r.Post(options.BaseURL+"/auth/register", wrapper.Register)
|
|
584
|
+
})
|
|
503
585
|
r.Group(func(r chi.Router) {
|
|
504
586
|
r.Get(options.BaseURL+"/deadline", wrapper.GetDeadline)
|
|
505
587
|
})
|
|
@@ -84,7 +84,7 @@ func TestAudit(t *testing.T) {
|
|
|
84
84
|
|
|
85
85
|
t.Run("track A is written in the caller's transaction and carries every field", func(t *testing.T) {
|
|
86
86
|
reset(t, d)
|
|
87
|
-
ctx := ctxWithReq(&security.Principal{UserID:
|
|
87
|
+
ctx := ctxWithReq(&security.Principal{UserID: uuid.New(), EmpNo: "E0001", Role: security.RoleUser})
|
|
88
88
|
target := uuid.New()
|
|
89
89
|
out, err := db.InTx(ctx, d, func(ctx context.Context, tx *sql.Tx) error {
|
|
90
90
|
return svc.Record(ctx, tx, audit.Event{
|
|
@@ -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
|
+
}
|