diff --git a/backend/internal/web/handlers.go b/backend/internal/web/handlers.go index 7207023..936f8f1 100644 --- a/backend/internal/web/handlers.go +++ b/backend/internal/web/handlers.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "backend/internal/auth" "backend/templates" ) @@ -35,10 +36,14 @@ func HealthzHandler(pinger Pinger) http.HandlerFunc { } // IndexHandler renders the root page (templates.Index) as text/html. +// The authenticated user is pulled from the request context (set by +// auth.ResolveSession) and passed to the template so the layout header can +// render the logout button and the page can show the user's email. func IndexHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + _, user, _ := auth.Authed(r.Context()) w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.Index().Render(r.Context(), w) + _ = templates.Index(user).Render(r.Context(), w) } } diff --git a/backend/internal/web/handlers_auth.go b/backend/internal/web/handlers_auth.go index 37c2fef..ffd90e8 100644 --- a/backend/internal/web/handlers_auth.go +++ b/backend/internal/web/handlers_auth.go @@ -2,6 +2,7 @@ package web import ( "errors" + "log/slog" "net" "net/http" "net/mail" @@ -275,3 +276,42 @@ func renderLoginError(w http.ResponseWriter, r *http.Request, form templates.Log _ = templates.LoginPage(form, errs).Render(r.Context(), w) } } + +// LogoutHandler handles POST /logout: deletes the session row and clears the +// cookie, then redirects to /login. +// +// Security invariants: +// - Only reachable via the RequireAuth-gated protected group (D-23). +// - Defense-in-depth: if somehow reached unauthenticated, redirects to /login. +// - Store.Delete hard-deletes the session row (D-06, T-2-07). +// - ClearSessionCookie sets Max-Age=-1 to expire the browser cookie (D-06). +// - HTMX requests receive 200 + HX-Redirect; plain requests receive 303 (D-22). +func LogoutHandler(deps AuthDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Defense-in-depth: RequireAuth already gates this route, but guard here + // too so the handler never panics on a nil session. + sess, _, ok := auth.Authed(r.Context()) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Hard-delete the session row from the DB (D-06, T-2-07). + if err := deps.Store.Delete(r.Context(), sess.ID); err != nil { + slog.Default().Error("logout: delete session", "session_id", sess.ID, "err", err) + // Continue and clear the cookie even on delete error — partial + // invalidation is better than leaving the cookie intact. + } + + // Expire the browser cookie (D-06: Max-Age=-1). + auth.ClearSessionCookie(w, deps.Secure) + + // HTMX-aware redirect (D-23). + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/login") + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) + } +} diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go index e234e4a..6f409ad 100644 --- a/backend/internal/web/handlers_test.go +++ b/backend/internal/web/handlers_test.go @@ -60,29 +60,23 @@ func TestHealthz_Down(t *testing.T) { } } -func TestIndex_RendersHxGet(t *testing.T) { +// TestIndex_UnauthRedirects verifies that an unauthenticated GET / now +// redirects to /login (AUTH-05: / is protected behind RequireAuth). +// This replaces the Phase 1 TestIndex_RendersHxGet test which assumed / +// was public. The HTMX demo content is tested by +// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go. +func TestIndex_UnauthRedirects(t *testing.T) { router := NewRouter(stubPinger{}, "./static", AuthDeps{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d; want 200", rec.Code) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d; want 303 (unauthenticated GET / redirects to /login)", rec.Code) } - if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { - t.Errorf("Content-Type = %q; want text/html", ct) - } - body := rec.Body.String() - for _, want := range []string{ - `hx-get="/demo/time"`, - `hx-target="#demo-out"`, - `ui-button-solid-default-md`, - `Fetch server time`, - } { - if !strings.Contains(body, want) { - t.Errorf("body missing %q", want) - } + if loc := rec.Header().Get("Location"); loc != "/login" { + t.Errorf("Location = %q; want /login", loc) } } diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index e44dbc8..cc677cb 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -57,7 +57,15 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps) http.Handler { r.Post("/signup", SignupPostHandler(deps)) r.Post("/login", LoginPostHandler(deps)) - r.Get("/", IndexHandler()) + // Protected routes — require an authenticated session (D-23, AUTH-05). + // RequireAuth checks the context set by ResolveSession above and redirects + // unauthenticated requests to /login (HTMX: HX-Redirect, plain: 303). + r.Group(func(r chi.Router) { + r.Use(auth.RequireAuth) + r.Get("/", IndexHandler()) + r.Post("/logout", LogoutHandler(deps)) + }) + r.Get("/healthz", HealthzHandler(pinger)) r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() })) diff --git a/backend/templates/auth_login.templ b/backend/templates/auth_login.templ index 8cb4935..fb9ad40 100644 --- a/backend/templates/auth_login.templ +++ b/backend/templates/auth_login.templ @@ -6,7 +6,7 @@ import "backend/internal/web/ui" // It delegates the form section to LoginFormFragment so HTMX can swap just the // form on validation errors without re-rendering the surrounding shell. templ LoginPage(form LoginForm, errs LoginErrors) { - @Layout("Sign in") { + @Layout("Sign in", nil) {
Signed in as { user.Email }
- Go + HTMX foundation. Sign-in and the Tablos workflow ship in later phases. + Go + HTMX foundation. The Tablos workflow ships in later phases.