Files
go-simple-api/lessons/lesson-06-sessions-scs-redis.md
T
2026-07-16 10:13:46 +03:30

472 lines
15 KiB
Markdown

# Lesson 6 — Server-Side Sessions with scs + Redis
> **New Go concepts in this lesson:** working with a connection pool for a
> second kind of backing store (Redis, via redigo), middleware composing
> with something other than chi's own middleware. Nothing brand new at
> the language level here — mostly applying everything from the Go Basics
> lessons to a new library.
## What "server-side session" means, concretely
The browser only ever holds a random, meaningless token in a cookie. All
the actual session **data** (which user is logged in, etc.) lives in
Redis, keyed by that token. This is different from storing data directly
inside a signed/encrypted cookie: server-side sessions can be instantly
revoked (delete the Redis key), don't grow the cookie as you store more
data, and never expose their contents to the browser at all.
## Part A — standalone playground
First, run Redis:
```bash
docker run --name redis-demo -p 6379:6379 -d redis:8
```
```bash
mkdir ~/go-playground/session-demo && cd ~/go-playground/session-demo
go mod init session-demo
go get github.com/alexedwards/scs/v2@latest
go get github.com/alexedwards/scs/redisstore@latest
go get github.com/gomodule/redigo@latest
```
**`main.go`**
```go
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/alexedwards/scs/redisstore"
"github.com/alexedwards/scs/v2"
"github.com/gomodule/redigo/redis"
)
// A package-level session manager - scs is designed to be created once
// and reused everywhere, similar to how we handle *sql.DB.
var sessionManager *scs.SessionManager
func main() {
// 1. Build a Redis connection pool (redigo, not redis/go-redis - this
// is the client library scs's redisstore is built on).
pool := &redis.Pool{
MaxIdle: 10,
Dial: func() (redis.Conn, error) {
return redis.Dial("tcp", "127.0.0.1:6379")
},
}
// 2. Create the session manager and point its Store at Redis.
sessionManager = scs.New()
sessionManager.Store = redisstore.New(pool)
sessionManager.Lifetime = 24 * time.Hour
sessionManager.Cookie.Name = "session_id"
sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
mux := http.NewServeMux()
mux.HandleFunc("/set", setHandler)
mux.HandleFunc("/get", getHandler)
mux.HandleFunc("/clear", clearHandler)
// 3. Wrap the whole mux with LoadAndSave - this is scs's own
// middleware, same shape as chi's: func(http.Handler) http.Handler.
log.Println("listening on :4000")
log.Fatal(http.ListenAndServe(":4000", sessionManager.LoadAndSave(mux)))
}
func setHandler(w http.ResponseWriter, r *http.Request) {
// Put stores a value in the session, keyed by string.
sessionManager.Put(r.Context(), "username", "hamid")
sessionManager.Put(r.Context(), "visits", 1)
fmt.Fprintln(w, "session data set")
}
func getHandler(w http.ResponseWriter, r *http.Request) {
// GetString / GetInt read back typed values. If the key doesn't
// exist, they return the zero value ("" or 0), not an error.
username := sessionManager.GetString(r.Context(), "username")
visits := sessionManager.GetInt(r.Context(), "visits")
// Exists checks presence explicitly, useful to distinguish "never
// set" from "set to zero value".
if !sessionManager.Exists(r.Context(), "username") {
fmt.Fprintln(w, "no session data yet - try /set first")
return
}
fmt.Fprintf(w, "username=%s visits=%d\n", username, visits)
}
func clearHandler(w http.ResponseWriter, r *http.Request) {
// Destroy wipes the session entirely and tells the browser to delete
// the cookie.
if err := sessionManager.Destroy(r.Context()); err != nil {
http.Error(w, "failed to destroy session", http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "session destroyed")
}
```
Run it:
```bash
go run .
```
In another terminal, **use `-c cookies.txt -b cookies.txt`** so curl
remembers the session cookie between requests, just like a browser would:
```bash
curl -c cookies.txt -b cookies.txt http://localhost:4000/set
curl -c cookies.txt -b cookies.txt http://localhost:4000/get
curl -c cookies.txt -b cookies.txt http://localhost:4000/clear
curl -c cookies.txt -b cookies.txt http://localhost:4000/get # back to "no session data yet"
```
While it's running, peek into Redis directly to *see* the session data
living server-side:
```bash
docker exec -it redis-demo redis-cli KEYS '*'
docker exec -it redis-demo redis-cli GET "scs:session:<token-from-above>"
```
Line by line:
- `redis.Pool{...}` — the same "connection pool" concept as `*sql.DB`
from Lesson 3, just for Redis instead of MySQL. `Dial` is a function the
pool calls whenever it needs a fresh connection.
- `scs.New()` — creates a `*scs.SessionManager` with sensible defaults
(in-memory store, no cookie config yet).
- `sessionManager.Store = redisstore.New(pool)` — by default scs stores
sessions **in memory** (lost on restart, useless across multiple server
instances). Setting `.Store` swaps the backend to Redis — same manager,
same API, completely different storage underneath. This is the same
"swap the implementation, keep the interface" idea from Lesson 2's
Text/JSON handler swap.
- `sessionManager.Lifetime = 24 * time.Hour` — how long a session stays
valid since it was created.
- `sessionManager.Cookie.HttpOnly = true` — the browser's JavaScript can't
read this cookie (`document.cookie` won't show it), blocking a large
class of XSS-based session theft.
- `sessionManager.Cookie.SameSite = http.SameSiteLaxMode` — restricts when
the browser sends this cookie on cross-site requests, mitigating CSRF
(more on this in Lesson 9).
- **How it all connects**: `sessionManager.LoadAndSave(mux)` wraps your
entire mux, same middleware pattern from Lesson 2. On every request: it
reads the session cookie, loads that session's data from Redis into the
request's context, lets your handler run (which reads/writes session
data via `sessionManager.Put`/`Get`, using `r.Context()` to know *which*
session it's operating on), then after your handler finishes, saves any
changes back to Redis and writes/refreshes the cookie on the response.
You never touch cookies or Redis directly.
- `sessionManager.Put(r.Context(), "username", "hamid")` — stores a value
under a string key, scoped to the session identified by this request's
cookie.
- `sessionManager.GetString(...)` / `GetInt(...)` — typed getters. There's
also `GetBool`, `GetFloat`, `GetTime`, and a generic `Get` returning
`any` for custom types.
- `sessionManager.Destroy(r.Context())` — deletes the session from Redis
and instructs the browser (via response headers) to expire the cookie.
Try stopping and restarting your Go program (Ctrl+C, `go run .` again)
*without* restarting Redis — set a session, restart the app, `GET` again.
The session survives, because it never lived in your Go process's memory
in the first place.
## Part B — apply it to the project
**Add the dependencies:**
```bash
go get github.com/alexedwards/scs/v2@latest
go get github.com/alexedwards/scs/redisstore@latest
go get github.com/gomodule/redigo@latest
```
**Extend `internal/config/config.go`** with Redis settings:
```go
type Config struct {
Port string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
RedisAddr string
}
func Load() Config {
return Config{
Port: getEnv("PORT", "8080"),
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"),
}
}
```
**`internal/session/session.go`** — builds the shared session manager:
```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
return manager
}
```
Identical to Part A's setup, wrapped in `New(cfg)` so `main.go` builds it
the same way it builds `database.NewMySQL(...)` and `logging.New()`.
**`internal/session/keys.go`** — a central place for session data keys:
```go
package session
const UserIDKey = "user_id"
```
Defining this constant once avoids typos across files that would silently
break authentication (e.g. one file writes `"user_id"`, another reads
`"userId"` — the compiler can't catch that for you if they're raw
strings; a shared constant makes that class of bug impossible).
**Update `internal/handlers/auth.go`** — inject the session manager, and
actually start a session on login. Update the struct and constructor:
```go
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/alexedwards/scs/v2"
"golang.org/x/crypto/bcrypt"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)
type AuthHandler struct {
userRepo *models.UserRepository
sessions *scs.SessionManager
logger *slog.Logger
}
func NewAuthHandler(userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *AuthHandler {
return &AuthHandler{userRepo: userRepo, sessions: sessions, logger: logger}
}
```
(`Register` is unchanged from Lesson 5 — leave it as-is.)
Update `Login` to actually create a session, and add `Logout` + `Me`:
```go
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
user, err := h.userRepo.FindByEmail(r.Context(), req.Email)
if errors.Is(err, models.ErrUserNotFound) {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
if err != nil {
h.logger.Error("find user by email failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
// Prevent session fixation: issue a fresh session token now that the
// user's privilege level is about to change (anonymous -> authenticated).
if err := h.sessions.RenewToken(r.Context()); err != nil {
h.logger.Error("renew token failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
h.sessions.Put(r.Context(), session.UserIDKey, user.ID)
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if err := h.sessions.Destroy(r.Context()); err != nil {
h.logger.Error("destroy session failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
userID := h.sessions.GetInt(r.Context(), session.UserIDKey)
if userID == 0 {
writeError(w, http.StatusUnauthorized, "not logged in")
return
}
user, err := h.userRepo.FindByID(r.Context(), userID)
if errors.Is(err, models.ErrUserNotFound) {
writeError(w, http.StatusUnauthorized, "not logged in")
return
}
if err != nil {
h.logger.Error("find user by id failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
})
}
```
What's new:
- `h.sessions.RenewToken(r.Context())` — generates a brand-new session
token while keeping the session's existing data intact, invalidating
the old token. This is **preventing session fixation**: if an attacker
somehow got a victim to use a *known* session token before login,
renewing it at the moment of authentication makes the pre-login token
useless. Always call this right before a privilege change (login here).
- `h.sessions.Put(r.Context(), session.UserIDKey, user.ID)` — this is the
entire "session" from the server's perspective: we store the user's ID,
not the whole `User` struct. Anything else about the user (email, etc.)
is looked up fresh from the database when needed (as in `Me`) — this
keeps the session small and avoids serving *stale* cached user data.
- `Me` reads back `session.UserIDKey` via `GetInt`, then does a real
`FindByID` lookup. This route is your template for **any** future route
that needs "the current logged-in user" — in Lesson 8 we'll extract the
"check the session, else 401" part into reusable middleware instead of
repeating it in every handler.
**Update `internal/router/router.go`**:
```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/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
)
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager) *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))
// scs's own middleware must wrap every route that touches sessions.
// Simplest for now: wrap the whole router.
r.Use(sessions.LoadAndSave)
r.Get("/health", handlers.Health)
userRepo := models.NewUserRepository(db)
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
r.Post("/logout", authHandler.Logout)
r.Get("/me", authHandler.Me)
return r
}
```
`r.Use(sessions.LoadAndSave)` — exactly like Part A's manual wrapping, but
as chi middleware. `sessions.LoadAndSave` already has the
`func(http.Handler) http.Handler` shape chi's `Use` expects, so it's
passed directly (same as `chimw.Recoverer`).
**Update `cmd/api/main.go`**:
```go
sessions := session.New(cfg)
logger.Info("session manager configured", "redis_addr", cfg.RedisAddr)
r := router.New(logger, db, sessions)
```
(Add `"git.hamidsoltani.com/hamid/go-simple-api/internal/session"` to
imports.)
## Try it
```bash
docker run --name redis-api -p 6379:6379 -d redis:8
go run ./cmd/api
```
```bash
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
curl -b cookies.txt -c cookies.txt -X POST http://localhost:8080/logout
curl -b cookies.txt http://localhost:8080/me # should now be unauthorized
```
Check Redis directly to see your real session sitting there server-side:
```bash
docker exec -it redis-api redis-cli KEYS '*'
```
Once `/me` correctly returns your user after login and fails after
logout, move to Lesson 7 — Google OAuth login.