@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
|
@@ -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
|
+
}
|
|
@@ -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,7 @@ 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
|
|
15
17
|
Account account.Handler
|
|
16
18
|
Deadline deadline.Handler
|
|
17
19
|
}
|
|
@@ -23,3 +25,6 @@ func (a API) GetDeadline(w http.ResponseWriter, r *http.Request) { a.Deadline.Ge
|
|
|
23
25
|
func (a API) Register(w http.ResponseWriter, r *http.Request, _ api.RegisterParams) {
|
|
24
26
|
a.Account.Register(w, r)
|
|
25
27
|
}
|
|
28
|
+
|
|
29
|
+
// GetCsrfToken serves GET /auth/csrf.
|
|
30
|
+
func (a API) GetCsrfToken(w http.ResponseWriter, r *http.Request) { a.Auth.GetCsrfToken(w, r) }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import "net/http"
|
|
4
|
+
|
|
5
|
+
// PublicRoutes is the explicit list of operations that need no session
|
|
6
|
+
// (Backend.md §3). It must equal the operations marked `security: []` in
|
|
7
|
+
// api/openapi.yaml; a test locks that. B1-10 adds POST /auth/login to both. Paths include the /api/v1 prefix.
|
|
8
|
+
var PublicRoutes = []struct{ Method, Path string }{
|
|
9
|
+
{http.MethodGet, "/api/v1/auth/csrf"},
|
|
10
|
+
{http.MethodPost, "/api/v1/auth/register"},
|
|
11
|
+
{http.MethodPost, "/api/v1/auth/logout"},
|
|
12
|
+
{http.MethodGet, "/api/v1/deadline"},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// IsPublic reports whether r is exactly one of PublicRoutes. A public path with
|
|
16
|
+
// another method is not public: without a session it answers 401, with one 405.
|
|
17
|
+
func IsPublic(r *http.Request) bool {
|
|
18
|
+
for _, p := range PublicRoutes {
|
|
19
|
+
if r.Method == p.Method && r.URL.Path == p.Path {
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// WriteActions maps the writes that exist today to their audit action, so a
|
|
27
|
+
// CSRF denial is recorded (PRD §6.11). Later PRs add their routes here, with
|
|
28
|
+
// the same exact-path rule; a write on an unknown path is not audited.
|
|
29
|
+
var WriteActions = map[string]string{
|
|
30
|
+
http.MethodPost + " /api/v1/auth/register": "AUTH_REGISTER",
|
|
31
|
+
http.MethodPost + " /api/v1/auth/logout": "AUTH_LOGOUT",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ActionFor returns the audit action of r, or "" when unknown.
|
|
35
|
+
func ActionFor(r *http.Request) string { return WriteActions[r.Method+" "+r.URL.Path] }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package server_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"slices"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/getkin/kin-openapi/openapi3"
|
|
11
|
+
|
|
12
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// The public list in code must equal the operations the spec marks
|
|
16
|
+
// `security: []`; otherwise the contract and the middleware disagree.
|
|
17
|
+
func TestPublicRoutesMatchOpenAPI(t *testing.T) {
|
|
18
|
+
doc, err := openapi3.NewLoader().LoadFromFile("../../api/openapi.yaml")
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
var want []string
|
|
23
|
+
for path, item := range doc.Paths.Map() {
|
|
24
|
+
for method, op := range item.Operations() {
|
|
25
|
+
if op.Security != nil && len(*op.Security) == 0 {
|
|
26
|
+
want = append(want, method+" /api/v1"+path)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
var got []string
|
|
31
|
+
for _, p := range server.PublicRoutes {
|
|
32
|
+
got = append(got, p.Method+" "+p.Path)
|
|
33
|
+
}
|
|
34
|
+
slices.Sort(want)
|
|
35
|
+
slices.Sort(got)
|
|
36
|
+
if !slices.Equal(want, got) {
|
|
37
|
+
t.Errorf("PublicRoutes\n got %v\n want %v (spec, security: [])", got, want)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func TestIsPublicNeedsExactMethodAndPath(t *testing.T) {
|
|
42
|
+
tests := []struct {
|
|
43
|
+
method, path string
|
|
44
|
+
want bool
|
|
45
|
+
}{
|
|
46
|
+
{"GET", "/api/v1/auth/csrf", true},
|
|
47
|
+
{"POST", "/api/v1/auth/logout", true},
|
|
48
|
+
{"POST", "/api/v1/auth/csrf", false},
|
|
49
|
+
{"HEAD", "/api/v1/deadline", false},
|
|
50
|
+
{"GET", "/api/v1/auth/csrf/", false},
|
|
51
|
+
{"GET", "/api/v1/auth/me", false},
|
|
52
|
+
{"GET", "/api/v1/auth/../ideas", false},
|
|
53
|
+
{"GET", "/auth/csrf", false},
|
|
54
|
+
}
|
|
55
|
+
for _, tc := range tests {
|
|
56
|
+
req := httptest.NewRequestWithContext(context.Background(), tc.method, "http://x"+tc.path, nil)
|
|
57
|
+
if got := server.IsPublic(req); got != tc.want {
|
|
58
|
+
t.Errorf("%s %s = %v, want %v", tc.method, tc.path, got, tc.want)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestActionForCoversPublicWrites(t *testing.T) {
|
|
64
|
+
for _, p := range server.PublicRoutes {
|
|
65
|
+
if p.Method == http.MethodGet {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
r := httptest.NewRequest(p.Method, p.Path, nil)
|
|
69
|
+
if server.ActionFor(r) == "" {
|
|
70
|
+
t.Errorf("%s %s has no audit action for a CSRF denial", p.Method, p.Path)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -22,8 +22,8 @@ const (
|
|
|
22
22
|
|
|
23
23
|
// NewPublic builds the public router. Module routes are mounted by `mount`
|
|
24
24
|
// (nil in the skeleton) under the chain: request ID → access log → recover →
|
|
25
|
-
// Accept check.
|
|
26
|
-
func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
|
|
25
|
+
// extra (the session middleware, so 401 beats 406) → Accept check.
|
|
26
|
+
func NewPublic(log *slog.Logger, mount func(chi.Router), extra ...func(http.Handler) http.Handler) http.Handler {
|
|
27
27
|
r := chi.NewRouter()
|
|
28
28
|
r.NotFound(web.NotFound)
|
|
29
29
|
r.MethodNotAllowed(web.MethodNotAllowed)
|
|
@@ -32,7 +32,11 @@ func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
|
|
|
32
32
|
}
|
|
33
33
|
// The chain wraps the router instead of using r.Use: chi skips r.Use
|
|
34
34
|
// middleware (also for NotFound) while no route is registered.
|
|
35
|
-
|
|
35
|
+
h := web.AcceptCheck(r)
|
|
36
|
+
for i := len(extra) - 1; i >= 0; i-- {
|
|
37
|
+
h = extra[i](h)
|
|
38
|
+
}
|
|
39
|
+
return web.RequestID(web.AccessLog(log)(web.Recover(log)(h)))
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
// Check reports whether one dependency is usable; a non-nil error makes
|
|
@@ -18,8 +18,16 @@ type Config struct {
|
|
|
18
18
|
LogLevel slog.Level
|
|
19
19
|
ShutdownTimeout time.Duration // must stay below terminationGracePeriodSeconds
|
|
20
20
|
ReadyDrainDelay time.Duration // time readiness stays false before Shutdown starts
|
|
21
|
+
CSRFKey string // HMAC key that signs CSRF tokens (>= 32 bytes)
|
|
22
|
+
CSRFKeyPrevious string // optional: still verifies for 8 h after a rotation
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
// DefaultCSRFKey is the throwaway key of the local profile only; every other
|
|
26
|
+
// profile must set ESG_CSRF_KEY.
|
|
27
|
+
const DefaultCSRFKey = "local-dev-csrf-key-not-a-secret-0000"
|
|
28
|
+
|
|
29
|
+
const minCSRFKeyBytes = 32
|
|
30
|
+
|
|
23
31
|
// DefaultDSN matches docker-compose.yml (throwaway local credentials). The query
|
|
24
32
|
// pins UTC and matched-row counts (Backend.md §2.3).
|
|
25
33
|
const DefaultDSN = "esg:esg@tcp(localhost:3306)/esg?parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27&clientFoundRows=true"
|
|
@@ -33,16 +41,27 @@ func Load(getenv func(string) string) (Config, error) {
|
|
|
33
41
|
return def
|
|
34
42
|
}
|
|
35
43
|
c := Config{
|
|
36
|
-
Profile:
|
|
37
|
-
PublicAddr:
|
|
38
|
-
ManagementAddr:
|
|
39
|
-
DBDSN:
|
|
44
|
+
Profile: get("ESG_PROFILE", "local"),
|
|
45
|
+
PublicAddr: get("ESG_PUBLIC_ADDR", ":8080"),
|
|
46
|
+
ManagementAddr: get("ESG_MANAGEMENT_ADDR", ":8081"),
|
|
47
|
+
DBDSN: get("ESG_DB_DSN", DefaultDSN),
|
|
48
|
+
CSRFKey: getenv("ESG_CSRF_KEY"),
|
|
49
|
+
CSRFKeyPrevious: getenv("ESG_CSRF_KEY_PREVIOUS"),
|
|
40
50
|
}
|
|
41
51
|
switch c.Profile {
|
|
42
52
|
case "local", "k3s", "dev":
|
|
43
53
|
default:
|
|
44
54
|
return Config{}, fmt.Errorf("ESG_PROFILE %q: want local, k3s or dev", c.Profile)
|
|
45
55
|
}
|
|
56
|
+
if c.CSRFKey == "" && c.Profile == "local" {
|
|
57
|
+
c.CSRFKey = DefaultCSRFKey
|
|
58
|
+
}
|
|
59
|
+
if len(c.CSRFKey) < minCSRFKeyBytes {
|
|
60
|
+
return Config{}, fmt.Errorf("ESG_CSRF_KEY: need at least %d bytes", minCSRFKeyBytes)
|
|
61
|
+
}
|
|
62
|
+
if c.CSRFKeyPrevious != "" && len(c.CSRFKeyPrevious) < minCSRFKeyBytes {
|
|
63
|
+
return Config{}, fmt.Errorf("ESG_CSRF_KEY_PREVIOUS: need at least %d bytes", minCSRFKeyBytes)
|
|
64
|
+
}
|
|
46
65
|
if err := c.LogLevel.UnmarshalText([]byte(strings.ToUpper(get("ESG_LOG_LEVEL", "info")))); err != nil {
|
|
47
66
|
return Config{}, fmt.Errorf("ESG_LOG_LEVEL: %w", err)
|
|
48
67
|
}
|
|
@@ -8,6 +8,8 @@ import (
|
|
|
8
8
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/config"
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
const key = "0123456789abcdef0123456789abcdef"
|
|
12
|
+
|
|
11
13
|
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
|
12
14
|
|
|
13
15
|
func TestDefaults(t *testing.T) {
|
|
@@ -16,14 +18,14 @@ func TestDefaults(t *testing.T) {
|
|
|
16
18
|
t.Fatal(err)
|
|
17
19
|
}
|
|
18
20
|
if c.Profile != "local" || c.PublicAddr != ":8080" || c.ManagementAddr != ":8081" ||
|
|
19
|
-
c.LogLevel != slog.LevelInfo || c.ShutdownTimeout != 20*time.Second {
|
|
21
|
+
c.LogLevel != slog.LevelInfo || c.ShutdownTimeout != 20*time.Second || c.CSRFKey != config.DefaultCSRFKey {
|
|
20
22
|
t.Fatalf("%+v", c)
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
func TestOverrides(t *testing.T) {
|
|
25
27
|
c, err := config.Load(env(map[string]string{
|
|
26
|
-
"ESG_PROFILE": "dev", "ESG_LOG_LEVEL": "debug", "ESG_SHUTDOWN_TIMEOUT": "5s", "ESG_READY_DRAIN_DELAY": "1s",
|
|
28
|
+
"ESG_PROFILE": "dev", "ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": key + "2", "ESG_LOG_LEVEL": "debug", "ESG_SHUTDOWN_TIMEOUT": "5s", "ESG_READY_DRAIN_DELAY": "1s",
|
|
27
29
|
}))
|
|
28
30
|
if err != nil || c.Profile != "dev" || c.LogLevel != slog.LevelDebug || c.ReadyDrainDelay != time.Second {
|
|
29
31
|
t.Fatalf("%+v %v", c, err)
|
|
@@ -32,12 +34,16 @@ func TestOverrides(t *testing.T) {
|
|
|
32
34
|
|
|
33
35
|
func TestInvalid(t *testing.T) {
|
|
34
36
|
for name, m := range map[string]map[string]string{
|
|
35
|
-
"profile":
|
|
36
|
-
"log level":
|
|
37
|
-
"timeout":
|
|
38
|
-
"zero timeout":
|
|
39
|
-
"drain":
|
|
40
|
-
"
|
|
37
|
+
"profile": {"ESG_PROFILE": "prod"},
|
|
38
|
+
"log level": {"ESG_LOG_LEVEL": "loud"},
|
|
39
|
+
"timeout": {"ESG_SHUTDOWN_TIMEOUT": "soon"},
|
|
40
|
+
"zero timeout": {"ESG_SHUTDOWN_TIMEOUT": "0s"},
|
|
41
|
+
"drain": {"ESG_READY_DRAIN_DELAY": "x"},
|
|
42
|
+
"short csrf key": {"ESG_CSRF_KEY": "short"},
|
|
43
|
+
"short previous key": {"ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": "short"},
|
|
44
|
+
"dev needs csrf key": {"ESG_PROFILE": "dev"},
|
|
45
|
+
"k3s needs csrf key": {"ESG_PROFILE": "k3s"},
|
|
46
|
+
"same address": {"ESG_PUBLIC_ADDR": ":9", "ESG_MANAGEMENT_ADDR": ":9"},
|
|
41
47
|
} {
|
|
42
48
|
if _, err := config.Load(env(m)); err == nil {
|
|
43
49
|
t.Errorf("%s: want error", name)
|
|
@@ -161,3 +161,16 @@ describe('accessibility', () => {
|
|
|
161
161
|
expect((await axe(container)).violations).toEqual([]);
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
describe('global 401 (Frontend.md §5.2)', () => {
|
|
166
|
+
it('goes to /login?next=<current> with a session-ended notice and forgets the user', async () => {
|
|
167
|
+
const { router } = await open('/ideas?page=2', 'USER');
|
|
168
|
+
await screen.findByRole('heading', { level: 1 });
|
|
169
|
+
server.use(http.get(`${API}/ideas`, () => problem(401, 'UNAUTHENTICATED')));
|
|
170
|
+
const { apiFetch } = await import('@/shared/api');
|
|
171
|
+
await expect(apiFetch('/ideas')).rejects.toMatchObject({ status: 401 });
|
|
172
|
+
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
|
173
|
+
expect(router.state.location.search).toBe(`?next=${encodeURIComponent('/ideas?page=2')}`);
|
|
174
|
+
expect(router.state.location.state).toEqual({ notice: 'sessionEnded' });
|
|
175
|
+
});
|
|
176
|
+
});
|
package/frontend/src/app/App.tsx
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
1
2
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
3
|
import { RouterProvider, createBrowserRouter, type DataRouter } from 'react-router';
|
|
4
|
+
import { setUnauthenticatedHandler } from '@/shared/api';
|
|
3
5
|
import { routes } from './router';
|
|
6
|
+
import { handleSessionEnded } from './session';
|
|
4
7
|
|
|
5
8
|
const queryClient = new QueryClient();
|
|
6
9
|
const browserRouter = createBrowserRouter(routes);
|
|
7
10
|
|
|
8
11
|
export function App({ router = browserRouter, client = queryClient }: { router?: DataRouter; client?: QueryClient }) {
|
|
12
|
+
useEffect(() => setUnauthenticatedHandler(() => handleSessionEnded(client, router)), [client, router]);
|
|
9
13
|
return (
|
|
10
14
|
<QueryClientProvider client={client}>
|
|
11
15
|
<RouterProvider router={router} />
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useQuery } from '@tanstack/react-query';
|
|
2
|
-
import {
|
|
1
|
+
import { useQuery, type QueryClient } from '@tanstack/react-query';
|
|
2
|
+
import type { DataRouter } from 'react-router';
|
|
3
|
+
import { ApiError, apiFetch } from '@/shared/api';
|
|
4
|
+
import { findRoute } from './access';
|
|
3
5
|
import type { Role } from './routes';
|
|
4
6
|
|
|
5
7
|
/** GET /auth/me body (docs/API/response-bodies.md); `email` is null for Admins. */
|
|
@@ -14,10 +16,30 @@ export const meKey = ['me'] as const;
|
|
|
14
16
|
|
|
15
17
|
/** A 401 means visitor → null (Frontend.md §6.3 rule 1). */
|
|
16
18
|
export async function fetchMe(signal?: AbortSignal): Promise<Me | null> {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
try {
|
|
20
|
+
const res = await apiFetch('/auth/me', { signal, allowUnauthenticated: true });
|
|
21
|
+
return (await res.json()) as Me;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
if (e instanceof ApiError && e.status === 401) return null;
|
|
24
|
+
throw e;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Router state a 401 redirect leaves for the login page to show "your session ended" (Frontend.md §5.2). */
|
|
29
|
+
export interface SessionEndedState {
|
|
30
|
+
notice: 'sessionEnded';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Global 401 (Frontend.md §5.2): forget the user and go to `/login?next=<current>`.
|
|
35
|
+
* Already on a public route (login, register) there is nothing to leave, so only `me` is cleared.
|
|
36
|
+
*/
|
|
37
|
+
export function handleSessionEnded(client: QueryClient, router: DataRouter): void {
|
|
38
|
+
client.setQueryData(meKey, null);
|
|
39
|
+
const { pathname, search } = router.state.location;
|
|
40
|
+
if (findRoute(pathname)?.access === 'public') return;
|
|
41
|
+
const state: SessionEndedState = { notice: 'sessionEnded' };
|
|
42
|
+
void router.navigate(`/login?next=${encodeURIComponent(pathname + search)}`, { replace: true, state });
|
|
21
43
|
}
|
|
22
44
|
|
|
23
45
|
export function useMe() {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parseApiError } from './problem';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session-bound CSRF token, kept in memory only (PRD §6.5, Frontend.md §5.2, F13).
|
|
5
|
+
* The token is bound to the current session (anonymous before login), so it is
|
|
6
|
+
* dropped on login, logout and any 401 and fetched again lazily.
|
|
7
|
+
*/
|
|
8
|
+
let token: string | null = null;
|
|
9
|
+
let inflight: Promise<string> | null = null;
|
|
10
|
+
/** Bumped by clearCsrfToken so a fetch started before the clear cannot repopulate the cache. */
|
|
11
|
+
let generation = 0;
|
|
12
|
+
|
|
13
|
+
export const API_BASE = () => `${location.origin}/api/v1`;
|
|
14
|
+
|
|
15
|
+
async function fetchToken(): Promise<string> {
|
|
16
|
+
const res = await fetch(`${API_BASE()}/auth/csrf`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
|
|
17
|
+
if (!res.ok) throw await parseApiError(res);
|
|
18
|
+
const body = (await res.json()) as { token?: unknown };
|
|
19
|
+
if (typeof body.token !== 'string' || body.token === '') throw await parseApiError(new Response(null, { status: 500 }));
|
|
20
|
+
return body.token;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Returns the cached token, fetching it once (shared by concurrent callers) when absent. */
|
|
24
|
+
export function getCsrfToken(): Promise<string> {
|
|
25
|
+
if (token !== null) return Promise.resolve(token);
|
|
26
|
+
if (inflight) return inflight;
|
|
27
|
+
const started = generation;
|
|
28
|
+
const p = fetchToken()
|
|
29
|
+
.then((t) => {
|
|
30
|
+
if (started === generation) token = t;
|
|
31
|
+
return t;
|
|
32
|
+
})
|
|
33
|
+
.finally(() => {
|
|
34
|
+
if (inflight === p) inflight = null;
|
|
35
|
+
});
|
|
36
|
+
inflight = p;
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Drops the token (login, logout, 401). The next write fetches a new one. */
|
|
41
|
+
export function clearCsrfToken(): void {
|
|
42
|
+
generation += 1;
|
|
43
|
+
token = null;
|
|
44
|
+
inflight = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drops the token and fetches a new one (the `CSRF_INVALID` retry). */
|
|
48
|
+
export function refreshCsrfToken(): Promise<string> {
|
|
49
|
+
clearCsrfToken();
|
|
50
|
+
return getCsrfToken();
|
|
51
|
+
}
|