first commit
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user