diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..7c90d90 --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,38 @@ +package auth + +import ( + "context" + "net/http" +) + +type contextKey int + +const claimsContextKey contextKey = iota + +// RequireSession returns middleware that rejects a request with 401 unless +// it carries a valid SessionCookieName cookie signed with secret, and +// otherwise makes the session's Claims available via ClaimsFromContext. +func RequireSession(secret []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(SessionCookieName) + if err != nil { + http.Error(w, "not authenticated", http.StatusUnauthorized) + return + } + claims, err := ParseSessionCookie(cookie, secret) + if err != nil { + http.Error(w, "not authenticated", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsContextKey, claims))) + }) + } +} + +// ClaimsFromContext returns the authenticated user's Claims, as populated by +// RequireSession. +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + c, ok := ctx.Value(claimsContextKey).(Claims) + return c, ok +} diff --git a/backend/internal/auth/middleware_test.go b/backend/internal/auth/middleware_test.go new file mode 100644 index 0000000..bac4856 --- /dev/null +++ b/backend/internal/auth/middleware_test.go @@ -0,0 +1,74 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func protectedTestHandler() http.Handler { + return RequireSession([]byte(testSecret))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "no claims in context", http.StatusInternalServerError) + return + } + w.Write([]byte(claims.Name)) + })) +} + +func TestRequireSession_NoCookie(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireSession_ValidCookie(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != "Alice" { + t.Fatalf("body = %q, want Alice", rec.Body.String()) + } +} + +func TestRequireSession_ExpiredCookie(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireSession_TamperedCookie(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + cookie.Value = cookie.Value[:len(cookie.Value)-1] + "x" + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +}