Extensions

Security

Our app can talk to its users now — but before it can have users at all, it needs to know who they are. The Security battery is WebFluid's answer to that: a user store with roles and permissions, argon2 password hashing, CSRF protection, two-factor auth and OAuth logins, all wrapped in ready-made FastAPI dependencies.

Enabling security

The battery keeps its users, roles and tokens in the database, so it builds on the SQLAlchemy extension — both switches have to be on. It also signs its CSRF and one-time tokens with a dedicated secret. In production a missing SECURITY_SECRET is a hard error; in debug mode it falls back to a fixed development secret and warns you about it.

app_configs/app.ini ini
[general]
SECRET_KEY = supersecret

[data]
DATABASE_URI = sqlite:///app.db

[extensions]
EXT_SQLALCHEMY = 1
EXT_SECURITY = 1

[security]
SECURITY_SECRET = another-long-secret
 

Security brings a whole set of db models (users, roles, permissions, plus the 2FA and OAuth identity tables). Just like with Babel, this is the moment to run the migration flow again (wf migrate revision app -a and wf migrate upgrade app) so the tables actually exist.

The services

Everything is reached through the shared registry in webfluid.core.ext. The security instance exposes four services:

  • security.user_service — the current user and every route guard.
  • security.hash_service — argon2 password hashing.
  • security.token_service — CSRF and signed one-time tokens.
  • security.oauth_service — social logins through authlib.

Registering and authenticating users

There is no one-size-fits-all signup endpoint — you own that flow. The battery gives you the pieces: the User model, the hasher and a pair of validators. The validators are built to plug into Pydantic, so the natural home for them is a schema — and that earns our project its fluid/schemas package, sitting right next to the models and services we've already grown:

fluid/schemas/accounts.py python
from pydantic import BaseModel, EmailStr, field_validator

from webfluid.extensions.security.utils import (
    validate_username, validate_password
)


class CreateUser(BaseModel):
    username: str
    email: EmailStr
    password: str

    # validate_username / validate_password raise on bad input, so they
    # drop straight in as field validators (validate_password even
    # attaches an .errors list with the individual reasons).
    _validate_username = field_validator("username")(validate_username)
    _validate_password = field_validator("password")(validate_password)

With the input validated, a small service turns the schema into a stored user, hashing the password on the way in:

fluid/services/accounts.py python
from webfluid.core.ext import db, security
from webfluid.extensions.security.models import User
from sqlalchemy import select

from fluid.schemas.accounts import CreateUser


async def register(data: CreateUser) -> User:
    async with db.async_executor(model=User) as e:
        user = User(
            data.username, data.email,
            security.hash_service.hash(data.password)
        )
        await e.insert(user, flush=True)
        return user


async def authenticate(username: str, password: str) -> User | None:
    async with db.async_executor(model=User) as e:
        result = await e.exec(select(User).where(User.username == username))
        user = result.first()

    if not user or not user.psw_hash:
        return None
    if not security.hash_service.verify(user.psw_hash, password):
        return None
    return user

Logging in and out

The current user is derived from request.session["user_id"], so logging in is simply storing that id and logging out is dropping it. The session cookie is already signed by the framework, so there is nothing else to wire up. These are JSON endpoints, so they join the api_router in fluid/api next to the routes from the Mail chapter:

fluid/api/accounts.py python
from fastapi import Request
from fastapi.exceptions import HTTPException

from fluid.services.accounts import authenticate


async def login(request: Request):
    data = await request.json()
    user = await authenticate(data["username"], data["password"])
    if not user:
        raise HTTPException(status_code=401, detail="INVALID_CREDENTIALS")

    request.session["user_id"] = user.id
    return {"id": user.id, "username": user.username}


async def logout(request: Request):
    request.session.pop("user_id", None)
    return {"status": "ok"}

Knowing the current user

The user service hands you FastAPI dependencies. The plain current_user yields the logged-in User or None; require_user enforces a session (and validates CSRF on unsafe methods) and raises 401 otherwise:

fluid/api/accounts.py python
from webfluid.core.ext import security
from webfluid.extensions.security.models import User


async def me(user: User = security.user_service.require_user):
    return {"id": user.id, "username": user.username}


async def maybe(user: User | None = security.user_service.current_user):
    return {"authenticated": user is not None}

Roles and permissions

Users carry Roles, roles carry Permissions and an is_admin flag. The user service turns all of that into dependencies you drop straight into your routes:

example python
from webfluid.core.ext import security

svc = security.user_service

# Attributes (no call): a fixed guard.
async def dashboard(user = svc.require_admin): ...

# Methods (called): parametrised guards.
async def write_posts(user = svc.require_permissions(["posts:write"])): ...
async def staff_area(user = svc.require_any_role(["editor", "moderator"])): ...

The full set is require_user, require_2fa, require_admin, require_roles / require_any_role and require_permissions / require_any_permission. The _any_ variants pass if the user matches at least one entry; the others require all of them.

 

The guards build on each other: require_2fa implies require_user, and require_admin as well as the role and permission guards all imply require_2fa. So a permission check also enforces authentication and, when the user has a second factor configured, that it has been verified in this session (request.session['2fa_verified']).

CSRF protection

The token service ships a double-submit CSRF guard. require_user already runs it on unsafe methods, but you can also apply it on its own. Hand the browser a token with csrf_response (it sets the cookie and the session token) and echo it back in the X-CSRF-Token header on your next request:

example python
from fastapi import Request

from webfluid.core.ext import security


# GET this once to receive the csrf cookie + session token.
async def csrf(request: Request):
    return security.token_service.csrf_response(request)


# Protect a standalone route (require_user does this for you already).
async def submit(request: Request, _=security.token_service.csrf_protect):
    ...

Two-factor, backup codes and OAuth

The models also cover the harder parts of auth: TOTPSecret for authenticator apps, WebAuthnCredential for passkeys and BackupCode for one-time recovery codes. UserService.has_2fa(user) tells you whether a user has any second factor at all. Social logins run through security.oauth_service, which wraps authlib, is configured with SECURITY_OAUTH_CLIENTS and links each external login to a user through the Identity model.

 

These are building blocks, not a turnkey flow in this alpha. The battery gives you the models, the hasher, the guards and the OAuth plumbing, but the actual enrollment and verification routes are yours to write. If EXT_JWT is enabled, the guards can additionally accept a bearer token through requirement_or_grant / requirement_and_grant for machine-to-machine access.

The config values

  • SECURITY_SECRET — signs CSRF and one-time tokens. Required in production.
  • SECURITY_TOKEN_MAX_AGE — lifetime of signed tokens in seconds. Default 3600.
  • SECURITY_CSRF_COOKIE_NAME / SECURITY_CSRF_COOKIE_SECURE — the CSRF cookie. Defaults csrf_token / True.
  • SECURITY_HASHER_TIME_COST / _MEMORY_COST / _PARALLELISM — argon2 tuning. Defaults 3 / 65536 / 4.
  • SECURITY_PASSWORD_MIN_LENGTH / SECURITY_PASSWORD_REQUIREMENTS — the policy the validators enforce. Defaults 8 and one each of lower, upper, digits and special.
  • SECURITY_OAUTH_CLIENTS — a mapping of provider name to authlib client settings.
  • SECURITY_MODELS_DB_BIND — put the security tables on a non-default bind.

Continue reading

From here you can continue straight with Events.