@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.
- package/backend/api/openapi.yaml +70 -0
- package/backend/cmd/server/main.go +17 -1
- package/backend/internal/account/logging_integration_test.go +70 -0
- package/backend/internal/api/api.gen.go +125 -0
- 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 +63 -5
- package/backend/internal/auth/logging_integration_test.go +88 -0
- package/backend/internal/auth/login.go +252 -0
- package/backend/internal/auth/login_integration_test.go +421 -0
- package/backend/internal/auth/login_test.go +68 -0
- package/backend/internal/auth/logout.go +68 -0
- package/backend/internal/auth/logout_integration_test.go +124 -0
- package/backend/internal/auth/me.go +40 -0
- package/backend/internal/auth/me_integration_test.go +70 -0
- package/backend/internal/auth/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -0
- package/backend/internal/auth/session.go +20 -1
- package/backend/internal/server/api.go +19 -0
- package/backend/internal/server/public.go +37 -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/web/problem.go +9 -0
- package/backend/internal/shared/web/retryafter_test.go +31 -0
- package/frontend/src/app/App.test.tsx +13 -0
- package/frontend/src/app/App.tsx +4 -0
- package/frontend/src/app/index.css +102 -102
- package/frontend/src/app/layouts.tsx +75 -18
- package/frontend/src/app/pages/auth.tsx +52 -78
- package/frontend/src/app/pages/login.test.tsx +218 -0
- package/frontend/src/app/pages/register.test.tsx +231 -0
- package/frontend/src/app/router.tsx +83 -34
- package/frontend/src/app/session.ts +71 -18
- package/frontend/src/features/auth/LoginForm.tsx +145 -0
- package/frontend/src/features/auth/RegisterForm.tsx +211 -0
- package/frontend/src/features/auth/login.ts +23 -0
- package/frontend/src/features/auth/register.ts +105 -0
- package/frontend/src/shared/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/i18n/locales/en.ts +15 -2
- package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
- package/frontend/src/shared/text/index.ts +20 -0
- package/frontend/src/shared/text/text.test.ts +28 -1
- package/frontend/vite.config.ts +5 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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) }
|