@johnnyboy11234/esg-idea-contest 0.2.2 → 0.2.4

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 (35) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +10 -2
  3. package/backend/internal/account/logging_integration_test.go +70 -0
  4. package/backend/internal/api/api.gen.go +125 -0
  5. package/backend/internal/auth/internal/store/session.go +63 -5
  6. package/backend/internal/auth/logging_integration_test.go +88 -0
  7. package/backend/internal/auth/login.go +252 -0
  8. package/backend/internal/auth/login_integration_test.go +421 -0
  9. package/backend/internal/auth/login_test.go +68 -0
  10. package/backend/internal/auth/logout.go +68 -0
  11. package/backend/internal/auth/logout_integration_test.go +124 -0
  12. package/backend/internal/auth/me.go +40 -0
  13. package/backend/internal/auth/me_integration_test.go +70 -0
  14. package/backend/internal/auth/session.go +20 -1
  15. package/backend/internal/server/api.go +14 -0
  16. package/backend/internal/server/public.go +3 -1
  17. package/backend/internal/shared/web/problem.go +9 -0
  18. package/backend/internal/shared/web/retryafter_test.go +31 -0
  19. package/frontend/src/app/index.css +102 -102
  20. package/frontend/src/app/layouts.tsx +75 -18
  21. package/frontend/src/app/pages/auth.tsx +52 -78
  22. package/frontend/src/app/pages/login.test.tsx +218 -0
  23. package/frontend/src/app/pages/register.test.tsx +231 -0
  24. package/frontend/src/app/router.tsx +83 -34
  25. package/frontend/src/app/session.ts +53 -22
  26. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  27. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  28. package/frontend/src/features/auth/login.ts +23 -0
  29. package/frontend/src/features/auth/register.ts +105 -0
  30. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  31. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  32. package/frontend/src/shared/text/index.ts +20 -0
  33. package/frontend/src/shared/text/text.test.ts +28 -1
  34. package/frontend/vite.config.ts +5 -1
  35. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ package auth
2
+
3
+ import (
4
+ "slices"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "golang.org/x/crypto/bcrypt"
10
+ )
11
+
12
+ func median(ds []time.Duration) time.Duration {
13
+ slices.Sort(ds)
14
+ return ds[len(ds)/2]
15
+ }
16
+
17
+ func timeCompare(hash []byte, pw string) time.Duration {
18
+ const n = 7
19
+ ds := make([]time.Duration, n)
20
+ for i := range ds {
21
+ start := time.Now()
22
+ comparePassword(hash, pw)
23
+ ds[i] = time.Since(start)
24
+ }
25
+ return median(ds)
26
+ }
27
+
28
+ // Unknown vs wrong password (B1-10 acceptance): one cost-10 comparison either
29
+ // way, so the medians are close. The bound is loose on purpose: bcrypt takes
30
+ // tens of milliseconds, a skipped comparison would be ~1000x off.
31
+ func TestComparePasswordTimingSimilar(t *testing.T) {
32
+ l, err := NewLogins(nil, nil, nil)
33
+ if err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ real, err := bcrypt.GenerateFromPassword([]byte("Correct-Horse-1"), bcryptCost)
37
+ if err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ wrong := timeCompare(real, "Wrong-Horse-99")
41
+ dummy := timeCompare(l.dummyHash, "Wrong-Horse-99")
42
+ long := timeCompare(l.dummyHash, strings.Repeat("x", 100)) // > 72 bytes
43
+
44
+ for name, d := range map[string]time.Duration{"dummy": dummy, "over 72 bytes": long} {
45
+ if d < wrong/2 || d > wrong*2 {
46
+ t.Errorf("%s comparison %v vs wrong-password %v: not similar (a comparison was skipped?)", name, d, wrong)
47
+ }
48
+ }
49
+ }
50
+
51
+ func TestComparePassword(t *testing.T) {
52
+ pw72 := strings.Repeat("a", 72)
53
+ h72, _ := bcrypt.GenerateFromPassword([]byte(pw72), bcryptCost)
54
+ tests := []struct {
55
+ name, pw string
56
+ want bool
57
+ }{
58
+ {"exact 72 bytes", pw72, true},
59
+ {"73 bytes never matches, even with a matching 72-byte prefix", pw72 + "b", false},
60
+ {"wrong", "nope", false},
61
+ {"empty", "", false},
62
+ }
63
+ for _, tc := range tests {
64
+ if got := comparePassword(h72, tc.pw); got != tc.want {
65
+ t.Errorf("%s: got %v", tc.name, got)
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,68 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "net/http"
7
+
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
12
+ )
13
+
14
+ // Logouts implements POST /auth/logout.
15
+ type Logouts struct {
16
+ Sessions *Sessions
17
+ Audit Recorder
18
+ }
19
+
20
+ // Logout deletes the caller's session and writes AUTH_LOGOUT in the same
21
+ // transaction (fail-closed). Without a session there is nothing to end and no
22
+ // actor to record: it succeeds silently (PRD §6.5, idempotent 204). A session
23
+ // that vanished meanwhile (concurrent logout) is not an error either.
24
+ func (l *Logouts) Logout(ctx context.Context, sessionID string, p security.Principal, has bool, req Request) error {
25
+ if !has {
26
+ return nil
27
+ }
28
+ _, err := db.InTx(ctx, l.Sessions.DB, func(ctx context.Context, tx *sql.Tx) error {
29
+ if err := l.Sessions.RevokeIn(ctx, tx, sessionID); err != nil {
30
+ return err
31
+ }
32
+ return l.Audit.Record(ctx, tx, audit.Event{
33
+ Action: audit.AuthLogout, Outcome: audit.Success,
34
+ ActorRole: p.ActorRole(), ActorEmpNo: p.EmpNo,
35
+ TargetType: audit.TargetUser, TargetID: p.UserID,
36
+ Method: req.Method, Route: req.Route, Status: http.StatusNoContent,
37
+ IP: req.IP, UserAgent: req.UserAgent,
38
+ })
39
+ })
40
+ return err
41
+ }
42
+
43
+ // LogoutHandler is the HTTP adapter of Logouts.
44
+ type LogoutHandler struct {
45
+ Logouts *Logouts
46
+ Fail func(w http.ResponseWriter, r *http.Request, err error)
47
+ }
48
+
49
+ // Logout serves POST /auth/logout: 204 and an expired session cookie, with or
50
+ // without a session. The CSRF check has already run in the middleware.
51
+ func (h LogoutHandler) Logout(w http.ResponseWriter, r *http.Request) {
52
+ p, has := security.PrincipalFrom(r.Context())
53
+ var sid string
54
+ if c, err := r.Cookie(SessionCookie); err == nil {
55
+ sid = c.Value
56
+ }
57
+ if err := h.Logouts.Logout(r.Context(), sid, p, has, Request{
58
+ Method: r.Method, Route: web.RoutePattern(r), UserAgent: r.UserAgent(), IP: web.ClientIP(r),
59
+ }); err != nil {
60
+ h.Fail(w, r, err)
61
+ return
62
+ }
63
+ http.SetCookie(w, &http.Cookie{
64
+ Name: SessionCookie, Value: "", Path: "/", MaxAge: -1,
65
+ Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode,
66
+ })
67
+ w.WriteHeader(http.StatusNoContent)
68
+ }
@@ -0,0 +1,124 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+
10
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
11
+ )
12
+
13
+ func (e loginEnv) logout(tok string, ck *http.Cookie, extra ...*http.Cookie) *httptest.ResponseRecorder {
14
+ req := httptest.NewRequest("POST", "/api/v1/auth/logout", nil)
15
+ req.Header.Set("X-CSRF-Token", tok)
16
+ req.Header.Set("User-Agent", "it/1")
17
+ req.RemoteAddr = "192.0.2.9:4000"
18
+ if ck != nil {
19
+ req.AddCookie(ck)
20
+ }
21
+ for _, c := range extra {
22
+ req.AddCookie(c)
23
+ }
24
+ return e.do(req)
25
+ }
26
+
27
+ func (e loginEnv) logoutAudits(t *testing.T) (n int, actor, empNo string, status int) {
28
+ t.Helper()
29
+ if err := e.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action='AUTH_LOGOUT' AND outcome='SUCCESS'`).Scan(&n); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ if n > 0 {
33
+ if err := e.db.QueryRow(`SELECT actor_role, actor_emp_no, status FROM audit_log WHERE action='AUTH_LOGOUT' LIMIT 1`).
34
+ Scan(&actor, &empNo, &status); err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ }
38
+ return
39
+ }
40
+
41
+ func TestLogoutEndsSession(t *testing.T) {
42
+ e := newLoginEnv(t)
43
+ e.addUser(t, "123456", "USER", true)
44
+ sess := findCookie(e.login(t, creds("123456", password)), auth.SessionCookie)
45
+
46
+ tok, ck := e.csrf(t, sess) // token bound to the session
47
+ rec := e.logout(tok, ck, sess)
48
+ if rec.Code != 204 || rec.Body.Len() != 0 {
49
+ t.Fatalf("status %d body %q", rec.Code, rec.Body)
50
+ }
51
+ if c := findCookie(rec, auth.SessionCookie); c == nil || c.MaxAge >= 0 || c.Value != "" || !c.Secure || !c.HttpOnly || c.Path != "/" {
52
+ t.Errorf("cookie not cleared: %+v", c)
53
+ }
54
+ if e.sessions(t) != 0 {
55
+ t.Error("session row still present")
56
+ }
57
+ if n, actor, empNo, status := e.logoutAudits(t); n != 1 || actor != "USER" || empNo != "123456" || status != 204 {
58
+ t.Errorf("audit n=%d actor=%s emp=%s status=%d", n, actor, empNo, status)
59
+ }
60
+
61
+ // The old cookie no longer authenticates.
62
+ req := httptest.NewRequest("GET", "/api/v1/auth/me", nil)
63
+ req.AddCookie(sess)
64
+ if rec := e.do(req); rec.Code != 401 {
65
+ t.Errorf("after logout: %d, want 401", rec.Code)
66
+ }
67
+ // The session-bound token is useless now: replaying logout is a 403 only
68
+ // because the anonymous binding differs.
69
+ if rec := e.logout(tok, ck, sess); rec.Code != 403 {
70
+ t.Errorf("replay with old token: %d, want 403", rec.Code)
71
+ }
72
+ }
73
+
74
+ func TestLogoutWithoutSessionIsIdempotent(t *testing.T) {
75
+ e := newLoginEnv(t)
76
+ tok, ck := e.csrf(t, nil)
77
+ rec := e.logout(tok, ck)
78
+ if rec.Code != 204 {
79
+ t.Fatalf("status %d: %s", rec.Code, rec.Body)
80
+ }
81
+ if n, _, _, _ := e.logoutAudits(t); n != 0 {
82
+ t.Errorf("anonymous logout wrote %d audit rows", n)
83
+ }
84
+ // Expired or junk session cookie behaves the same.
85
+ if rec := e.logout(tok, ck, &http.Cookie{Name: auth.SessionCookie, Value: "junk"}); rec.Code != 204 {
86
+ t.Errorf("junk cookie: %d", rec.Code)
87
+ }
88
+ }
89
+
90
+ func TestLogoutRequiresCSRF(t *testing.T) {
91
+ e := newLoginEnv(t)
92
+ e.addUser(t, "123456", "USER", true)
93
+ sess := findCookie(e.login(t, creds("123456", password)), auth.SessionCookie)
94
+
95
+ anonTok, anonCk := e.csrf(t, nil) // wrong binding for this session
96
+ for name, rec := range map[string]*httptest.ResponseRecorder{
97
+ "no token": e.logout("", nil, sess),
98
+ "anonymous tok": e.logout(anonTok, anonCk, sess),
99
+ } {
100
+ if rec.Code != 403 || code(t, rec) != "CSRF_INVALID" {
101
+ t.Errorf("%s: %d %s", name, rec.Code, rec.Body)
102
+ }
103
+ }
104
+ if e.sessions(t) != 1 {
105
+ t.Error("a rejected logout must not end the session")
106
+ }
107
+ if n, _, _, _ := e.logoutAudits(t); n != 0 {
108
+ t.Error("no SUCCESS row for a rejected logout")
109
+ }
110
+ }
111
+
112
+ func TestLogoutOtherSessionsSurvive(t *testing.T) {
113
+ e := newLoginEnv(t)
114
+ e.addUser(t, "123456", "USER", true)
115
+ a := findCookie(e.login(t, creds("123456", password)), auth.SessionCookie)
116
+ e.login(t, creds("123456", password))
117
+ tok, ck := e.csrf(t, a)
118
+ if rec := e.logout(tok, ck, a); rec.Code != 204 {
119
+ t.Fatal(rec.Code)
120
+ }
121
+ if e.sessions(t) != 1 {
122
+ t.Errorf("want 1 remaining session, got %d", e.sessions(t))
123
+ }
124
+ }
@@ -0,0 +1,40 @@
1
+ package auth
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
7
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth/internal/store"
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
10
+ )
11
+
12
+ // MeHandler serves GET /auth/me. The session middleware has already answered
13
+ // 401 without a valid session and touched the session (idle refresh, OI-11),
14
+ // so this is also the idle-timeout "continue" call (OI-22).
15
+ type MeHandler struct {
16
+ Sessions *Sessions
17
+ Fail func(w http.ResponseWriter, r *http.Request, err error)
18
+ }
19
+
20
+ // GetMe answers the caller from the database (name and email are not in the
21
+ // principal). An account that vanished since the middleware ran answers 401.
22
+ func (h MeHandler) GetMe(w http.ResponseWriter, r *http.Request) {
23
+ p, ok := security.PrincipalFrom(r.Context())
24
+ if !ok {
25
+ h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
26
+ return
27
+ }
28
+ a, found, err := store.AccountByID(r.Context(), h.Sessions.DB, p.UserID)
29
+ if err != nil {
30
+ h.Fail(w, r, err)
31
+ return
32
+ }
33
+ if !found || !a.Enabled {
34
+ h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
35
+ return
36
+ }
37
+ web.WriteJSON(w, http.StatusOK, api.LoginResponse{
38
+ EmpNo: a.EmpNo, Name: a.Name, Email: a.Email, Role: api.LoginResponseRole(a.Role),
39
+ })
40
+ }
@@ -0,0 +1,70 @@
1
+ //go:build integration
2
+
3
+ package auth_test
4
+
5
+ import (
6
+ "encoding/json"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "testing"
10
+
11
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
12
+ )
13
+
14
+ func (e loginEnv) me(t *testing.T, withSession bool, sid string) *httptest.ResponseRecorder {
15
+ t.Helper()
16
+ req := httptest.NewRequest("GET", "/api/v1/auth/me", nil)
17
+ if withSession {
18
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: sid})
19
+ }
20
+ return e.do(req)
21
+ }
22
+
23
+ func TestMeReturnsCaller(t *testing.T) {
24
+ e := newLoginEnv(t)
25
+ e.addUser(t, "123456", "USER", true)
26
+ e.addUser(t, "900001", "ADMIN", true)
27
+
28
+ for _, tc := range []struct {
29
+ emp, role string
30
+ email any
31
+ }{{"123456", "USER", "123456@example.com"}, {"900001", "ADMIN", nil}} {
32
+ sess := findCookie(e.login(t, creds(tc.emp, password)), auth.SessionCookie)
33
+ rec := e.me(t, true, sess.Value)
34
+ if rec.Code != 200 {
35
+ t.Fatalf("%s: status %d", tc.emp, rec.Code)
36
+ }
37
+ var got map[string]any
38
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ if got["empNo"] != tc.emp || got["role"] != tc.role || got["email"] != tc.email || got["name"] != "N "+tc.emp {
42
+ t.Errorf("%s: body %v", tc.emp, got)
43
+ }
44
+ if _, leaked := got["passwordHash"]; leaked || len(got) != 4 {
45
+ t.Errorf("%s: unexpected fields %v", tc.emp, got)
46
+ }
47
+ }
48
+ }
49
+
50
+ func TestMeWithoutValidSessionIs401(t *testing.T) {
51
+ e := newLoginEnv(t)
52
+ if rec := e.me(t, false, ""); rec.Code != 401 || code(t, rec) != "UNAUTHENTICATED" {
53
+ t.Errorf("no cookie: %d", rec.Code)
54
+ }
55
+ if rec := e.me(t, true, "not-a-session"); rec.Code != 401 {
56
+ t.Errorf("bad cookie: %d", rec.Code)
57
+ }
58
+ }
59
+
60
+ func TestMeAfterAccountDisabledIs401(t *testing.T) {
61
+ e := newLoginEnv(t)
62
+ id := e.addUser(t, "123456", "USER", true)
63
+ sess := findCookie(e.login(t, creds("123456", password)), auth.SessionCookie)
64
+ if _, err := e.db.Exec(`UPDATE app_user SET enabled = 0 WHERE id = ?`, id.String()); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ if rec := e.me(t, true, sess.Value); rec.Code != 401 {
68
+ t.Errorf("disabled: %d, want 401", rec.Code)
69
+ }
70
+ }
@@ -45,11 +45,21 @@ func New(db *sql.DB) *Sessions { return &Sessions{DB: db} }
45
45
  // (32 random bytes, base64url without padding). Every login calls it, so an ID
46
46
  // is never reused (no fixation).
47
47
  func (s *Sessions) Create(ctx context.Context, userID uuid.UUID) (string, error) {
48
+ return s.create(ctx, s.DB, userID)
49
+ }
50
+
51
+ // CreateIn is Create inside the caller's transaction, so login commits the
52
+ // session together with its audit row.
53
+ func (s *Sessions) CreateIn(ctx context.Context, tx *sql.Tx, userID uuid.UUID) (string, error) {
54
+ return s.create(ctx, tx, userID)
55
+ }
56
+
57
+ func (s *Sessions) create(ctx context.Context, x store.Execer, userID uuid.UUID) (string, error) {
48
58
  raw := make([]byte, idBytes)
49
59
  if _, err := rand.Read(raw); err != nil {
50
60
  return "", fmt.Errorf("session id: %w", err)
51
61
  }
52
- if err := store.Insert(ctx, s.DB, hash(raw), userID); err != nil {
62
+ if err := store.Insert(ctx, x, hash(raw), userID); err != nil {
53
63
  return "", err
54
64
  }
55
65
  return base64.RawURLEncoding.EncodeToString(raw), nil
@@ -81,6 +91,15 @@ func (s *Sessions) Revoke(ctx context.Context, id string) error {
81
91
  return store.Delete(ctx, s.DB, h)
82
92
  }
83
93
 
94
+ // RevokeIn is Revoke inside the caller's transaction.
95
+ func (s *Sessions) RevokeIn(ctx context.Context, tx *sql.Tx, id string) error {
96
+ h, valid := parse(id)
97
+ if !valid {
98
+ return nil
99
+ }
100
+ return store.Delete(ctx, tx, h)
101
+ }
102
+
84
103
  // RevokeAll deletes every session of the user. It implements
85
104
  // account.SessionRevoker (wired in main.go; account never imports auth).
86
105
  func (s *Sessions) RevokeAll(ctx context.Context, userID uuid.UUID) error {
@@ -14,6 +14,9 @@ import (
14
14
  type API struct {
15
15
  api.Unimplemented
16
16
  Auth auth.Handler
17
+ Logins auth.LoginHandler
18
+ Logouts auth.LogoutHandler
19
+ Me auth.MeHandler
17
20
  Account account.Handler
18
21
  Deadline deadline.Handler
19
22
  }
@@ -28,3 +31,14 @@ func (a API) Register(w http.ResponseWriter, r *http.Request, _ api.RegisterPara
28
31
 
29
32
  // GetCsrfToken serves GET /auth/csrf.
30
33
  func (a API) GetCsrfToken(w http.ResponseWriter, r *http.Request) { a.Auth.GetCsrfToken(w, r) }
34
+
35
+ // Login serves POST /auth/login.
36
+ func (a API) Login(w http.ResponseWriter, r *http.Request, _ api.LoginParams) { a.Logins.Login(w, r) }
37
+
38
+ // Logout serves POST /auth/logout.
39
+ func (a API) Logout(w http.ResponseWriter, r *http.Request, _ api.LogoutParams) {
40
+ a.Logouts.Logout(w, r)
41
+ }
42
+
43
+ // GetMe serves GET /auth/me.
44
+ func (a API) GetMe(w http.ResponseWriter, r *http.Request) { a.Me.GetMe(w, r) }
@@ -4,9 +4,10 @@ import "net/http"
4
4
 
5
5
  // PublicRoutes is the explicit list of operations that need no session
6
6
  // (Backend.md §3). It must equal the operations marked `security: []` in
7
- // api/openapi.yaml; a test locks that. B1-10 adds POST /auth/login to both. Paths include the /api/v1 prefix.
7
+ // api/openapi.yaml; a test locks that. Paths include the /api/v1 prefix.
8
8
  var PublicRoutes = []struct{ Method, Path string }{
9
9
  {http.MethodGet, "/api/v1/auth/csrf"},
10
+ {http.MethodPost, "/api/v1/auth/login"},
10
11
  {http.MethodPost, "/api/v1/auth/register"},
11
12
  {http.MethodPost, "/api/v1/auth/logout"},
12
13
  {http.MethodGet, "/api/v1/deadline"},
@@ -27,6 +28,7 @@ func IsPublic(r *http.Request) bool {
27
28
  // CSRF denial is recorded (PRD §6.11). Later PRs add their routes here, with
28
29
  // the same exact-path rule; a write on an unknown path is not audited.
29
30
  var WriteActions = map[string]string{
31
+ http.MethodPost + " /api/v1/auth/login": "AUTH_LOGIN",
30
32
  http.MethodPost + " /api/v1/auth/register": "AUTH_REGISTER",
31
33
  http.MethodPost + " /api/v1/auth/logout": "AUTH_LOGOUT",
32
34
  }
@@ -3,6 +3,8 @@ package web
3
3
  import (
4
4
  "encoding/json"
5
5
  "net/http"
6
+ "strconv"
7
+ "time"
6
8
 
7
9
  "github.com/google/uuid"
8
10
  )
@@ -28,6 +30,9 @@ type Error struct {
28
30
  Detail string
29
31
  Fields []FieldError
30
32
  Cause error
33
+ // RetryAfter, when positive, is sent as Retry-After in whole seconds,
34
+ // rounded up (minimum 1; PRD §6.5 throttling).
35
+ RetryAfter time.Duration
31
36
 
32
37
  // AuditReason is the real reason of a rejected attempt (CSRF, role, state,
33
38
  // not-yours, not-found). It is stored in the row's after_json, never sent
@@ -94,6 +99,10 @@ func WriteProblem(w http.ResponseWriter, r *http.Request, e *Error) {
94
99
  }
95
100
  w.Header().Set("Content-Type", ProblemContentType)
96
101
  w.Header().Set("Cache-Control", "no-store")
102
+ if e.RetryAfter > 0 {
103
+ secs := max(1, int64((e.RetryAfter+time.Second-1)/time.Second))
104
+ w.Header().Set("Retry-After", strconv.FormatInt(secs, 10))
105
+ }
97
106
  w.WriteHeader(e.Status)
98
107
  _ = json.NewEncoder(w).Encode(body)
99
108
  }
@@ -0,0 +1,31 @@
1
+ package web_test
2
+
3
+ import (
4
+ "net/http/httptest"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
9
+ )
10
+
11
+ func TestRetryAfterWholeSecondsRoundedUpMinimumOne(t *testing.T) {
12
+ tests := []struct {
13
+ d time.Duration
14
+ want string
15
+ }{
16
+ {0, ""},
17
+ {time.Millisecond, "1"},
18
+ {time.Second, "1"},
19
+ {1001 * time.Millisecond, "2"},
20
+ {15 * time.Minute, "900"},
21
+ }
22
+ for _, tc := range tests {
23
+ e := web.NewError(web.CodeLoginThrottled, "")
24
+ e.RetryAfter = tc.d
25
+ rec := httptest.NewRecorder()
26
+ web.WriteProblem(rec, httptest.NewRequest("POST", "/", nil), e)
27
+ if got := rec.Header().Get("Retry-After"); got != tc.want {
28
+ t.Errorf("%v: Retry-After = %q, want %q", tc.d, got, tc.want)
29
+ }
30
+ }
31
+ }