@johnnyboy11234/esg-idea-contest 0.2.2 → 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 +10 -2
- package/backend/internal/account/logging_integration_test.go +70 -0
- package/backend/internal/api/api.gen.go +125 -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/session.go +20 -1
- package/backend/internal/server/api.go +14 -0
- package/backend/internal/server/public.go +3 -1
- package/backend/internal/shared/web/problem.go +9 -0
- package/backend/internal/shared/web/retryafter_test.go +31 -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 +53 -22
- 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/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
|
+
}
|
|
@@ -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 {
|
|
@@ -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.
|
|
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
|
+
}
|