From 8b54ff4beccf8d5ba52732abf0504b1a5ddd22c5 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 22:40:10 +0200 Subject: [PATCH] feat(02-06): implement logout, protect GET /, and update layout with auth state - Add LogoutHandler: deletes session row (D-06), clears cookie, redirects to /login - Protect GET / inside RequireAuth group; remove old top-level registration - Add POST /logout inside same RequireAuth group (D-22: POST-only logout) - Update Layout signature to accept *auth.User; render logout form + email when authed - Update Index template to accept *auth.User and show "Signed in as {email}" - Update SignupPage/LoginPage to pass nil to Layout (auth pages are unauthed) - Update IndexHandler to pull user from auth.Authed(ctx) and pass to template - Update TestIndex_RendersHxGet -> TestIndex_UnauthRedirects (GET / now protected) - AUTH-04 (logout) and AUTH-05 (protected /) are now closed --- backend/internal/web/handlers.go | 7 ++++- backend/internal/web/handlers_auth.go | 40 +++++++++++++++++++++++++++ backend/internal/web/handlers_test.go | 26 +++++++---------- backend/internal/web/router.go | 10 ++++++- backend/templates/auth_login.templ | 2 +- backend/templates/auth_signup.templ | 2 +- backend/templates/index.templ | 20 ++++++++------ backend/templates/layout.templ | 24 ++++++++++++++-- 8 files changed, 99 insertions(+), 32 deletions(-) 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) {
@ui.Card(nil) {
diff --git a/backend/templates/auth_signup.templ b/backend/templates/auth_signup.templ index facc389..b7140f0 100644 --- a/backend/templates/auth_signup.templ +++ b/backend/templates/auth_signup.templ @@ -6,7 +6,7 @@ import "backend/internal/web/ui" // It delegates the form section to SignupFormFragment so HTMX can swap just the // form on validation errors without re-rendering the surrounding shell. templ SignupPage(form SignupForm, errs SignupErrors) { - @Layout("Sign up") { + @Layout("Sign up", nil) {
@ui.Card(nil) {
diff --git a/backend/templates/index.templ b/backend/templates/index.templ index 4e30fab..ba7ab14 100644 --- a/backend/templates/index.templ +++ b/backend/templates/index.templ @@ -1,17 +1,19 @@ package templates -import "backend/internal/web/ui" +import ( + "backend/internal/auth" + "backend/internal/web/ui" +) -// Index renders the Phase 1 root page: page title, H1, muted subtitle, and -// an @ui.Card containing the canonical HTMX demo (per UI-SPEC §Component -// Library Contract / §HTMX Interaction Pattern). The demo CTA is rendered -// via @ui.Button — pages MUST NOT inline raw Tailwind classes for primitives -// that already exist in the ui package. -templ Index() { - @Layout("Xtablo — Foundation") { +// Index renders the root page (protected, requires auth). +// The user parameter is the authenticated user from request context, passed +// through to Layout so the header can render the logout button and email. +templ Index(user *auth.User) { + @Layout("Xtablo", user) { +

Signed in as { user.Email }

Xtablo

- Go + HTMX foundation. Sign-in and the Tablos workflow ship in later phases. + Go + HTMX foundation. The Tablos workflow ships in later phases.

@ui.Card(nil) { diff --git a/backend/templates/layout.templ b/backend/templates/layout.templ index 952daf1..b4f5c28 100644 --- a/backend/templates/layout.templ +++ b/backend/templates/layout.templ @@ -3,12 +3,19 @@ // generate`; generated files are gitignored. package templates +import "backend/internal/auth" + // Layout is the base HTML shell every page renders inside. The structural // classes, container width (max-w-5xl), horizontal padding, header strip, // footer, and asset references (/static/tailwind.css, /static/htmx.min.js) // are locked by UI-SPEC §Base Layout Contract and CONTEXT D-10 — do NOT // load HTMX from a CDN. -templ Layout(title string) { +// +// user is non-nil when the request context carries an authenticated session. +// When non-nil, the header renders a Log out POST form (D-22). Auth pages +// pass nil since they're gated behind RedirectIfAuthed and never shown to +// authed users. +templ Layout(title string, user *auth.User) { @@ -19,13 +26,24 @@ templ Layout(title string) {
-
+
+ Xtablo + if user != nil { +
+ { user.Email } +
+ + +
+
+ } +
{ children... }
- Phase 1 · Walking skeleton + Phase 2 · Authentication