first commit
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
# Lesson 9 — Rate Limiting & Security Hardening
|
||||
|
||||
> **New Go concepts in this lesson:** almost none new at the language
|
||||
> level — this lesson is mostly about correctly configuring existing
|
||||
> tools (`httprate`, `cors`, cookie flags) rather than new syntax. A good
|
||||
> lesson to consolidate everything from Go Basics so far.
|
||||
|
||||
Four separate concerns, each small on its own: **rate limiting** (stop
|
||||
abuse/brute-force), **secure cookie flags** (protect the session cookie
|
||||
itself), **CORS** (control which websites can call your API from a
|
||||
browser), and a basic **CSRF** mitigation for our cookie-based sessions.
|
||||
|
||||
## Part A — standalone playgrounds
|
||||
|
||||
### 1. Rate limiting with `httprate`
|
||||
|
||||
```bash
|
||||
mkdir ~/go-playground/security-demo && cd ~/go-playground/security-demo
|
||||
go mod init security-demo
|
||||
go get github.com/go-chi/httprate@latest
|
||||
go get github.com/go-chi/chi/v5@latest
|
||||
```
|
||||
|
||||
**`main.go`**
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
)
|
||||
|
||||
func main() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// 1. Limit EVERY client to 5 requests per 10 seconds, keyed by IP.
|
||||
r.Use(httprate.LimitByIP(5, 10*time.Second))
|
||||
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "pong")
|
||||
})
|
||||
|
||||
log.Println("listening on :4000")
|
||||
log.Fatal(http.ListenAndServe(":4000", r))
|
||||
}
|
||||
```
|
||||
|
||||
Run it and hammer it:
|
||||
```bash
|
||||
go run .
|
||||
|
||||
for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4000/ping; done
|
||||
```
|
||||
You should see `200` five times, then `429` (Too Many Requests) for the
|
||||
rest, until 10 seconds pass.
|
||||
|
||||
- `httprate.LimitByIP(5, 10*time.Second)` — a ready-made middleware (same
|
||||
`func(http.Handler) http.Handler` shape you already know) tracking
|
||||
request counts **per client IP**, in a sliding window. Exceeding the
|
||||
limit auto-responds with `429 Too Many Requests` — you don't write that
|
||||
logic yourself.
|
||||
- Why keyed by IP: without a key, one abusive client could exhaust the
|
||||
"budget" for every other user too. `LimitByIP` isolates each caller's
|
||||
own quota. (Other keying strategies exist too — `LimitByRealIP`, or
|
||||
custom keys like "by user ID" once authenticated.) This matters most on
|
||||
`/login` and `/register` — without it, someone could script thousands
|
||||
of password guesses per second against `/login`.
|
||||
|
||||
### 2. Cookie security flags
|
||||
|
||||
No need to run this one — just understand each flag, since we set these
|
||||
on `scs`'s cookie config (already partly done in Lesson 6), not by hand:
|
||||
|
||||
```go
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: "abc123",
|
||||
Path: "/",
|
||||
HttpOnly: true, // JS cannot read this cookie
|
||||
Secure: true, // browser only sends it over HTTPS
|
||||
SameSite: http.SameSiteLaxMode, // restricts cross-site sending
|
||||
})
|
||||
```
|
||||
- `HttpOnly: true` — blocks `document.cookie` access from JavaScript.
|
||||
Defeats a whole class of XSS attacks that try to steal the session
|
||||
cookie via injected script.
|
||||
- `Secure: true` — the browser will refuse to send this cookie over plain
|
||||
HTTP, only HTTPS. **Important gotcha**: if you set this while
|
||||
developing locally over `http://localhost`, the cookie won't be sent at
|
||||
all — you'll be confused why sessions "don't work." We'll make this
|
||||
environment-dependent in Part B.
|
||||
- `SameSite: http.SameSiteLaxMode` — controls whether the cookie is sent
|
||||
on cross-site requests. `Lax` (a good default) sends the cookie on
|
||||
top-level navigations (clicking a link to your site) but not on
|
||||
cross-site `POST`s triggered by another page (like a malicious
|
||||
`<form>` auto-submitting to your `/logout`) — this is your main defense
|
||||
against CSRF for cookie-based auth. `Strict` is even tighter but can
|
||||
break legitimate cross-site navigation flows (like our own OAuth
|
||||
callback from Google!). `None` disables the protection entirely and
|
||||
requires `Secure: true`.
|
||||
|
||||
### 3. CORS
|
||||
|
||||
CORS only matters for requests made **from browser JavaScript running on
|
||||
a different origin** than your API (e.g., a React app on
|
||||
`http://localhost:3000` calling your API on `http://localhost:8080`). It
|
||||
does **not** protect your API from curl, mobile apps, or server-to-server
|
||||
calls — CORS is a browser-enforced rule, not a server-side security
|
||||
boundary. It controls *which websites* a browser will let call your API
|
||||
with the user's cookies/credentials attached.
|
||||
|
||||
```bash
|
||||
go get github.com/go-chi/cors@latest
|
||||
```
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:3000"}, // your frontend's origin
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
AllowCredentials: true, // required for cookies to be sent cross-origin
|
||||
}))
|
||||
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("pong"))
|
||||
})
|
||||
|
||||
http.ListenAndServe(":4000", r)
|
||||
}
|
||||
```
|
||||
- `AllowedOrigins` — an explicit allowlist. **Never** use `"*"` (wildcard)
|
||||
together with `AllowCredentials: true` — browsers actually forbid that
|
||||
combination outright, and even without credentials it's a bad default
|
||||
for anything handling auth.
|
||||
- `AllowCredentials: true` — without this, the browser won't include
|
||||
cookies on cross-origin requests to your API at all, so session-based
|
||||
auth from a separate frontend wouldn't work.
|
||||
|
||||
## Part B — apply it all to the project
|
||||
|
||||
**Get the dependencies:**
|
||||
```bash
|
||||
go get github.com/go-chi/httprate@latest
|
||||
go get github.com/go-chi/cors@latest
|
||||
```
|
||||
|
||||
**Update `internal/router/router.go`** — apply a general limit to
|
||||
everything, and a stricter one specifically to auth endpoints:
|
||||
```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"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-chi/httprate"
|
||||
|
||||
"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(cors.Handler(cors.Options{
|
||||
AllowedOrigins: cfg.AllowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// A generous global limit - mostly to stop runaway scripts/bots.
|
||||
r.Use(httprate.LimitByIP(100, time.Minute))
|
||||
|
||||
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)
|
||||
|
||||
// A much stricter limit specifically on login/register, since these
|
||||
// are exactly what a credential-stuffing / brute-force script targets.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(httprate.LimitByIP(5, time.Minute))
|
||||
r.Post("/register", authHandler.Register)
|
||||
r.Post("/login", authHandler.Login)
|
||||
})
|
||||
|
||||
r.Post("/logout", authHandler.Logout)
|
||||
|
||||
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
|
||||
}
|
||||
```
|
||||
- Two separate `httprate.LimitByIP` calls at different scopes — the
|
||||
global `100/minute` is a loose safety net for the whole API, while the
|
||||
`r.Group` around `/register` and `/login` layers a *much* tighter
|
||||
`5/minute` on top. Both limits apply simultaneously to requests inside
|
||||
the group (they stack).
|
||||
- `/logout` deliberately sits *outside* that strict group — you don't
|
||||
want to rate-limit a legitimate logged-in user trying to log out.
|
||||
- `cors.Handler(...)` now reads `cfg.AllowedOrigins` instead of a
|
||||
hardcoded value.
|
||||
|
||||
**Extend `internal/config/config.go`** for CORS origins and cookie
|
||||
security:
|
||||
```go
|
||||
import "strings"
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
Env string // "development" or "production"
|
||||
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
|
||||
RedisAddr string
|
||||
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GoogleRedirectURL string
|
||||
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
Env: getEnv("ENV", "development"),
|
||||
|
||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "root"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "devpass"),
|
||||
DBName: getEnv("DB_NAME", "go_simple_api"),
|
||||
|
||||
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
|
||||
|
||||
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||||
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||||
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
|
||||
|
||||
AllowedOrigins: strings.Split(getEnv("ALLOWED_ORIGINS", "http://localhost:3000"), ","),
|
||||
}
|
||||
}
|
||||
```
|
||||
- `Env` — distinguishes development from production, used next for the
|
||||
cookie's `Secure` flag.
|
||||
- `strings.Split(getEnv(...), ",")` — lets you configure multiple allowed
|
||||
origins via one comma-separated env var (see Go Basics Part 3 on
|
||||
slices), e.g. `ALLOWED_ORIGINS=http://localhost:3000,https://myapp.com`.
|
||||
|
||||
**Update `internal/session/session.go`** — make `Secure` environment-aware,
|
||||
fixing the localhost gotcha from Part A:
|
||||
```go
|
||||
package session
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/redisstore"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/gomodule/redigo/redis"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||
)
|
||||
|
||||
func New(cfg config.Config) *scs.SessionManager {
|
||||
pool := &redis.Pool{
|
||||
MaxIdle: 10,
|
||||
Dial: func() (redis.Conn, error) {
|
||||
return redis.Dial("tcp", cfg.RedisAddr)
|
||||
},
|
||||
}
|
||||
|
||||
manager := scs.New()
|
||||
manager.Store = redisstore.New(pool)
|
||||
manager.Lifetime = 24 * time.Hour
|
||||
manager.Cookie.Name = "session_id"
|
||||
manager.Cookie.HttpOnly = true
|
||||
manager.Cookie.SameSite = http.SameSiteLaxMode
|
||||
manager.Cookie.Secure = cfg.Env == "production" // only require HTTPS in prod
|
||||
|
||||
return manager
|
||||
}
|
||||
```
|
||||
`manager.Cookie.Secure = cfg.Env == "production"` — in development
|
||||
(`ENV` unset or `"development"`), the cookie works over plain
|
||||
`http://localhost`. In production, set `ENV=production` and the cookie
|
||||
will refuse to be sent over anything but HTTPS.
|
||||
|
||||
**Update `cmd/api/main.go`** — no change needed; `router.New(logger, db,
|
||||
sessions, cfg)` already passes `cfg`, which now carries `AllowedOrigins`
|
||||
and `Env`.
|
||||
|
||||
**Add to your `.env`:**
|
||||
```
|
||||
ENV=development
|
||||
ALLOWED_ORIGINS=http://localhost:3000
|
||||
```
|
||||
|
||||
## Try it
|
||||
|
||||
```bash
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
**Rate limiting:**
|
||||
```bash
|
||||
for i in $(seq 1 7); do
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"nope@example.com","password":"wrong"}'
|
||||
done
|
||||
```
|
||||
You should see `401` (wrong credentials) for the first 5, then `429`
|
||||
(rate limited) for the rest.
|
||||
|
||||
**CORS:**
|
||||
```bash
|
||||
curl -i -X OPTIONS http://localhost:8080/login \
|
||||
-H "Origin: http://localhost:3000" \
|
||||
-H "Access-Control-Request-Method: POST"
|
||||
```
|
||||
Look for `Access-Control-Allow-Origin: http://localhost:3000` in the
|
||||
response headers.
|
||||
|
||||
A note on what we're *not* doing yet: full CSRF-token-based protection (a
|
||||
token embedded in forms and checked server-side) is a deeper topic on its
|
||||
own, and `SameSite=Lax` already covers the most common cookie-based CSRF
|
||||
vector for a JSON API like this. If you later build a traditional
|
||||
HTML-form frontend served from the same origin, that's when a dedicated
|
||||
CSRF token library becomes worth adding — treat current protections as
|
||||
sufficient for this course's scope.
|
||||
|
||||
Once rate limiting and CORS both check out, move to Lesson 10 — Docker,
|
||||
docker-compose, and the full course wrap-up.
|
||||
Reference in New Issue
Block a user