472 lines
15 KiB
Markdown
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.
|