360 lines
13 KiB
Markdown
360 lines
13 KiB
Markdown
# Lesson 8 — Auth Middleware & Route Protection
|
|||
|
|
|
||
|
|
> **New Go concepts in this lesson:** `context.Context` in depth
|
||
|
|
> (`context.WithValue`, `r.WithContext`), private types used purely as
|
||
|
|
> unique context keys, type assertions applied for real. This is the
|
||
|
|
> concept-heaviest lesson in the course — take it slowly, and don't skip
|
||
|
|
> Part A.
|
||
|
|
|
||
|
|
## Why we need this
|
||
|
|
|
||
|
|
Right now, `Me` manually checks the session and returns 401 if there's no
|
||
|
|
user. As we add more protected routes later, copy-pasting that check into
|
||
|
|
every handler is error-prone: forget it once, and you've got an
|
||
|
|
unprotected route. The fix is **middleware that guards routes**, plus
|
||
|
|
using the request's **context** to hand the logged-in user down to
|
||
|
|
whichever handler runs next.
|
||
|
|
|
||
|
|
## Part A — standalone playground
|
||
|
|
|
||
|
|
This lesson is really about one core Go mechanism: `context.Context` as a
|
||
|
|
way to pass request-scoped values through a middleware chain. Let's build
|
||
|
|
it from scratch, no chi, no scs — just `net/http` and `context`.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
mkdir ~/go-playground/context-demo && cd ~/go-playground/context-demo
|
||
|
|
go mod init context-demo
|
||
|
|
```
|
||
|
|
|
||
|
|
**`main.go`**
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"log"
|
||
|
|
"net/http"
|
||
|
|
)
|
||
|
|
|
||
|
|
// 1. A custom type for our context key. Using a plain string like "user"
|
||
|
|
// as a key is risky - other packages might use the same string and
|
||
|
|
// silently collide. A private, unexported type guarantees uniqueness.
|
||
|
|
type contextKey string
|
||
|
|
|
||
|
|
const userContextKey contextKey = "user"
|
||
|
|
|
||
|
|
type User struct {
|
||
|
|
ID int
|
||
|
|
Email string
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Middleware that pretends to authenticate a request (checks a fake
|
||
|
|
// header instead of a real session, just to isolate the context concept).
|
||
|
|
func fakeAuthMiddleware(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
token := r.Header.Get("Authorization")
|
||
|
|
|
||
|
|
if token != "secret-token" {
|
||
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||
|
|
return // IMPORTANT: we do NOT call next.ServeHTTP - chain stops here
|
||
|
|
}
|
||
|
|
|
||
|
|
user := &User{ID: 1, Email: "hamid@example.com"}
|
||
|
|
|
||
|
|
// 3. Store the user in a NEW context, derived from the request's
|
||
|
|
// existing context, then build a NEW request carrying that context.
|
||
|
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||
|
|
r = r.WithContext(ctx)
|
||
|
|
|
||
|
|
// 4. Pass the request onward - now carrying the user.
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func protectedHandler(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// 5. Read the value back out of the context, in the final handler.
|
||
|
|
user, ok := r.Context().Value(userContextKey).(*User)
|
||
|
|
if !ok {
|
||
|
|
// Should never happen if the middleware ran correctly, but
|
||
|
|
// defensive code is cheap insurance.
|
||
|
|
http.Error(w, "no user in context", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprintf(w, "hello, %s (id=%d)\n", user.Email, user.ID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.Handle("/protected", fakeAuthMiddleware(http.HandlerFunc(protectedHandler)))
|
||
|
|
|
||
|
|
log.Println("listening on :4000")
|
||
|
|
log.Fatal(http.ListenAndServe(":4000", mux))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Run it:
|
||
|
|
```bash
|
||
|
|
go run .
|
||
|
|
```
|
||
|
|
|
||
|
|
Test it:
|
||
|
|
```bash
|
||
|
|
curl http://localhost:4000/protected
|
||
|
|
# 401 unauthorized
|
||
|
|
|
||
|
|
curl -H "Authorization: secret-token" http://localhost:4000/protected
|
||
|
|
# hello, hamid@example.com (id=1)
|
||
|
|
```
|
||
|
|
|
||
|
|
Line by line — this is the trickiest idiom in the whole course, worth
|
||
|
|
sitting with:
|
||
|
|
|
||
|
|
- `type contextKey string` then `const userContextKey contextKey = "user"`
|
||
|
|
— why not just use the plain string `"user"` directly? Because
|
||
|
|
`context.WithValue` keys are compared by **both type and value**. If
|
||
|
|
two unrelated packages both used the plain string `"user"` as a key,
|
||
|
|
they'd accidentally read/overwrite each other's data. By defining our
|
||
|
|
own named type `contextKey`, our key `userContextKey` can never collide
|
||
|
|
with a plain `string` key or another package's own custom-typed key —
|
||
|
|
even if the underlying text is identical. This is a well-known,
|
||
|
|
idiomatic Go pattern specifically to avoid that collision class.
|
||
|
|
- `if token != "secret-token" { http.Error(...); return }` — note there's
|
||
|
|
**no call to `next.ServeHTTP`** in this branch. This is the entire
|
||
|
|
mechanism of "blocking" a request in middleware: simply *don't* call
|
||
|
|
the next handler. The chain just stops, and whatever you already wrote
|
||
|
|
to `w` (here, the 401) is the final response.
|
||
|
|
- `context.WithValue(r.Context(), userContextKey, user)` — contexts are
|
||
|
|
**immutable**. You can't add a value to an existing context; `WithValue`
|
||
|
|
returns a **brand-new** context wrapping the old one plus the new
|
||
|
|
key/value pair. The original `r.Context()` is untouched.
|
||
|
|
- `r = r.WithContext(ctx)` — similarly, `*http.Request` is designed so
|
||
|
|
you don't mutate its context in place; `WithContext` returns a **new**
|
||
|
|
`*http.Request` (a shallow copy) with the new context attached.
|
||
|
|
Reassigning `r` to this new value is how we "carry" the updated context
|
||
|
|
forward.
|
||
|
|
- `next.ServeHTTP(w, r)` — passing the **new** `r` (with the user
|
||
|
|
embedded) onward. Anything called after this point — more middleware,
|
||
|
|
or the final handler — can pull the user back out.
|
||
|
|
- `r.Context().Value(userContextKey).(*User)` — `Value` returns `any`
|
||
|
|
(could be anything, or `nil` if the key isn't present), so we need a
|
||
|
|
**type assertion** (`.(*User)`) to convert it back to our concrete
|
||
|
|
type. The two-value form `user, ok := ...` is the safe version: `ok`
|
||
|
|
is `false` if the assertion fails (wrong type, or key missing) instead
|
||
|
|
of panicking. **Always use the two-value form** when the value's
|
||
|
|
presence isn't 100% guaranteed — a single-value assertion panics on
|
||
|
|
failure, crashing your whole request.
|
||
|
|
|
||
|
|
Try removing the `Authorization` header check entirely and calling
|
||
|
|
`protectedHandler` directly, without going through the middleware — you'll
|
||
|
|
see the `ok` false-path trigger, since nothing populated the context.
|
||
|
|
|
||
|
|
## Part B — apply it to the project
|
||
|
|
|
||
|
|
We'll build real middleware that checks the actual session (Lesson 6),
|
||
|
|
loads the actual user from MySQL (Lesson 4), and stores it in context
|
||
|
|
using the exact pattern from Part A.
|
||
|
|
|
||
|
|
**`internal/middleware/require_auth.go`**
|
||
|
|
```go
|
||
|
|
package middleware
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
|
||
|
|
"github.com/alexedwards/scs/v2"
|
||
|
|
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
|
||
|
|
)
|
||
|
|
|
||
|
|
type contextKey string
|
||
|
|
|
||
|
|
const userContextKey contextKey = "current_user"
|
||
|
|
|
||
|
|
// RequireAuth is a middleware FACTORY - same three-layer shape as
|
||
|
|
// RequestLogger from Lesson 2. It takes the dependencies it needs
|
||
|
|
// (sessions, userRepo, logger), and returns the actual chi middleware.
|
||
|
|
func RequireAuth(sessions *scs.SessionManager, userRepo *models.UserRepository, logger *slog.Logger) func(http.Handler) http.Handler {
|
||
|
|
return func(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
userID := sessions.GetInt(r.Context(), session.UserIDKey)
|
||
|
|
if userID == 0 {
|
||
|
|
writeUnauthorized(w)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
user, err := userRepo.FindByID(r.Context(), userID)
|
||
|
|
if err != nil {
|
||
|
|
// Covers both "not found" (e.g. account deleted after
|
||
|
|
// login) and real DB errors - either way, this request
|
||
|
|
// cannot proceed as authenticated.
|
||
|
|
logger.Error("require auth: find user failed", "error", err, "user_id", userID)
|
||
|
|
writeUnauthorized(w)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// CurrentUser is how handlers pull the authenticated user back out.
|
||
|
|
// Handlers never touch userContextKey directly - they just call this.
|
||
|
|
func CurrentUser(r *http.Request) *models.User {
|
||
|
|
user, ok := r.Context().Value(userContextKey).(*models.User)
|
||
|
|
if !ok {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return user
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeUnauthorized(w http.ResponseWriter) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(http.StatusUnauthorized)
|
||
|
|
w.Write([]byte(`{"error":"unauthorized"}`))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
This should now read very familiarly — it's Part A's pattern with the
|
||
|
|
fake pieces swapped for real ones:
|
||
|
|
- `sessions.GetInt(...)` — same check `Me` did manually in Lesson 6.
|
||
|
|
- `userRepo.FindByID(...)` — same repository lookup `Me` did.
|
||
|
|
- `context.WithValue` / `r.WithContext` / `next.ServeHTTP(w, r.WithContext(ctx))`
|
||
|
|
— identical mechanism from Part A.
|
||
|
|
- `CurrentUser(r *http.Request) *models.User` — a small **exported
|
||
|
|
helper function**, not a method, wrapping the type assertion so
|
||
|
|
handlers never need to know about `userContextKey` at all (it's
|
||
|
|
unexported — package-private — precisely so only this file can create
|
||
|
|
or read that specific key). This pairs a private context key with a
|
||
|
|
public accessor function, a common Go idiom.
|
||
|
|
- `writeUnauthorized` — a tiny local helper, written by hand instead of
|
||
|
|
reusing `handlers.writeError`, because `internal/middleware` and
|
||
|
|
`internal/handlers` are separate packages, and `writeError` is
|
||
|
|
unexported in `handlers`. This is an intentional package boundary, not
|
||
|
|
an oversight — if we wanted to share it, we'd need to export it
|
||
|
|
(`WriteError`) from a package both can import.
|
||
|
|
|
||
|
|
**Simplify `Me` in `internal/handlers/auth.go`** now that middleware does
|
||
|
|
the lookup:
|
||
|
|
```go
|
||
|
|
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||
|
|
user := middleware.CurrentUser(r)
|
||
|
|
if user == nil {
|
||
|
|
writeError(w, http.StatusUnauthorized, "not logged in")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
||
|
|
"id": user.ID,
|
||
|
|
"email": user.Email,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
Add the import: `"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"`.
|
||
|
|
`Me` no longer touches `h.sessions` or does a `FindByID` itself at all —
|
||
|
|
the middleware already did that work before `Me` ever runs, and just
|
||
|
|
hands us the result via `CurrentUser(r)`. The `nil` check stays as a
|
||
|
|
defensive safety net (in case someone wires this handler up without the
|
||
|
|
middleware by mistake), but in normal operation it should never trigger.
|
||
|
|
|
||
|
|
**Update `internal/router/router.go`** to apply `RequireAuth` to `/me`,
|
||
|
|
using chi's route grouping:
|
||
|
|
```go
|
||
|
|
package router
|
||
|
|
|
||
|
|
import (
|
||
|
|
"database/sql"
|
||
|
|
"log/slog"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/alexedwards/scs/v2"
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||
|
|
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
|
||
|
|
)
|
||
|
|
|
||
|
|
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
|
||
|
|
r := chi.NewRouter()
|
||
|
|
|
||
|
|
r.Use(chimw.RequestID)
|
||
|
|
r.Use(middleware.RequestLogger(logger))
|
||
|
|
r.Use(chimw.Recoverer)
|
||
|
|
r.Use(chimw.Timeout(60 * time.Second))
|
||
|
|
r.Use(sessions.LoadAndSave)
|
||
|
|
|
||
|
|
r.Get("/health", handlers.Health)
|
||
|
|
|
||
|
|
userRepo := models.NewUserRepository(db)
|
||
|
|
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
|
||
|
|
requireAuth := middleware.RequireAuth(sessions, userRepo, logger)
|
||
|
|
|
||
|
|
r.Post("/register", authHandler.Register)
|
||
|
|
r.Post("/login", authHandler.Login)
|
||
|
|
r.Post("/logout", authHandler.Logout)
|
||
|
|
|
||
|
|
// Group: every route inside here goes through requireAuth first.
|
||
|
|
r.Group(func(r chi.Router) {
|
||
|
|
r.Use(requireAuth)
|
||
|
|
r.Get("/me", authHandler.Me)
|
||
|
|
})
|
||
|
|
|
||
|
|
googleConfig := oauth.NewGoogleConfig(cfg)
|
||
|
|
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
|
||
|
|
|
||
|
|
r.Get("/auth/google/login", googleHandler.Login)
|
||
|
|
r.Get("/auth/google/callback", googleHandler.Callback)
|
||
|
|
|
||
|
|
return r
|
||
|
|
}
|
||
|
|
```
|
||
|
|
- `requireAuth := middleware.RequireAuth(sessions, userRepo, logger)` —
|
||
|
|
calling the middleware factory *once*, producing the actual
|
||
|
|
`func(http.Handler) http.Handler` (same "call it once to get the real
|
||
|
|
middleware" pattern as `RequestLogger(logger)` in Lesson 2).
|
||
|
|
- `r.Group(func(r chi.Router) { ... })` — chi's way of scoping middleware
|
||
|
|
to a *subset* of routes instead of the whole router. Inside the group,
|
||
|
|
`r.Use(requireAuth)` only applies to routes registered *within that same
|
||
|
|
closure* — `/me` is protected, but `/register`/`/login`/`/logout`
|
||
|
|
(registered outside the group) are not. Add future authenticated-only
|
||
|
|
routes inside this same `r.Group(...)` block.
|
||
|
|
|
||
|
|
## Try it
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go run ./cmd/api
|
||
|
|
```
|
||
|
|
```bash
|
||
|
|
curl http://localhost:8080/me
|
||
|
|
# {"error":"unauthorized"}
|
||
|
|
|
||
|
|
curl -c cookies.txt -X POST http://localhost:8080/login \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{"email":"hamid@example.com","password":"secret123"}'
|
||
|
|
|
||
|
|
curl -b cookies.txt http://localhost:8080/me
|
||
|
|
# now works
|
||
|
|
```
|
||
|
|
|
||
|
|
Try logging out and hitting `/me` again — should go back to
|
||
|
|
`unauthorized`, this time via the middleware instead of manual logic
|
||
|
|
inside the handler.
|
||
|
|
|
||
|
|
**A sanity check on your understanding:** if you comment out
|
||
|
|
`r.Use(requireAuth)` inside the `Group`, `/me` will still correctly
|
||
|
|
return `401` (via `Me`'s defensive `nil` check on `CurrentUser(r)`), not a
|
||
|
|
crash — because `middleware.CurrentUser(r)` finds nothing in the context
|
||
|
|
(the middleware never ran to put it there), and `Me`'s check catches
|
||
|
|
that. Try it and read the log line that gets printed.
|
||
|
|
|
||
|
|
Once the protected/unprotected split works, move to Lesson 9 — rate
|
||
|
|
limiting & security hardening.
|