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
+383
View File
@@ -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.