first commit

This commit is contained in:
2026-07-16 10:13:46 +03:30
commit 423c528b2f
42 changed files with 7298 additions and 0 deletions
+359
View File
@@ -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.