@johnnyboy11234/esg-idea-contest 0.2.1 → 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/cmd/server/main.go +9 -1
- package/backend/internal/auth/csrf.go +212 -0
- package/backend/internal/auth/csrf_test.go +281 -0
- package/backend/internal/auth/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -0
- package/backend/internal/server/api.go +5 -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/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/package.json +1 -1
|
@@ -15,6 +15,7 @@ import (
|
|
|
15
15
|
|
|
16
16
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/account"
|
|
17
17
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
18
19
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
19
20
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
20
21
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/config"
|
|
@@ -46,7 +47,12 @@ func run() error {
|
|
|
46
47
|
|
|
47
48
|
auditSvc := audit.New(db, log)
|
|
48
49
|
errs := server.ErrorAdapter{Audit: auditSvc, Log: log}
|
|
50
|
+
csrf, err := auth.NewCSRF(cfg.CSRFKey, cfg.CSRFKeyPrevious)
|
|
51
|
+
if err != nil {
|
|
52
|
+
return err
|
|
53
|
+
}
|
|
49
54
|
si := server.API{
|
|
55
|
+
Auth: auth.Handler{CSRF: csrf, Log: log},
|
|
50
56
|
Account: account.Handler{
|
|
51
57
|
Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
|
|
52
58
|
Fail: errs.Write,
|
|
@@ -55,5 +61,7 @@ func run() error {
|
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
var ready atomic.Bool
|
|
58
|
-
return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si)
|
|
64
|
+
return server.Run(ctx, cfg, log, server.NewPublic(log, server.Mount(si),
|
|
65
|
+
auth.Middleware(auth.New(db), server.IsPublic, log),
|
|
66
|
+
csrf.Middleware(errs.Write, server.ActionFor)), &ready, server.DBCheck(db))
|
|
59
67
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/hmac"
|
|
5
|
+
"crypto/rand"
|
|
6
|
+
"crypto/sha256"
|
|
7
|
+
"crypto/subtle"
|
|
8
|
+
"encoding/base64"
|
|
9
|
+
"errors"
|
|
10
|
+
"fmt"
|
|
11
|
+
"log/slog"
|
|
12
|
+
"net/http"
|
|
13
|
+
"strings"
|
|
14
|
+
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// CSRF names and sizes (PRD §6.5, Backend.md §6, D40).
|
|
21
|
+
const (
|
|
22
|
+
// CSRFCookie is the double-submit cookie. The __Host- prefix forces Secure,
|
|
23
|
+
// Path=/ and no Domain; HttpOnly keeps it away from script, which reads the
|
|
24
|
+
// token from the GET /auth/csrf body instead.
|
|
25
|
+
CSRFCookie = "__Host-csrf"
|
|
26
|
+
// CSRFHeader carries the token on every write.
|
|
27
|
+
CSRFHeader = "X-CSRF-Token"
|
|
28
|
+
|
|
29
|
+
// MinCSRFKeyBytes is the shortest accepted HMAC key (the SHA-256 output size).
|
|
30
|
+
MinCSRFKeyBytes = 32
|
|
31
|
+
|
|
32
|
+
nonceBytes = 16
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
var anonymousBinding = []byte{0x00, 'a', 'n', 'o', 'n'}
|
|
36
|
+
|
|
37
|
+
// CSRF signs and verifies session-bound double-submit tokens:
|
|
38
|
+
//
|
|
39
|
+
// base64url(nonce) "." base64url(HMAC-SHA256(key, binding ‖ nonce))
|
|
40
|
+
//
|
|
41
|
+
// The binding is 0x01 ‖ SHA-256(session ID) with a session, a fixed anonymous
|
|
42
|
+
// value without one. A token minted anonymously, or for another session,
|
|
43
|
+
// therefore fails against a victim's session (D40).
|
|
44
|
+
//
|
|
45
|
+
// The first key signs; every key verifies. Rotation: make the new key current
|
|
46
|
+
// and the old one previous, and drop the previous key after 8 h (Backend.md §6).
|
|
47
|
+
// Tokens carry no expiry, so the operator's removal of the old key is the bound.
|
|
48
|
+
type CSRF struct {
|
|
49
|
+
keys [][]byte
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// NewCSRF returns a signer. current signs; previous (optional) only verifies.
|
|
53
|
+
func NewCSRF(current string, previous ...string) (*CSRF, error) {
|
|
54
|
+
c := &CSRF{}
|
|
55
|
+
for i, k := range append([]string{current}, previous...) {
|
|
56
|
+
if i > 0 && k == "" {
|
|
57
|
+
continue // an unset previous key means "no rotation in progress"
|
|
58
|
+
}
|
|
59
|
+
if len(k) < MinCSRFKeyBytes {
|
|
60
|
+
return nil, fmt.Errorf("csrf key %d: need at least %d bytes", i, MinCSRFKeyBytes)
|
|
61
|
+
}
|
|
62
|
+
c.keys = append(c.keys, []byte(k))
|
|
63
|
+
}
|
|
64
|
+
return c, nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// bindingFor returns the binding for a request: the session's when the session
|
|
68
|
+
// middleware resolved one, else the anonymous value. ok is false only when a
|
|
69
|
+
// validated session carries an ID that cannot be bound (cannot happen unless
|
|
70
|
+
// the validator and Binding disagree).
|
|
71
|
+
func bindingFor(r *http.Request) (b []byte, ok bool) {
|
|
72
|
+
if _, has := security.PrincipalFrom(r.Context()); !has {
|
|
73
|
+
return anonymousBinding, true
|
|
74
|
+
}
|
|
75
|
+
c, err := r.Cookie(SessionCookie)
|
|
76
|
+
if err != nil {
|
|
77
|
+
return nil, false
|
|
78
|
+
}
|
|
79
|
+
h, ok := Binding(c.Value)
|
|
80
|
+
if !ok {
|
|
81
|
+
return nil, false
|
|
82
|
+
}
|
|
83
|
+
return append([]byte{0x01}, h...), true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func mac(key, binding, nonce []byte) []byte {
|
|
87
|
+
m := hmac.New(sha256.New, key)
|
|
88
|
+
m.Write(binding)
|
|
89
|
+
m.Write(nonce)
|
|
90
|
+
return m.Sum(nil)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Issue returns a fresh token for binding.
|
|
94
|
+
func (c *CSRF) Issue(binding []byte) (string, error) {
|
|
95
|
+
nonce := make([]byte, nonceBytes)
|
|
96
|
+
if _, err := rand.Read(nonce); err != nil {
|
|
97
|
+
return "", err
|
|
98
|
+
}
|
|
99
|
+
return b64(nonce) + "." + b64(mac(c.keys[0], binding, nonce)), nil
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Verify reports whether token was issued by one of the keys for binding.
|
|
103
|
+
func (c *CSRF) Verify(token string, binding []byte) bool {
|
|
104
|
+
n, s, found := strings.Cut(token, ".")
|
|
105
|
+
if !found {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
nonce, err1 := base64.RawURLEncoding.DecodeString(n)
|
|
109
|
+
sig, err2 := base64.RawURLEncoding.DecodeString(s)
|
|
110
|
+
if err1 != nil || err2 != nil || len(nonce) != nonceBytes {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
ok := false
|
|
114
|
+
for _, k := range c.keys { // no early exit: time does not tell which key matched
|
|
115
|
+
if hmac.Equal(sig, mac(k, binding, nonce)) {
|
|
116
|
+
ok = true
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return ok
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
|
123
|
+
|
|
124
|
+
// safeMethod is the set that is never CSRF-checked (RFC 9110 §9.2.1).
|
|
125
|
+
func safeMethod(m string) bool {
|
|
126
|
+
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ActionFor names the audit action of the write r attempts, or "" when it is
|
|
130
|
+
// unknown (unrouted path). A rejected attempt is audited only with an action.
|
|
131
|
+
type ActionFor func(r *http.Request) string
|
|
132
|
+
|
|
133
|
+
// Middleware enforces the CSRF check on every non-safe method. It runs after
|
|
134
|
+
// the session middleware (so 401 wins) and before routing (so 403 beats 404),
|
|
135
|
+
// reads only the header and the cookie and never the body. Accepted only if
|
|
136
|
+
// cookie and header are both present and equal, the signature verifies, and
|
|
137
|
+
// the binding matches the request's session (or anonymous without one).
|
|
138
|
+
//
|
|
139
|
+
// fail is the central error adapter; it audits the denial when the *web.Error
|
|
140
|
+
// carries an AuditReason and an action.
|
|
141
|
+
func (c *CSRF) Middleware(fail func(http.ResponseWriter, *http.Request, error), action ActionFor) func(http.Handler) http.Handler {
|
|
142
|
+
return func(next http.Handler) http.Handler {
|
|
143
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
144
|
+
if safeMethod(r.Method) {
|
|
145
|
+
next.ServeHTTP(w, r)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if reason := c.check(r); reason != "" {
|
|
149
|
+
e := web.NewError(web.CodeCSRFInvalid, "CSRF token missing or invalid.")
|
|
150
|
+
if action != nil {
|
|
151
|
+
if a := action(r); a != "" {
|
|
152
|
+
e.AuditAction, e.AuditReason = a, reason
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
fail(w, r, e)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
next.ServeHTTP(w, r)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// check returns "" when r passes, else the audit reason (never sent to the client).
|
|
164
|
+
func (c *CSRF) check(r *http.Request) string {
|
|
165
|
+
hdr := r.Header.Get(CSRFHeader)
|
|
166
|
+
ck, err := r.Cookie(CSRFCookie)
|
|
167
|
+
switch {
|
|
168
|
+
case hdr == "" || err != nil || ck.Value == "":
|
|
169
|
+
return "csrf_missing"
|
|
170
|
+
case subtle.ConstantTimeCompare([]byte(hdr), []byte(ck.Value)) != 1:
|
|
171
|
+
return "csrf_mismatch"
|
|
172
|
+
}
|
|
173
|
+
b, ok := bindingFor(r)
|
|
174
|
+
if !ok || !c.Verify(hdr, b) {
|
|
175
|
+
return "csrf_invalid_binding"
|
|
176
|
+
}
|
|
177
|
+
return ""
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Handler serves GET /auth/csrf: a token for the caller's current binding in
|
|
181
|
+
// the body and in the cookie. It is public; with a session the token is bound
|
|
182
|
+
// to it. Login and logout change the binding, so the client refetches then.
|
|
183
|
+
type Handler struct {
|
|
184
|
+
CSRF *CSRF
|
|
185
|
+
Log *slog.Logger
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// GetCsrfToken serves GET /auth/csrf.
|
|
189
|
+
func (h Handler) GetCsrfToken(w http.ResponseWriter, r *http.Request) {
|
|
190
|
+
b, ok := bindingFor(r)
|
|
191
|
+
if !ok {
|
|
192
|
+
h.fail(w, r, errors.New("session without bindable ID"))
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
tok, err := h.CSRF.Issue(b)
|
|
196
|
+
if err != nil {
|
|
197
|
+
h.fail(w, r, err)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
http.SetCookie(w, &http.Cookie{
|
|
201
|
+
Name: CSRFCookie, Value: tok, Path: "/",
|
|
202
|
+
Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
203
|
+
})
|
|
204
|
+
w.Header().Set("Cache-Control", "no-store")
|
|
205
|
+
web.WriteJSON(w, http.StatusOK, api.CsrfTokenResponse{Token: tok})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func (h Handler) fail(w http.ResponseWriter, r *http.Request, err error) {
|
|
209
|
+
h.Log.ErrorContext(r.Context(), "csrf token issue failed",
|
|
210
|
+
"requestId", web.RequestIDFrom(r.Context()), "error", err)
|
|
211
|
+
web.WriteProblem(w, r, web.NewError(web.CodeInternalError, ""))
|
|
212
|
+
}
|
|
@@ -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,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
|
+
}
|