@johnnyboy11234/esg-idea-contest 0.2.0 → 0.2.1

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 (29) hide show
  1. package/backend/api/openapi.yaml +44 -0
  2. package/backend/cmd/server/main.go +11 -1
  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/internal/store/session.go +111 -0
  14. package/backend/internal/auth/session.go +113 -0
  15. package/backend/internal/auth/session_integration_test.go +245 -0
  16. package/backend/internal/auth/session_test.go +36 -0
  17. package/backend/internal/server/api.go +7 -0
  18. package/backend/internal/server/errors.go +10 -25
  19. package/backend/internal/server/errors_test.go +18 -0
  20. package/backend/internal/shared/db/intx_test.go +25 -0
  21. package/backend/internal/shared/db/mysqlerr.go +26 -0
  22. package/backend/internal/shared/security/security.go +6 -2
  23. package/backend/internal/shared/security/security_test.go +6 -4
  24. package/backend/internal/shared/web/problem.go +6 -0
  25. package/backend/internal/shared/web/request.go +27 -0
  26. package/frontend/src/shared/text/index.ts +93 -0
  27. package/frontend/src/shared/text/text.test.ts +22 -0
  28. package/package.json +1 -1
  29. 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: 7, EmpNo: "E0001", Role: security.RoleUser})
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,111 @@
1
+ // Package store holds the SQL of the auth module. Only the auth module may
2
+ // import it (Go's internal rule); no other module touches the session table.
3
+ package store
4
+
5
+ import (
6
+ "context"
7
+ "database/sql"
8
+ "errors"
9
+ "fmt"
10
+
11
+ "github.com/google/uuid"
12
+
13
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
14
+ )
15
+
16
+ // Limits are the session timeouts in seconds (PRD §6.5).
17
+ type Limits struct {
18
+ IdleSeconds int
19
+ AbsoluteSeconds int
20
+ RefreshSeconds int
21
+ }
22
+
23
+ // Insert stores a new session row. hash is SHA-256 of the session ID.
24
+ func Insert(ctx context.Context, db *sql.DB, hash []byte, userID uuid.UUID) error {
25
+ _, err := db.ExecContext(ctx,
26
+ `INSERT INTO session (id_hash, user_id) VALUES (?, ?)`, hash, userID.String())
27
+ if err != nil {
28
+ return fmt.Errorf("insert session: %w", err)
29
+ }
30
+ return nil
31
+ }
32
+
33
+ // Touch validates and refreshes a session in ONE statement on database time.
34
+ // It reports true when the row matched: the session exists, is inside both
35
+ // timeouts and its account is enabled. RowsAffected counts matched rows
36
+ // (clientFoundRows=true in the DSN), so a row that needs no refresh still
37
+ // counts; a test locks this. last_access_at is written only when older than
38
+ // the refresh interval (OI-11).
39
+ func Touch(ctx context.Context, db *sql.DB, hash []byte, l Limits) (bool, error) {
40
+ res, err := db.ExecContext(ctx,
41
+ `UPDATE session s JOIN app_user u ON u.id = s.user_id
42
+ SET s.last_access_at = IF(s.last_access_at < NOW(6) - INTERVAL ? SECOND, NOW(6), s.last_access_at)
43
+ WHERE s.id_hash = ?
44
+ AND u.enabled
45
+ AND s.last_access_at > NOW(6) - INTERVAL ? SECOND
46
+ AND s.created_at > NOW(6) - INTERVAL ? SECOND`,
47
+ l.RefreshSeconds, hash, l.IdleSeconds, l.AbsoluteSeconds)
48
+ if err != nil {
49
+ return false, fmt.Errorf("touch session: %w", err)
50
+ }
51
+ n, err := res.RowsAffected()
52
+ if err != nil {
53
+ return false, fmt.Errorf("touch session rows: %w", err)
54
+ }
55
+ return n == 1, nil
56
+ }
57
+
58
+ // Principal reads the caller behind a session. The role comes from app_user,
59
+ // never from the session. found is false when the session vanished between
60
+ // Touch and this read (logout, revoke, purge).
61
+ func Principal(ctx context.Context, db *sql.DB, hash []byte) (p security.Principal, found bool, err error) {
62
+ var id, role string
63
+ err = db.QueryRowContext(ctx,
64
+ `SELECT u.id, u.emp_no, u.role
65
+ FROM session s JOIN app_user u ON u.id = s.user_id
66
+ WHERE s.id_hash = ? AND u.enabled`, hash).Scan(&id, &p.EmpNo, &role)
67
+ if errors.Is(err, sql.ErrNoRows) {
68
+ return security.Principal{}, false, nil
69
+ }
70
+ if err != nil {
71
+ return security.Principal{}, false, fmt.Errorf("read session principal: %w", err)
72
+ }
73
+ if p.UserID, err = uuid.Parse(id); err != nil {
74
+ return security.Principal{}, false, fmt.Errorf("parse user id %q: %w", id, err)
75
+ }
76
+ p.Role = security.Role(role)
77
+ return p, true, nil
78
+ }
79
+
80
+ // Delete removes one session; deleting a missing one is not an error.
81
+ func Delete(ctx context.Context, db *sql.DB, hash []byte) error {
82
+ if _, err := db.ExecContext(ctx, `DELETE FROM session WHERE id_hash = ?`, hash); err != nil {
83
+ return fmt.Errorf("delete session: %w", err)
84
+ }
85
+ return nil
86
+ }
87
+
88
+ // DeleteByUser removes every session of one account.
89
+ func DeleteByUser(ctx context.Context, db *sql.DB, userID uuid.UUID) error {
90
+ if _, err := db.ExecContext(ctx, `DELETE FROM session WHERE user_id = ?`, userID.String()); err != nil {
91
+ return fmt.Errorf("delete sessions of user: %w", err)
92
+ }
93
+ return nil
94
+ }
95
+
96
+ // DeleteExpired removes sessions past either timeout and returns how many.
97
+ func DeleteExpired(ctx context.Context, db *sql.DB, l Limits) (int64, error) {
98
+ res, err := db.ExecContext(ctx,
99
+ `DELETE FROM session
100
+ WHERE last_access_at <= NOW(6) - INTERVAL ? SECOND
101
+ OR created_at <= NOW(6) - INTERVAL ? SECOND`,
102
+ l.IdleSeconds, l.AbsoluteSeconds)
103
+ if err != nil {
104
+ return 0, fmt.Errorf("purge sessions: %w", err)
105
+ }
106
+ n, err := res.RowsAffected()
107
+ if err != nil {
108
+ return 0, fmt.Errorf("purge sessions rows: %w", err)
109
+ }
110
+ return n, nil
111
+ }
@@ -0,0 +1,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
+ }