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
This commit is contained in:
Arthur Belleville 2026-05-14 22:40:10 +02:00
parent b5c3fc4d48
commit 8b54ff4bec
No known key found for this signature in database
8 changed files with 99 additions and 32 deletions

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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 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 rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d; want 303 (unauthenticated GET / redirects to /login)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Errorf("Location = %q; want /login", loc)
}
}

View file

@ -57,7 +57,15 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps) http.Handler {
r.Post("/signup", SignupPostHandler(deps))
r.Post("/login", LoginPostHandler(deps))
// 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() }))

View file

@ -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) {
<div class="flex min-h-[60vh] items-start justify-center pt-16">
@ui.Card(nil) {
<div class="w-full max-w-sm px-6 py-8">

View file

@ -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) {
<div class="flex min-h-[60vh] items-start justify-center pt-16">
@ui.Card(nil) {
<div class="w-full max-w-sm px-6 py-8">

View file

@ -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) {
<p class="text-sm text-slate-500 mb-6">Signed in as { user.Email }</p>
<h1 class="text-[28px] font-semibold leading-tight">Xtablo</h1>
<p class="mt-2 text-base text-slate-600">
Go + HTMX foundation. Sign-in and the Tablos workflow ship in later phases.
Go + HTMX foundation. The Tablos workflow ships in later phases.
</p>
<div class="mt-8">
@ui.Card(nil) {

View file

@ -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) {
<!DOCTYPE html>
<html lang="en">
<head>
@ -19,13 +26,24 @@ templ Layout(title string) {
</head>
<body class="min-h-screen bg-white text-slate-900 antialiased">
<header class="bg-slate-50 border-b border-slate-200">
<div class="mx-auto max-w-5xl px-4 sm:px-6 py-4"></div>
<div class="mx-auto max-w-5xl px-4 sm:px-6 py-4 flex items-center justify-between">
<span class="text-sm font-semibold text-slate-800">Xtablo</span>
if user != nil {
<div class="flex items-center gap-3">
<span class="text-sm text-slate-600">{ user.Email }</span>
<form method="POST" action="/logout" class="inline">
<!-- CSRF field added in Plan 07 -->
<button type="submit" class="text-sm text-slate-700 hover:underline">Log out</button>
</form>
</div>
}
</div>
</header>
<main class="mx-auto max-w-5xl px-4 sm:px-6 py-8">
{ children... }
</main>
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
Phase 1 · Walking skeleton
Phase 2 · Authentication
</footer>
<script src="/static/htmx.min.js" defer></script>
</body>