@johnnyboy11234/esg-idea-contest 0.2.0 → 0.2.2
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 +44 -0
- package/backend/cmd/server/main.go +20 -2
- package/backend/db/migration/V004__session.sql +19 -0
- package/backend/go.mod +1 -1
- package/backend/internal/account/account_test.go +191 -0
- package/backend/internal/account/handler.go +58 -0
- package/backend/internal/account/integration_test.go +268 -0
- package/backend/internal/account/internal/store/store.go +35 -0
- package/backend/internal/account/service.go +153 -0
- package/backend/internal/account/validate.go +100 -0
- package/backend/internal/api/api.gen.go +82 -0
- package/backend/internal/audit/integration_test.go +1 -1
- 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 +111 -0
- package/backend/internal/auth/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -0
- package/backend/internal/auth/session.go +113 -0
- package/backend/internal/auth/session_integration_test.go +245 -0
- package/backend/internal/auth/session_test.go +36 -0
- package/backend/internal/server/api.go +12 -0
- package/backend/internal/server/errors.go +10 -25
- package/backend/internal/server/errors_test.go +18 -0
- package/backend/internal/server/public.go +35 -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/db/intx_test.go +25 -0
- package/backend/internal/shared/db/mysqlerr.go +26 -0
- package/backend/internal/shared/security/security.go +6 -2
- package/backend/internal/shared/security/security_test.go +6 -4
- package/backend/internal/shared/web/problem.go +6 -0
- package/backend/internal/shared/web/request.go +27 -0
- package/frontend/src/app/App.test.tsx +13 -0
- package/frontend/src/app/App.tsx +4 -0
- package/frontend/src/app/session.ts +28 -6
- 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/text/index.ts +93 -0
- package/frontend/src/shared/text/text.test.ts +22 -0
- package/package.json +1 -1
- package/frontend/src/shared/text/.gitkeep +0 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
package auth_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/rand"
|
|
6
|
+
"encoding/base64"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"errors"
|
|
9
|
+
"log/slog"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/http/httptest"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
|
|
15
|
+
"github.com/go-chi/chi/v5"
|
|
16
|
+
"github.com/google/uuid"
|
|
17
|
+
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
19
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const (
|
|
24
|
+
keyA = "0123456789abcdef0123456789abcdef"
|
|
25
|
+
keyB = "fedcba9876543210fedcba9876543210"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
func sid(t *testing.T) string {
|
|
29
|
+
t.Helper()
|
|
30
|
+
b := make([]byte, 32)
|
|
31
|
+
if _, err := rand.Read(b); err != nil {
|
|
32
|
+
t.Fatal(err)
|
|
33
|
+
}
|
|
34
|
+
return base64.RawURLEncoding.EncodeToString(b)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func mustCSRF(t *testing.T, cur string, prev ...string) *auth.CSRF {
|
|
38
|
+
t.Helper()
|
|
39
|
+
c, err := auth.NewCSRF(cur, prev...)
|
|
40
|
+
if err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
return c
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type denial struct{ action, reason string }
|
|
47
|
+
|
|
48
|
+
// csrfChain mirrors production order: session middleware, then CSRF, then router.
|
|
49
|
+
func csrfChain(c *auth.CSRF, v auth.Validator, got *denial) http.Handler {
|
|
50
|
+
r := chi.NewRouter()
|
|
51
|
+
r.NotFound(web.NotFound)
|
|
52
|
+
r.MethodNotAllowed(web.MethodNotAllowed)
|
|
53
|
+
ok := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
|
|
54
|
+
r.Get("/auth/csrf", auth.Handler{CSRF: c, Log: slog.New(slog.DiscardHandler)}.GetCsrfToken)
|
|
55
|
+
r.Post("/w", ok)
|
|
56
|
+
r.Put("/w", ok)
|
|
57
|
+
r.Delete("/w", ok)
|
|
58
|
+
r.Post("/auth/logout", ok)
|
|
59
|
+
fail := func(w http.ResponseWriter, req *http.Request, err error) {
|
|
60
|
+
var e *web.Error
|
|
61
|
+
if errors.As(err, &e) && got != nil {
|
|
62
|
+
*got = denial{e.AuditAction, e.AuditReason}
|
|
63
|
+
}
|
|
64
|
+
web.WriteError(w, req, err)
|
|
65
|
+
}
|
|
66
|
+
action := func(req *http.Request) string {
|
|
67
|
+
if req.URL.Path == "/auth/logout" {
|
|
68
|
+
return "AUTH_LOGOUT"
|
|
69
|
+
}
|
|
70
|
+
return ""
|
|
71
|
+
}
|
|
72
|
+
public := func(req *http.Request) bool { return req.URL.Path == "/auth/csrf" || req.URL.Path == "/auth/logout" }
|
|
73
|
+
log := slog.New(slog.DiscardHandler)
|
|
74
|
+
return web.RequestID(auth.Middleware(v, public, log)(c.Middleware(fail, action)(r)))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func fetch(t *testing.T, h http.Handler, session string) string {
|
|
78
|
+
t.Helper()
|
|
79
|
+
req := httptest.NewRequest("GET", "/auth/csrf", nil)
|
|
80
|
+
if session != "" {
|
|
81
|
+
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
|
82
|
+
}
|
|
83
|
+
rec := httptest.NewRecorder()
|
|
84
|
+
h.ServeHTTP(rec, req)
|
|
85
|
+
if rec.Code != 200 {
|
|
86
|
+
t.Fatalf("GET /auth/csrf = %d %s", rec.Code, rec.Body)
|
|
87
|
+
}
|
|
88
|
+
var body struct{ Token string }
|
|
89
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
|
|
90
|
+
t.Fatalf("body %s: %v", rec.Body, err)
|
|
91
|
+
}
|
|
92
|
+
var ck *http.Cookie
|
|
93
|
+
for _, c := range rec.Result().Cookies() {
|
|
94
|
+
if c.Name == auth.CSRFCookie {
|
|
95
|
+
ck = c
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if ck == nil || ck.Value != body.Token {
|
|
99
|
+
t.Fatalf("cookie %v does not match body token", ck)
|
|
100
|
+
}
|
|
101
|
+
if !ck.HttpOnly || !ck.Secure || ck.Path != "/" || ck.Domain != "" || ck.SameSite != http.SameSiteLaxMode {
|
|
102
|
+
t.Errorf("cookie attributes: %+v", ck)
|
|
103
|
+
}
|
|
104
|
+
if rec.Header().Get("Cache-Control") != "no-store" {
|
|
105
|
+
t.Errorf("Cache-Control = %q", rec.Header().Get("Cache-Control"))
|
|
106
|
+
}
|
|
107
|
+
return body.Token
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func write(method, path, session, cookie, header string) *http.Request {
|
|
111
|
+
req := httptest.NewRequest(method, path, strings.NewReader("ignored"))
|
|
112
|
+
if session != "" {
|
|
113
|
+
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
|
114
|
+
}
|
|
115
|
+
if cookie != "" {
|
|
116
|
+
req.AddCookie(&http.Cookie{Name: auth.CSRFCookie, Value: cookie})
|
|
117
|
+
}
|
|
118
|
+
if header != "" {
|
|
119
|
+
req.Header.Set(auth.CSRFHeader, header)
|
|
120
|
+
}
|
|
121
|
+
return req
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func TestCSRFWrites(t *testing.T) {
|
|
125
|
+
c := mustCSRF(t, keyA)
|
|
126
|
+
s1, s2 := sid(t), sid(t)
|
|
127
|
+
user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
|
|
128
|
+
v := &fakeValidator{live: map[string]security.Principal{s1: user, s2: user}}
|
|
129
|
+
h := csrfChain(c, v, nil)
|
|
130
|
+
|
|
131
|
+
anon, tok1, tok2 := fetch(t, h, ""), fetch(t, h, s1), fetch(t, h, s2)
|
|
132
|
+
other := mustCSRF(t, keyB)
|
|
133
|
+
foreign, _ := other.Issue([]byte("x"))
|
|
134
|
+
|
|
135
|
+
tests := []struct {
|
|
136
|
+
name string
|
|
137
|
+
method, path string
|
|
138
|
+
session, cookie, header string
|
|
139
|
+
status int
|
|
140
|
+
}{
|
|
141
|
+
{"session token, own session", "POST", "/w", s1, tok1, tok1, 204},
|
|
142
|
+
{"anonymous token, no session", "POST", "/auth/logout", "", anon, anon, 204},
|
|
143
|
+
{"PUT and DELETE are checked", "PUT", "/w", s1, "", "", 403},
|
|
144
|
+
{"DELETE with token", "DELETE", "/w", s1, tok1, tok1, 204},
|
|
145
|
+
{"no header", "POST", "/w", s1, tok1, "", 403},
|
|
146
|
+
{"no cookie", "POST", "/w", s1, "", tok1, 403},
|
|
147
|
+
{"cookie and header differ", "POST", "/w", s1, tok1, tok2, 403},
|
|
148
|
+
{"other session's token", "POST", "/w", s1, tok2, tok2, 403},
|
|
149
|
+
{"anonymous token against a session", "POST", "/w", s1, anon, anon, 403},
|
|
150
|
+
{"session token without a session (logout)", "POST", "/auth/logout", "", tok1, tok1, 403},
|
|
151
|
+
{"session token, stale session cookie (logout)", "POST", "/auth/logout", sid(t), tok1, tok1, 403},
|
|
152
|
+
{"signed by an unknown key", "POST", "/w", s1, foreign, foreign, 403},
|
|
153
|
+
{"garbage", "POST", "/w", s1, "abc", "abc", 403},
|
|
154
|
+
{"truncated signature", "POST", "/w", s1, tok1[:len(tok1)-2], tok1[:len(tok1)-2], 403},
|
|
155
|
+
{"no dot", "POST", "/w", s1, strings.ReplaceAll(tok1, ".", ""), strings.ReplaceAll(tok1, ".", ""), 403},
|
|
156
|
+
{"403 beats 404 on unknown path", "POST", "/nope", s1, "", "", 403},
|
|
157
|
+
{"unknown path, valid token, is 404", "POST", "/nope", s1, tok1, tok1, 404},
|
|
158
|
+
{"401 beats 403", "POST", "/w", "", "", "", 401},
|
|
159
|
+
}
|
|
160
|
+
for _, tc := range tests {
|
|
161
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
162
|
+
rec := httptest.NewRecorder()
|
|
163
|
+
h.ServeHTTP(rec, write(tc.method, tc.path, tc.session, tc.cookie, tc.header))
|
|
164
|
+
if rec.Code != tc.status {
|
|
165
|
+
t.Fatalf("status = %d, want %d (%s)", rec.Code, tc.status, rec.Body)
|
|
166
|
+
}
|
|
167
|
+
if tc.status == 403 && !strings.Contains(rec.Body.String(), `"code":"CSRF_INVALID"`) {
|
|
168
|
+
t.Errorf("body %s", rec.Body)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func TestCSRFSafeMethodsAreNotChecked(t *testing.T) {
|
|
175
|
+
h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
|
|
176
|
+
for _, m := range []string{"GET", "HEAD", "OPTIONS"} {
|
|
177
|
+
rec := httptest.NewRecorder()
|
|
178
|
+
h.ServeHTTP(rec, httptest.NewRequest(m, "/auth/csrf", nil))
|
|
179
|
+
if rec.Code == 403 {
|
|
180
|
+
t.Errorf("%s was CSRF-checked", m)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func TestCSRFNeverReadsBody(t *testing.T) {
|
|
186
|
+
h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
|
|
187
|
+
req := write("POST", "/auth/logout", "", "", "")
|
|
188
|
+
req.Body = failReader{t}
|
|
189
|
+
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
type failReader struct{ t *testing.T }
|
|
193
|
+
|
|
194
|
+
func (f failReader) Read([]byte) (int, error) { f.t.Error("body was read"); return 0, context.Canceled }
|
|
195
|
+
func (failReader) Close() error { return nil }
|
|
196
|
+
|
|
197
|
+
func TestCSRFDenialAuditFields(t *testing.T) {
|
|
198
|
+
c := mustCSRF(t, keyA)
|
|
199
|
+
anon := fetch(t, csrfChain(c, &fakeValidator{}, nil), "")
|
|
200
|
+
for _, tc := range []struct{ name, cookie, header, reason string }{
|
|
201
|
+
{"missing", "", "", "csrf_missing"},
|
|
202
|
+
{"mismatch", anon, anon + "x", "csrf_mismatch"},
|
|
203
|
+
{"bad signature", "a.b", "a.b", "csrf_invalid_binding"},
|
|
204
|
+
} {
|
|
205
|
+
var d denial
|
|
206
|
+
rec := httptest.NewRecorder()
|
|
207
|
+
csrfChain(c, &fakeValidator{}, &d).ServeHTTP(rec, write("POST", "/auth/logout", "", tc.cookie, tc.header))
|
|
208
|
+
if rec.Code != 403 || d != (denial{"AUTH_LOGOUT", tc.reason}) {
|
|
209
|
+
t.Errorf("%s: status %d, audit %+v", tc.name, rec.Code, d)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Unrouted write: denied, but nothing to audit.
|
|
213
|
+
var d denial
|
|
214
|
+
csrfChain(c, &fakeValidator{live: map[string]security.Principal{"x": {}}}, &d).
|
|
215
|
+
ServeHTTP(httptest.NewRecorder(), write("POST", "/w", "", "", ""))
|
|
216
|
+
if d != (denial{}) {
|
|
217
|
+
t.Errorf("unknown action was audited: %+v", d)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func TestCSRFTokensAreFresh(t *testing.T) {
|
|
222
|
+
h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
|
|
223
|
+
first, second := fetch(t, h, ""), fetch(t, h, "")
|
|
224
|
+
if first == second {
|
|
225
|
+
t.Error("two tokens are equal")
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func TestCSRFKeyRotation(t *testing.T) {
|
|
230
|
+
b := []byte("binding")
|
|
231
|
+
old := mustCSRF(t, keyA)
|
|
232
|
+
tok, _ := old.Issue(b)
|
|
233
|
+
|
|
234
|
+
rotated := mustCSRF(t, keyB, keyA)
|
|
235
|
+
if !rotated.Verify(tok, b) {
|
|
236
|
+
t.Error("previous key must still verify")
|
|
237
|
+
}
|
|
238
|
+
if fresh, _ := rotated.Issue(b); !mustCSRF(t, keyB).Verify(fresh, b) || old.Verify(fresh, b) {
|
|
239
|
+
t.Error("new tokens must be signed by the current key only")
|
|
240
|
+
}
|
|
241
|
+
if mustCSRF(t, keyB).Verify(tok, b) {
|
|
242
|
+
t.Error("after the previous key is dropped the old token must fail")
|
|
243
|
+
}
|
|
244
|
+
if !mustCSRF(t, keyB, "").Verify(mustTok(t, mustCSRF(t, keyB), b), b) {
|
|
245
|
+
t.Error("empty previous key means none")
|
|
246
|
+
}
|
|
247
|
+
if old.Verify(tok, []byte("other")) {
|
|
248
|
+
t.Error("binding must be part of the signature")
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func mustTok(t *testing.T, c *auth.CSRF, b []byte) string {
|
|
253
|
+
t.Helper()
|
|
254
|
+
tok, err := c.Issue(b)
|
|
255
|
+
if err != nil {
|
|
256
|
+
t.Fatal(err)
|
|
257
|
+
}
|
|
258
|
+
return tok
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func TestNewCSRFRejectsShortKeys(t *testing.T) {
|
|
262
|
+
if _, err := auth.NewCSRF("short"); err == nil {
|
|
263
|
+
t.Error("short current key accepted")
|
|
264
|
+
}
|
|
265
|
+
if _, err := auth.NewCSRF(keyA, "short"); err == nil {
|
|
266
|
+
t.Error("short previous key accepted")
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func TestCSRFSessionWithUnbindableIDIs500(t *testing.T) {
|
|
271
|
+
// A validator that accepts an ID Binding cannot parse is a wiring bug: fail closed.
|
|
272
|
+
v := &fakeValidator{live: map[string]security.Principal{"not-an-id": {Role: security.RoleUser}}}
|
|
273
|
+
h := csrfChain(mustCSRF(t, keyA), v, nil)
|
|
274
|
+
req := httptest.NewRequest("GET", "/auth/csrf", nil)
|
|
275
|
+
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: "not-an-id"})
|
|
276
|
+
rec := httptest.NewRecorder()
|
|
277
|
+
h.ServeHTTP(rec, req)
|
|
278
|
+
if rec.Code != 500 {
|
|
279
|
+
t.Errorf("status = %d", rec.Code)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Package store holds the SQL of the auth module. Only the auth module may
|
|
2
|
+
// import it (Go's internal rule); no other module touches the session table.
|
|
3
|
+
package store
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
|
|
13
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// Limits are the session timeouts in seconds (PRD §6.5).
|
|
17
|
+
type Limits struct {
|
|
18
|
+
IdleSeconds int
|
|
19
|
+
AbsoluteSeconds int
|
|
20
|
+
RefreshSeconds int
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Insert stores a new session row. hash is SHA-256 of the session ID.
|
|
24
|
+
func Insert(ctx context.Context, db *sql.DB, hash []byte, userID uuid.UUID) error {
|
|
25
|
+
_, err := db.ExecContext(ctx,
|
|
26
|
+
`INSERT INTO session (id_hash, user_id) VALUES (?, ?)`, hash, userID.String())
|
|
27
|
+
if err != nil {
|
|
28
|
+
return fmt.Errorf("insert session: %w", err)
|
|
29
|
+
}
|
|
30
|
+
return nil
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Touch validates and refreshes a session in ONE statement on database time.
|
|
34
|
+
// It reports true when the row matched: the session exists, is inside both
|
|
35
|
+
// timeouts and its account is enabled. RowsAffected counts matched rows
|
|
36
|
+
// (clientFoundRows=true in the DSN), so a row that needs no refresh still
|
|
37
|
+
// counts; a test locks this. last_access_at is written only when older than
|
|
38
|
+
// the refresh interval (OI-11).
|
|
39
|
+
func Touch(ctx context.Context, db *sql.DB, hash []byte, l Limits) (bool, error) {
|
|
40
|
+
res, err := db.ExecContext(ctx,
|
|
41
|
+
`UPDATE session s JOIN app_user u ON u.id = s.user_id
|
|
42
|
+
SET s.last_access_at = IF(s.last_access_at < NOW(6) - INTERVAL ? SECOND, NOW(6), s.last_access_at)
|
|
43
|
+
WHERE s.id_hash = ?
|
|
44
|
+
AND u.enabled
|
|
45
|
+
AND s.last_access_at > NOW(6) - INTERVAL ? SECOND
|
|
46
|
+
AND s.created_at > NOW(6) - INTERVAL ? SECOND`,
|
|
47
|
+
l.RefreshSeconds, hash, l.IdleSeconds, l.AbsoluteSeconds)
|
|
48
|
+
if err != nil {
|
|
49
|
+
return false, fmt.Errorf("touch session: %w", err)
|
|
50
|
+
}
|
|
51
|
+
n, err := res.RowsAffected()
|
|
52
|
+
if err != nil {
|
|
53
|
+
return false, fmt.Errorf("touch session rows: %w", err)
|
|
54
|
+
}
|
|
55
|
+
return n == 1, nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Principal reads the caller behind a session. The role comes from app_user,
|
|
59
|
+
// never from the session. found is false when the session vanished between
|
|
60
|
+
// Touch and this read (logout, revoke, purge).
|
|
61
|
+
func Principal(ctx context.Context, db *sql.DB, hash []byte) (p security.Principal, found bool, err error) {
|
|
62
|
+
var id, role string
|
|
63
|
+
err = db.QueryRowContext(ctx,
|
|
64
|
+
`SELECT u.id, u.emp_no, u.role
|
|
65
|
+
FROM session s JOIN app_user u ON u.id = s.user_id
|
|
66
|
+
WHERE s.id_hash = ? AND u.enabled`, hash).Scan(&id, &p.EmpNo, &role)
|
|
67
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
68
|
+
return security.Principal{}, false, nil
|
|
69
|
+
}
|
|
70
|
+
if err != nil {
|
|
71
|
+
return security.Principal{}, false, fmt.Errorf("read session principal: %w", err)
|
|
72
|
+
}
|
|
73
|
+
if p.UserID, err = uuid.Parse(id); err != nil {
|
|
74
|
+
return security.Principal{}, false, fmt.Errorf("parse user id %q: %w", id, err)
|
|
75
|
+
}
|
|
76
|
+
p.Role = security.Role(role)
|
|
77
|
+
return p, true, nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Delete removes one session; deleting a missing one is not an error.
|
|
81
|
+
func Delete(ctx context.Context, db *sql.DB, hash []byte) error {
|
|
82
|
+
if _, err := db.ExecContext(ctx, `DELETE FROM session WHERE id_hash = ?`, hash); err != nil {
|
|
83
|
+
return fmt.Errorf("delete session: %w", err)
|
|
84
|
+
}
|
|
85
|
+
return nil
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// DeleteByUser removes every session of one account.
|
|
89
|
+
func DeleteByUser(ctx context.Context, db *sql.DB, userID uuid.UUID) error {
|
|
90
|
+
if _, err := db.ExecContext(ctx, `DELETE FROM session WHERE user_id = ?`, userID.String()); err != nil {
|
|
91
|
+
return fmt.Errorf("delete sessions of user: %w", err)
|
|
92
|
+
}
|
|
93
|
+
return nil
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// DeleteExpired removes sessions past either timeout and returns how many.
|
|
97
|
+
func DeleteExpired(ctx context.Context, db *sql.DB, l Limits) (int64, error) {
|
|
98
|
+
res, err := db.ExecContext(ctx,
|
|
99
|
+
`DELETE FROM session
|
|
100
|
+
WHERE last_access_at <= NOW(6) - INTERVAL ? SECOND
|
|
101
|
+
OR created_at <= NOW(6) - INTERVAL ? SECOND`,
|
|
102
|
+
l.IdleSeconds, l.AbsoluteSeconds)
|
|
103
|
+
if err != nil {
|
|
104
|
+
return 0, fmt.Errorf("purge sessions: %w", err)
|
|
105
|
+
}
|
|
106
|
+
n, err := res.RowsAffected()
|
|
107
|
+
if err != nil {
|
|
108
|
+
return 0, fmt.Errorf("purge sessions rows: %w", err)
|
|
109
|
+
}
|
|
110
|
+
return n, nil
|
|
111
|
+
}
|
|
@@ -0,0 +1,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
|
+
}
|