Session 11 ยท ๐Ÿ Capstone

Secure User Management System

Every layer from sessions 01โ€“10 in one running Go server โ€” 13 files, 842 lines, standard library plus four packages.

โœ“
11astructure & routes
โœ“
11bone request, every layer
โœ“
11cwiring & errors
โœ“
11dthe end-to-end run

11a

Thirteen files, nine routes

One package per concern. Each route's guards are visible in one line of routes.go.

usermgmt/
โ”œโ”€ cmd/api/main.go config ยท timeouts
โ””โ”€ internal/
   โ”œโ”€ app/routes.go wiring
   โ”œโ”€ auth/password.go 01
   โ”‚    token.go 02โ€“03
   โ”‚    middleware.go 03
   โ”‚    refresh.go 04
   โ”œโ”€ authz/authz.go 05โ€“06
   โ”œโ”€ httpx/decode.go 08
   โ”‚     security.go 08โ€“09
   โ”‚     errors.go 11
   โ”œโ”€ config/config.go 10
   โ””โ”€ user/user.go model ยท store
          handler.go endpoints
ROUTEGUARDS (outer โ†’ inner)
POST /auth/register5/min
POST /auth/login5/min
POST /auth/refresh30/mincookie
POST /auth/logoutauth
GET /users/meauth
GET /users/{id}authowner|admin
PATCH /users/{id}authowner|admin
GET /admin/usersauthuser:list
PATCH /admin/users/{id}/roleauthuser:set_role
โ†’Around every route: Recover โ†’ SecureHeaders โ†’ CORS.
โ†’The user store is in memory so the capstone runs anywhere; its methods are the seam where Postgres plugs in.

11b

One request, every layer

Seven real requests from the end-to-end run. Each stops at exactly one layer โ€” or reaches the handler.

11c

The wiring and the one place errors are decided

Handlers return errors. httpx.Handle turns safe ones into their status and everything else into a bare 500.

internal/app/routes.go
func Routes(h *user.Handler, origins []string) http.Handler {
    mux := http.NewServeMux()
    H := httpx.Handle
    authn := func(next http.Handler) http.Handler {
        return auth.RequireAuth(h.Tokens, h.Refresh, next)
    }
    owner := func(next http.Handler) http.Handler {
        return authn(authz.OwnerOrAdmin(authz.PathOwner("id"), next))
    }
    admin := func(p authz.Perm, next http.Handler) http.Handler {
        return authn(authz.RequirePerm(p, next))
    }
    // one bucket per route: a shared one locks out normal users
    loginLim := httpx.NewIPLimiter(time.Minute/5, 5).Limit     // 5/min
    signupLim := httpx.NewIPLimiter(time.Minute/5, 5).Limit    // 5/min
    refreshLim := httpx.NewIPLimiter(time.Minute/30, 30).Limit // 30/min

    // public, rate limited
    mux.Handle("POST /auth/register", signupLim(H(h.Register)))
    mux.Handle("POST /auth/login", loginLim(H(h.Login)))
    mux.Handle("POST /auth/refresh", refreshLim(H(h.RefreshToken)))

    // authenticated
    mux.Handle("POST /auth/logout", authn(H(h.Logout)))
    mux.Handle("GET /users/me", authn(H(h.Me)))

    // owner or admin
    mux.Handle("GET /users/{id}", owner(H(h.Get)))
    mux.Handle("PATCH /users/{id}", owner(H(h.Patch)))

    // admin only
    mux.Handle("GET /admin/users", admin(authz.ListUsers, H(h.List)))
    mux.Handle("PATCH /admin/users/{id}/role", admin(authz.SetRole, H(h.SetRole)))

    return httpx.Recover(httpx.SecureHeaders(httpx.CORS(origins, mux)))
}
internal/httpx/errors.go
// Error is an error that is safe to show the client.
type Error struct {
    Status int
    Msg    string
}

// Handle is the one place errors become responses.
func Handle(h HandlerFunc) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        err := h(w, r)
        if err == nil {
            return
        }
        var e *Error
        if !errors.As(err, &e) {
            slog.Error("internal", "path", r.URL.Path, "err", err)
            e = &Error{500, "internal error"}
        }
        JSON(w, e.Status, map[string]string{"error": e.Msg})
    })
}

// Recover turns a panic into a clean 500; the stack goes to the log only.
func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if v := recover(); v != nil {
                slog.Error("panic", "path", r.URL.Path, "value", v,
                    "stack", string(debug.Stack()))
                JSON(w, 500, map[string]string{"error": "internal error"})
            }
        }()
        next.ServeHTTP(w, r)
    })
}
โ†’Safe errors are typed (*httpx.Error): 400, 401, 404, 409, 413, 422, 429. Anything else โ€” a DB error, a bug โ€” becomes internal error.
โ†’The detail goes to the log, keyed by path; the client never sees a hostname, SQL or stack.
โ†’Recover is outermost, so a panic anywhere โ€” even in a middleware โ€” still gets a clean reply.
Found in the first run

The first version used one limiter for register, login and refresh. A normal journey โ€” sign up, log in, refresh โ€” hit 429 on its 6th request. Each route now has its own bucket, and refresh gets 30/min.

11d

The end-to-end run

Real TCP server, real HTTP client, Redis protocol via miniredis. Bodies are cut at 110 characters by the test driver.

go run ./cmd/e2e ยท stdout = what the client saw
โ”€โ”€ 1 ยท register & login
POST /auth/register asha           โ†’ 201 {"id":"2","email":"asha@example.com","name":"Asha","role":"user","created_at":"2026-09-30T18:13:38.087930999Z"โ€ฆ
POST /auth/register ravi           โ†’ 201 {"id":"3","email":"ravi@example.com","name":"Ravi","role":"user","created_at":"2026-09-30T18:13:38.455595843Z"โ€ฆ
POST /auth/register (role:admin)   โ†’ 400 {"error":"bad request"}
POST /auth/register (bad input)    โ†’ 422 {"error":"email: invalid; name: 1-80 chars; password: 8-72 bytes"}
POST /auth/login wrong pw          โ†’ 401 {"error":"invalid email or password"}
POST /auth/login unknown email     โ†’ 401 {"error":"invalid email or password"}
POST /auth/login asha              โ†’ 200 {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsInN1YiI6IjIiLCJleHAiOjE3OTA3OTI5MTksโ€ฆ
โ”€โ”€ 2 ยท authentication
GET /users/me (no token)           โ†’ 401 {"error":"unauthorized"}
GET /users/me asha                 โ†’ 200 {"id":"2","email":"asha@example.com","name":"Asha","role":"user","created_at":"2026-09-30T18:13:38.087930999Z"โ€ฆ
โ”€โ”€ 3 ยท ownership & RBAC
PATCH /users/2 asha (own)          โ†’ 200 {"id":"2","email":"asha@example.com","name":"Asha K","role":"user","created_at":"2026-09-30T18:13:38.087930999โ€ฆ
PATCH /users/3 asha (ravi)         โ†’ 404 {"error":"not found"}
PATCH /users/3 admin               โ†’ 200 {"id":"3","email":"ravi@example.com","name":"Ravi S","role":"user","created_at":"2026-09-30T18:13:38.455595843โ€ฆ
GET /admin/users asha              โ†’ 403 {"error":"forbidden"}
GET /admin/users admin             โ†’ 200 [{"id":"1","email":"admin@example.com","name":"Admin","role":"admin","created_at":"2026-09-30T18:13:37.7206601โ€ฆ
โ”€โ”€ 4 ยท refresh rotation
POST /auth/refresh with R1         โ†’ 200 {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsInN1YiI6IjIiLCJleHAiOjE3OTA3OTI5MTksโ€ฆ
POST /auth/refresh with R1 again   โ†’ 401 {"error":"unauthorized"}
POST /auth/refresh with R2         โ†’ 401 {"error":"unauthorized"}
โ”€โ”€ 5 ยท logout denies the access token
GET /users/me ravi                 โ†’ 200 {"id":"3","email":"ravi@example.com","name":"Ravi S","role":"user","created_at":"2026-09-30T18:13:38.455595843โ€ฆ
POST /auth/logout ravi             โ†’ 204
GET /users/me ravi (same token)    โ†’ 401 {"error":"unauthorized"}
POST /auth/refresh ravi's cookie   โ†’ 401 {"error":"unauthorized"}
โ”€โ”€ 6 ยท hardening
PATCH /users/2 (2 MB body)         โ†’ 413 {"error":"body too large"}
OPTIONS from evil.example          โ†’ 204
  headers: nosniff ยท DENY ยท max-age=63072000; includeSubDomains
POST /auth/login (6th this minute) โ†’ 429 {"error":"too many requests"}
โ”€โ”€ 7 ยท errors never leak internals
GET /debug/db                      โ†’ 500 {"error":"internal error"}
GET /debug/panic                   โ†’ 500 {"error":"internal error"}
stderr = the server log (only you see this)
level=ERROR msg=internal path=/debug/db err="pq: connection refused to 10.0.3.7:5432 (user api_app)"
level=ERROR msg=panic path=/debug/panic value="assignment to entry in nil map" stack="goroutine 100 [running]:\nruntime/debug.Stack()โ€ฆ
./api ยท started without REDIS_ADDR and JWT_SECRET
ERROR refusing to start err="REDIS_ADDR is required\nJWT_SECRET must be โ‰ฅ 32 bytes"
exit=1
โ†’Step 1: "role":"admin" in the register body is rejected (unknown field) โ€” and the handler hard-codes "user" anyway.
โ†’Step 4: replaying R1 revoked the family, so the legitimate R2 died too โ€” reuse detection working.
โ†’Step 5: after logout the same, unexpired access token is refused โ€” the jti denylist.

11e

The brief, feature by feature

Everything the capstone had to include โ€” and the file it lives in.

Registration & login

  • bcrypt cost 12, dummy hash for unknown emails
  • 15-min access JWT with jti
  • 7-day refresh, rotated, reuse-detected
auth/password.go ยท token.go ยท refresh.go

Auth middleware

  • Bearer parse, HS256 pinned
  • denylist check, fails closed
  • claims in context
auth/middleware.go

RBAC & ownership

  • permission map, default deny
  • OwnerOrAdmin โ†’ 404 for others
  • role never taken from input
authz/authz.go ยท user/handler.go

Hardening

  • per-route rate limits
  • CORS allowlist, 5 security headers
  • 1 MB bodies, strict JSON, validation
  • HttpOnly ยท Secure ยท SameSite cookie
  • server timeouts, config refuses bad boot
httpx/security.go ยท decode.go ยท cmd/api

Centralised errors

  • one Handle maps errors to status
  • unknown errors โ†’ bare 500
  • panics recovered, stack to log only
httpx/errors.go
CAPSTONE ยท TOPIC 4

Build it โ€” then make the run above pass

  • Recreate the 13-file layout yourself; write each file from its session.
  • Your acceptance test is the 26-request end-to-end run: every status code must match.
  • Swap the in-memory store for PostgreSQL with the api_app role from session 10.
  • Stretch: move the limiter to Redis and run two instances behind one port.

Recap

The whole track in four lines

1Prove identity: bcrypt, short JWTs, rotated refresh tokens in HttpOnly cookies.
2Decide access: permissions by role, ownership per row, attributes where needed โ€” default deny.
3Distrust input: placeholders, size limits, strict JSON, rate limits, CORS, headers, TLS.
4Leak nothing: secrets from env, json:"-", redacted logs, one error handler, least-privilege roles.