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:
parent
b5c3fc4d48
commit
8b54ff4bec
8 changed files with 99 additions and 32 deletions
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"backend/internal/auth"
|
||||||
"backend/templates"
|
"backend/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,10 +36,14 @@ func HealthzHandler(pinger Pinger) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// IndexHandler renders the root page (templates.Index) as text/html.
|
// 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 {
|
func IndexHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, user, _ := auth.Authed(r.Context())
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = templates.Index().Render(r.Context(), w)
|
_ = templates.Index(user).Render(r.Context(), w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/mail"
|
"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)
|
_ = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{})
|
router := NewRouter(stubPinger{}, "./static", AuthDeps{})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("status = %d; want 200", rec.Code)
|
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") {
|
if loc := rec.Header().Get("Location"); loc != "/login" {
|
||||||
t.Errorf("Content-Type = %q; want text/html", ct)
|
t.Errorf("Location = %q; want /login", loc)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,15 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps) http.Handler {
|
||||||
r.Post("/signup", SignupPostHandler(deps))
|
r.Post("/signup", SignupPostHandler(deps))
|
||||||
r.Post("/login", LoginPostHandler(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("/healthz", HealthzHandler(pinger))
|
||||||
r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() }))
|
r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() }))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import "backend/internal/web/ui"
|
||||||
// It delegates the form section to LoginFormFragment so HTMX can swap just the
|
// It delegates the form section to LoginFormFragment so HTMX can swap just the
|
||||||
// form on validation errors without re-rendering the surrounding shell.
|
// form on validation errors without re-rendering the surrounding shell.
|
||||||
templ LoginPage(form LoginForm, errs LoginErrors) {
|
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">
|
<div class="flex min-h-[60vh] items-start justify-center pt-16">
|
||||||
@ui.Card(nil) {
|
@ui.Card(nil) {
|
||||||
<div class="w-full max-w-sm px-6 py-8">
|
<div class="w-full max-w-sm px-6 py-8">
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import "backend/internal/web/ui"
|
||||||
// It delegates the form section to SignupFormFragment so HTMX can swap just the
|
// It delegates the form section to SignupFormFragment so HTMX can swap just the
|
||||||
// form on validation errors without re-rendering the surrounding shell.
|
// form on validation errors without re-rendering the surrounding shell.
|
||||||
templ SignupPage(form SignupForm, errs SignupErrors) {
|
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">
|
<div class="flex min-h-[60vh] items-start justify-center pt-16">
|
||||||
@ui.Card(nil) {
|
@ui.Card(nil) {
|
||||||
<div class="w-full max-w-sm px-6 py-8">
|
<div class="w-full max-w-sm px-6 py-8">
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
package templates
|
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
|
// Index renders the root page (protected, requires auth).
|
||||||
// an @ui.Card containing the canonical HTMX demo (per UI-SPEC §Component
|
// The user parameter is the authenticated user from request context, passed
|
||||||
// Library Contract / §HTMX Interaction Pattern). The demo CTA is rendered
|
// through to Layout so the header can render the logout button and email.
|
||||||
// via @ui.Button — pages MUST NOT inline raw Tailwind classes for primitives
|
templ Index(user *auth.User) {
|
||||||
// that already exist in the ui package.
|
@Layout("Xtablo", user) {
|
||||||
templ Index() {
|
<p class="text-sm text-slate-500 mb-6">Signed in as { user.Email }</p>
|
||||||
@Layout("Xtablo — Foundation") {
|
|
||||||
<h1 class="text-[28px] font-semibold leading-tight">Xtablo</h1>
|
<h1 class="text-[28px] font-semibold leading-tight">Xtablo</h1>
|
||||||
<p class="mt-2 text-base text-slate-600">
|
<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>
|
</p>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
@ui.Card(nil) {
|
@ui.Card(nil) {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,19 @@
|
||||||
// generate`; generated files are gitignored.
|
// generate`; generated files are gitignored.
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
|
import "backend/internal/auth"
|
||||||
|
|
||||||
// Layout is the base HTML shell every page renders inside. The structural
|
// Layout is the base HTML shell every page renders inside. The structural
|
||||||
// classes, container width (max-w-5xl), horizontal padding, header strip,
|
// classes, container width (max-w-5xl), horizontal padding, header strip,
|
||||||
// footer, and asset references (/static/tailwind.css, /static/htmx.min.js)
|
// 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
|
// are locked by UI-SPEC §Base Layout Contract and CONTEXT D-10 — do NOT
|
||||||
// load HTMX from a CDN.
|
// 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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -19,13 +26,24 @@ templ Layout(title string) {
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-white text-slate-900 antialiased">
|
<body class="min-h-screen bg-white text-slate-900 antialiased">
|
||||||
<header class="bg-slate-50 border-b border-slate-200">
|
<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>
|
</header>
|
||||||
<main class="mx-auto max-w-5xl px-4 sm:px-6 py-8">
|
<main class="mx-auto max-w-5xl px-4 sm:px-6 py-8">
|
||||||
{ children... }
|
{ children... }
|
||||||
</main>
|
</main>
|
||||||
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
|
<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>
|
</footer>
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue