Skip to content

SessionStrategy

SessionStrategy Browser to Your app: POST /login, password. Your app to Sessions: new session: user 1. Your app to Browser: Set-Cookie: session=<id>. Browser to Your app: GET /me, Cookie: session. Your app to Sessions: load session <id>. Sessions to Your app: user 1. Your app to Browser: 200 {"login": "octocat"}. The browser holds an id; the user stays on the server. Browser Your app Sessions POST /login, password new session: user 1 Set-Cookie: session=<id> GET /me, Cookie: session load session <id> user 1 200 {"login": "octocat"} The browser holds an id; the user stays on the server
What it's for: a browser stays logged in after a login, holding only a random id; who the user is stays on the server.

A web app's login: a password, then a server-side session that remembers who logged in. The browser only holds a random id, in a cookie. The strategy: SessionStrategy.

Run it

From a clone of the repository:

make deps                 # .venv, with every extra
source .venv/bin/activate
uvicorn examples.auth_strategies.session_login:app

Set CACHE_SIGNING_KEY (secrets.token_urlsafe(32)), the same in every process. Without it each start makes up a throwaway one, and the sessions stop working when the app restarts.

Try it

Log in as octocat, whose password is correct horse battery staple. curl keeps the session's cookie in cookies.txt, as a browser would:

$ curl -i -c cookies.txt -X POST http://localhost:8000/login -H "Content-Type: application/json" -d '{"login": "octocat", "password": "correct horse battery staple"}'
HTTP/1.1 200 OK
set-cookie: __Host-session=NZ4ZNmaZUVX7u7ZU4LB4AA4T8QDdE91BoXMzbXLKmJc; Max-Age=1209600; Path=/; HttpOnly; Secure; SameSite=Lax
cache-control: no-store

{"id":"1","login":"octocat","name":"The Octocat"}

$ curl -b cookies.txt http://localhost:8000/me
{"id":"1","login":"octocat","name":"The Octocat"}

Logging out is refused without the app's Origin: CSRFMiddleware can't tell that the request comes from the app's own pages, as browsers say it with their Origin and Sec-Fetch-Site headers.

$ curl -i -b cookies.txt -X POST http://localhost:8000/logout
HTTP/1.1 403 Forbidden

{"detail":"Cross-site request refused"}

$ curl -i -b cookies.txt -X POST http://localhost:8000/logout -H "Origin: http://localhost:8000"
HTTP/1.1 204 No Content
set-cookie: __Host-session=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax
cache-control: no-store

The session is deleted from the backend, so the cookie that cookies.txt still holds is refused:

$ curl -i -b cookies.txt http://localhost:8000/me
HTTP/1.1 401 Unauthorized

{"detail":"Not authenticated"}

What to look at

  • The user id, not the user: the session keeps the app's user id, and load_user loads the user on each request. A session whose user doesn't exist any more fails.
  • A new session at login: sessions.login() gives the session a new id, so one planted in the browser before (session fixation) doesn't get logged in too.
  • The cookie: HttpOnly (scripts in the page can't read it), Secure and SameSite=Lax, with Cache-Control: no-store on the responses that set it.
  • Passwords: kept as scrypt digests, compared in constant time, and unknown logins take as long as wrong passwords. FastAPIRateLimiter limits the tries per address.

The code

examples/auth_strategies/session_login.py
"""A web app's login: a password, then a server-side session that remembers who logged in.

    uvicorn examples.auth_strategies.session_login:app

`sessions.login()` keeps the user's id in a session stored in the cache's backend, under a new
id (so that one planted in the browser before doesn't get logged in too); the browser only
holds that id, in a cookie. `SessionStrategy` loads the user from it on each request, and
`sessions.logout()` deletes the session from the backend.

Log in as octocat, with the password "correct horse battery staple". Set CACHE_SIGNING_KEY
(secrets.token_urlsafe(32)), the same in every process, in the environment or a .env file.
"""

import hashlib
import hmac
import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated

import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request, status
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

from fastapi_alcazaba import FastAPIAuth, FastAPICache
from fastapi_alcazaba.auth import SessionStrategy
from fastapi_alcazaba.backends.inmemory import InMemoryBackend
from fastapi_alcazaba.rate_limit import FastAPIRateLimiter
from fastapi_alcazaba.security import CSRFMiddleware, ServerSessionMiddleware

DEMO_PASSWORD = "correct horse battery staple"  # noqa: S105  # octocat's, to try the example


def throwaway_secret() -> SecretStr:
    """A random secret, for trying the example: it changes at every start and differs between
    processes."""
    return SecretStr(secrets.token_urlsafe(32))


class Settings(BaseSettings):
    """The configuration, from environment variables or a .env file."""

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

    cache_signing_key: SecretStr = Field(default_factory=throwaway_secret)


settings = Settings()


class User(BaseModel):
    id: str
    login: str
    name: str | None = None


def password_digest(password: str, salt: bytes) -> bytes:
    """scrypt: slow on purpose, so that a stolen database gives the passwords away slowly."""
    return hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)


# What unknown logins are checked against, so that they take as long as wrong passwords.
UNKNOWN_SALT = secrets.token_bytes(16)
UNKNOWN_DIGEST = password_digest(secrets.token_urlsafe(), UNKNOWN_SALT)


class Database:
    """Stands in for the database: one user, octocat, with the demo password."""

    def __init__(self) -> None:
        self.users = {"1": User(id="1", login="octocat", name="The Octocat")}
        salt = secrets.token_bytes(16)
        self.passwords = {"octocat": ("1", salt, password_digest(DEMO_PASSWORD, salt))}

    def check_password(self, login: str, password: str) -> User | None:
        user_id, salt, digest = self.passwords.get(login, ("", UNKNOWN_SALT, UNKNOWN_DIGEST))
        if hmac.compare_digest(password_digest(password, salt), digest) and user_id:
            return self.users[user_id]
        return None


db = Database()


def load_user(user_id: str) -> User | None:
    return db.users.get(user_id)


auth = FastAPIAuth()
sessions = auth.use(SessionStrategy(load_user))
CurrentUser = Annotated[User, Depends(auth.authenticate("session"))]
# Five logins a minute per address: guessing passwords soon gets 429s.
login_attempts = FastAPIRateLimiter(times=5, minutes=1)


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
    # Redis or Valkey in production, so that every process shares the sessions.
    FastAPICache.init(InMemoryBackend(), prefix="web", signing_key=settings.cache_signing_key)
    yield


app = FastAPI(lifespan=lifespan)
app.add_middleware(ServerSessionMiddleware)
app.add_middleware(CSRFMiddleware)  # session cookies: refuse what other sites make browsers send


class Login(BaseModel):
    login: str = Field(max_length=100)
    password: str = Field(max_length=200)


@app.post("/login", dependencies=[Depends(login_attempts)])
async def log_in(request: Request, form: Login) -> User:
    """Start a session for the user of that login and password."""
    user = db.check_password(form.login, form.password)
    if user is None:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong login or password")
    sessions.login(request, user.id)  # a new session id: no session fixation
    return user


@app.get("/me")
async def me(user: CurrentUser) -> User:
    return user


@app.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def log_out(request: Request, user: CurrentUser) -> None:
    del user  # only to refuse requests without a session
    sessions.logout(request)


if __name__ == "__main__":
    uvicorn.run("session_login:app", reload=True)