@johnnyboy11234/esg-idea-contest 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +17 -1
  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/csrf.go +212 -0
  6. package/backend/internal/auth/csrf_test.go +281 -0
  7. package/backend/internal/auth/internal/store/session.go +63 -5
  8. package/backend/internal/auth/logging_integration_test.go +88 -0
  9. package/backend/internal/auth/login.go +252 -0
  10. package/backend/internal/auth/login_integration_test.go +421 -0
  11. package/backend/internal/auth/login_test.go +68 -0
  12. package/backend/internal/auth/logout.go +68 -0
  13. package/backend/internal/auth/logout_integration_test.go +124 -0
  14. package/backend/internal/auth/me.go +40 -0
  15. package/backend/internal/auth/me_integration_test.go +70 -0
  16. package/backend/internal/auth/middleware.go +66 -0
  17. package/backend/internal/auth/middleware_test.go +153 -0
  18. package/backend/internal/auth/session.go +20 -1
  19. package/backend/internal/server/api.go +19 -0
  20. package/backend/internal/server/public.go +37 -0
  21. package/backend/internal/server/public_test.go +73 -0
  22. package/backend/internal/server/server.go +7 -3
  23. package/backend/internal/shared/config/config.go +23 -4
  24. package/backend/internal/shared/config/config_test.go +14 -8
  25. package/backend/internal/shared/web/problem.go +9 -0
  26. package/backend/internal/shared/web/retryafter_test.go +31 -0
  27. package/frontend/src/app/App.test.tsx +13 -0
  28. package/frontend/src/app/App.tsx +4 -0
  29. package/frontend/src/app/index.css +102 -102
  30. package/frontend/src/app/layouts.tsx +75 -18
  31. package/frontend/src/app/pages/auth.tsx +52 -78
  32. package/frontend/src/app/pages/login.test.tsx +218 -0
  33. package/frontend/src/app/pages/register.test.tsx +231 -0
  34. package/frontend/src/app/router.tsx +83 -34
  35. package/frontend/src/app/session.ts +71 -18
  36. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  38. package/frontend/src/features/auth/login.ts +23 -0
  39. package/frontend/src/features/auth/register.ts +105 -0
  40. package/frontend/src/shared/api/csrf.ts +51 -0
  41. package/frontend/src/shared/api/http.test.tsx +209 -0
  42. package/frontend/src/shared/api/http.ts +91 -0
  43. package/frontend/src/shared/api/index.ts +3 -0
  44. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  45. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  46. package/frontend/src/shared/text/index.ts +20 -0
  47. package/frontend/src/shared/text/text.test.ts +28 -1
  48. package/frontend/vite.config.ts +5 -1
  49. 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
+ }
@@ -0,0 +1,66 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "log/slog"
6
+ "net/http"
7
+
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
10
+ )
11
+
12
+ // SessionCookie is the session cookie name (openapi sessionCookie). The
13
+ // __Host- prefix forces Secure, Path=/ and no Domain.
14
+ const SessionCookie = "__Host-session"
15
+
16
+ // Validator resolves a cookie value to its caller. *Sessions implements it.
17
+ type Validator interface {
18
+ Validate(ctx context.Context, id string) (p security.Principal, ok bool, err error)
19
+ }
20
+
21
+ // Public reports whether a request may be served without a session. It is
22
+ // decided on method and path before routing, so unknown paths are not public.
23
+ type Public func(r *http.Request) bool
24
+
25
+ // Middleware is the session middleware (Backend.md §3, §6). It must wrap the
26
+ // router, not sit inside it, so that an unknown path without a session answers
27
+ // 401 and only a caller with a session learns it is a 404 (PRD §6.1).
28
+ //
29
+ // - Public request: a valid session is still resolved (the principal is then
30
+ // in the context for the audit actor and the session-bound CSRF token); a
31
+ // missing or invalid one is simply anonymous.
32
+ // - Any other request: no valid session → 401 UNAUTHENTICATED. With a valid
33
+ // session the principal is stored in the context and routing continues, so
34
+ // 404 and 405 come from the router.
35
+ //
36
+ // It never reads the body. A database failure answers 500, never a guess.
37
+ func Middleware(v Validator, public Public, log *slog.Logger) func(http.Handler) http.Handler {
38
+ return func(next http.Handler) http.Handler {
39
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40
+ isPublic := public != nil && public(r)
41
+
42
+ var (
43
+ p security.Principal
44
+ ok bool
45
+ )
46
+ if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" {
47
+ var verr error
48
+ if p, ok, verr = v.Validate(r.Context(), c.Value); verr != nil {
49
+ log.ErrorContext(r.Context(), "session validation failed",
50
+ "requestId", web.RequestIDFrom(r.Context()), "error", verr)
51
+ web.WriteProblem(w, r, web.NewError(web.CodeInternalError, ""))
52
+ return
53
+ }
54
+ }
55
+
56
+ switch {
57
+ case ok:
58
+ r = r.WithContext(security.WithPrincipal(r.Context(), p))
59
+ case !isPublic:
60
+ web.WriteProblem(w, r, web.NewError(web.CodeUnauthenticated, "Authentication required."))
61
+ return
62
+ }
63
+ next.ServeHTTP(w, r)
64
+ })
65
+ }
66
+ }
@@ -0,0 +1,153 @@
1
+ package auth_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "log/slog"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/go-chi/chi/v5"
13
+ "github.com/google/uuid"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
18
+ )
19
+
20
+ type fakeValidator struct {
21
+ live map[string]security.Principal
22
+ err error
23
+ calls int
24
+ }
25
+
26
+ func (f *fakeValidator) Validate(_ context.Context, id string) (security.Principal, bool, error) {
27
+ f.calls++
28
+ if f.err != nil {
29
+ return security.Principal{}, false, f.err
30
+ }
31
+ p, ok := f.live[id]
32
+ return p, ok, nil
33
+ }
34
+
35
+ func isPublic(r *http.Request) bool {
36
+ return r.Method == "GET" && r.URL.Path == "/open"
37
+ }
38
+
39
+ // chain mirrors production: middleware wraps the router, whose NotFound and
40
+ // MethodNotAllowed hooks are the web ones.
41
+ func chain(v auth.Validator, seen *security.Principal) http.Handler {
42
+ r := chi.NewRouter()
43
+ r.NotFound(web.NotFound)
44
+ r.MethodNotAllowed(web.MethodNotAllowed)
45
+ h := func(w http.ResponseWriter, req *http.Request) {
46
+ if p, ok := security.PrincipalFrom(req.Context()); ok && seen != nil {
47
+ *seen = p
48
+ }
49
+ w.WriteHeader(http.StatusNoContent)
50
+ }
51
+ r.Get("/open", h)
52
+ r.Get("/closed", h)
53
+ return web.RequestID(auth.Middleware(v, isPublic, slog.New(slog.DiscardHandler))(r))
54
+ }
55
+
56
+ func request(method, path, cookie string) *http.Request {
57
+ req := httptest.NewRequest(method, path, nil)
58
+ if cookie != "" {
59
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: cookie})
60
+ }
61
+ return req
62
+ }
63
+
64
+ func TestMiddlewareCombinations(t *testing.T) {
65
+ user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
66
+ v := &fakeValidator{live: map[string]security.Principal{"good": user}}
67
+
68
+ tests := []struct {
69
+ name string
70
+ method, path string
71
+ cookie string
72
+ status int
73
+ code string
74
+ principal bool
75
+ }{
76
+ {"public, no session", "GET", "/open", "", 204, "", false},
77
+ {"public, valid session", "GET", "/open", "good", 204, "", true},
78
+ {"public, invalid session is anonymous", "GET", "/open", "stale", 204, "", false},
79
+ {"public path, other method, no session", "POST", "/open", "", 401, "UNAUTHENTICATED", false},
80
+ {"public path, other method, session", "POST", "/open", "good", 405, "METHOD_NOT_ALLOWED", false},
81
+ {"protected, no session", "GET", "/closed", "", 401, "UNAUTHENTICATED", false},
82
+ {"protected, invalid session", "GET", "/closed", "stale", 401, "UNAUTHENTICATED", false},
83
+ {"protected, valid session", "GET", "/closed", "good", 204, "", true},
84
+ {"unknown path, no session", "GET", "/nope", "", 401, "UNAUTHENTICATED", false},
85
+ {"unknown path, invalid session", "GET", "/nope", "stale", 401, "UNAUTHENTICATED", false},
86
+ {"unknown path, session", "GET", "/nope", "good", 404, "NOT_FOUND", false},
87
+ }
88
+ for _, tc := range tests {
89
+ t.Run(tc.name, func(t *testing.T) {
90
+ var seen security.Principal
91
+ rec := httptest.NewRecorder()
92
+ chain(v, &seen).ServeHTTP(rec, request(tc.method, tc.path, tc.cookie))
93
+ if rec.Code != tc.status {
94
+ t.Fatalf("status = %d, want %d (%s)", rec.Code, tc.status, rec.Body)
95
+ }
96
+ if tc.code != "" && !strings.Contains(rec.Body.String(), `"code":"`+tc.code+`"`) {
97
+ t.Errorf("body %s lacks code %s", rec.Body, tc.code)
98
+ }
99
+ if tc.code != "" && rec.Header().Get("Content-Type") != web.ProblemContentType {
100
+ t.Errorf("Content-Type = %q", rec.Header().Get("Content-Type"))
101
+ }
102
+ if got := seen == user; got != tc.principal {
103
+ t.Errorf("principal in context = %v, want %v", got, tc.principal)
104
+ }
105
+ })
106
+ }
107
+ }
108
+
109
+ func TestMiddlewareNoCookieSkipsStore(t *testing.T) {
110
+ v := &fakeValidator{}
111
+ chain(v, nil).ServeHTTP(httptest.NewRecorder(), request("GET", "/open", ""))
112
+ // An empty cookie value is the same as none.
113
+ req := httptest.NewRequest("GET", "/open", nil)
114
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: ""})
115
+ chain(v, nil).ServeHTTP(httptest.NewRecorder(), req)
116
+ if v.calls != 0 {
117
+ t.Errorf("store called %d times without a cookie value", v.calls)
118
+ }
119
+ }
120
+
121
+ func TestMiddlewareIgnoresOtherCookies(t *testing.T) {
122
+ v := &fakeValidator{live: map[string]security.Principal{"good": {Role: security.RoleUser}}}
123
+ req := httptest.NewRequest("GET", "/closed", nil)
124
+ req.AddCookie(&http.Cookie{Name: "session", Value: "good"})
125
+ rec := httptest.NewRecorder()
126
+ chain(v, nil).ServeHTTP(rec, req)
127
+ if rec.Code != 401 || v.calls != 0 {
128
+ t.Errorf("status %d, store calls %d", rec.Code, v.calls)
129
+ }
130
+ }
131
+
132
+ func TestMiddlewareStoreFailureIs500NotPublicFallthrough(t *testing.T) {
133
+ v := &fakeValidator{err: errors.New("db down")}
134
+ for _, path := range []string{"/open", "/closed", "/nope"} {
135
+ rec := httptest.NewRecorder()
136
+ chain(v, nil).ServeHTTP(rec, request("GET", path, "good"))
137
+ if rec.Code != 500 || !strings.Contains(rec.Body.String(), "INTERNAL_ERROR") {
138
+ t.Errorf("%s: status %d body %s", path, rec.Code, rec.Body)
139
+ }
140
+ if strings.Contains(rec.Body.String(), "db down") {
141
+ t.Errorf("%s: cause leaked: %s", path, rec.Body)
142
+ }
143
+ }
144
+ }
145
+
146
+ func TestMiddlewareNilPublicMeansAllProtected(t *testing.T) {
147
+ h := auth.Middleware(&fakeValidator{}, nil, slog.New(slog.DiscardHandler))(http.NotFoundHandler())
148
+ rec := httptest.NewRecorder()
149
+ h.ServeHTTP(rec, request("GET", "/open", ""))
150
+ if rec.Code != 401 {
151
+ t.Errorf("status = %d", rec.Code)
152
+ }
153
+ }
@@ -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 {
@@ -5,6 +5,7 @@ import (
5
5
 
6
6
  "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
7
7
  "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
8
9
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
9
10
  )
10
11
 
@@ -12,6 +13,10 @@ import (
12
13
  // module has not landed yet stay api.Unimplemented (501).
13
14
  type API struct {
14
15
  api.Unimplemented
16
+ Auth auth.Handler
17
+ Logins auth.LoginHandler
18
+ Logouts auth.LogoutHandler
19
+ Me auth.MeHandler
15
20
  Account account.Handler
16
21
  Deadline deadline.Handler
17
22
  }
@@ -23,3 +28,17 @@ func (a API) GetDeadline(w http.ResponseWriter, r *http.Request) { a.Deadline.Ge
23
28
  func (a API) Register(w http.ResponseWriter, r *http.Request, _ api.RegisterParams) {
24
29
  a.Account.Register(w, r)
25
30
  }
31
+
32
+ // GetCsrfToken serves GET /auth/csrf.
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) }