Register, end to end
Now register, end to end. Six files as tabs, in dependency order: auth.templ → util.go → handlers.go → auth.go → router.go → main.go (the form, a helper, the struct that gains the session manager, the handler, the routes, the wiring). Three are modifications; the diffs carry lessons 1–3 forward.
1. internal/web/auth.templ (new) — the Register form. Plain HTML form fields with minlength/maxlength matching the 8–64 rule. (No CSRF field yet — lesson 6.)
2. internal/handlers/util.go (new) — one helper, isUniqueViolation, which recognises Postgres error 23505 (unique-constraint) so a duplicate-email race becomes a friendly message instead of a 500.
3. internal/handlers/handlers.go (modify) — the Handlers struct gains a Sessions *session.Manager field, New takes it, and a small serverError helper appears (log the real error, return a generic 500). Home and Health are unchanged.
4. internal/handlers/auth.go (new) — RegisterForm (render the form) and Register, which is the whole flow:
if !validEmail(emailAddr) { fail("Please enter a valid email address."); return }
if password != confirm { fail("Passwords do not match."); return }
hash, err := auth.HashPassword(password) // bcrypt; ErrPasswordLength → friendly message
// duplicate check (nice message) + CreateUser (handles the race via isUniqueViolation)
h.Sessions.Create(r.Context(), w, user.ID) // ← issues the session + sets the cookie
http.Redirect(w, r, "/", http.StatusSeeOther)
The redirect goes to / for now — the dashboard for signed-in users is lesson 5's job.
5. internal/handlers/router.go (modify) — two routes: GET /register (the form) and POST /register (the handler).
6. cmd/signflow/main.go (modify) — construct the manager and pass it in:
sessions := session.NewManager(queries, cfg.IsProd())
h := handlers.New(cfg, queries, sessions, log)
Verify. Regenerate and run:
sqlc generate && templ generate
go run ./cmd/signflow
Open http://localhost:8080/register, create an account (e.g. you@example.com / a password 8–64 chars). You're redirected home and the user count ticks up. Now inspect what happened:
$ psql signflow -c "SELECT email, left(password_hash, 7) AS hash_prefix FROM users;"
email | hash_prefix
------------------+-------------
you@example.com | $2a$12$ ← bcrypt, cost 12 ($2a$12$…)
$ psql signflow -c "SELECT left(token_hash, 12) AS stored, expires_at > now() AS live FROM sessions;"
stored | live
--------------+------
3f9a1c2e... | t ← a SHA-256 HASH, not the cookie's token
And the cookie itself carries its flags:
$ curl -si -c /dev/null -X POST localhost:8080/register \
-d 'email=x@y.co&password=password123&confirm=password123' | grep -i set-cookie
Set-Cookie: signflow_session=Xa9...; Path=/; Expires=...; HttpOnly; SameSite=Lax
HttpOnly and SameSite=Lax are present; Secure is absent because we're on http in dev (it appears in prod). The token in the cookie is not the value stored in the database — the DB has only its SHA-256.
Gotcha (bcrypt's 72-byte cliff). Try registering with a 100-character password: you get "Password must be between 8 and 64 characters." — rejected by our cap, before bcrypt would silently ignore everything past byte 72. Without the cap, two different long passwords sharing their first 72 bytes would be interchangeable at login. The fence matters.