Skip to content

Authentication#

A complete session-based auth system: registration, login, password reset, email verification, 2FA (TOTP), session listing/rotation/ revoke-all, and audit events for every step — all pluggable into your own routes or used as the pre-wired auth_routes() endpoints.

Pre-wired routes#

from hayavega.auth_api import auth_routes

urlpatterns = [
    *auth_routes(DATABASE, secret_key=SECRET_KEY),
]

This wires up POST /api/auth/login, POST /api/auth/logout, POST /api/auth/password-reset/{request,confirm}, GET /api/auth/sessions, POST /api/auth/sessions/revoke-others, and more — all fully described in the generated OpenAPI document, including the shared {"detail": string} error envelope. See REST & OpenAPI.

Users and sessions, directly#

from hayavega.auth import create_user, authenticate, create_session, authenticate_session

user = create_user(DATABASE, "alice", "alice@example.com", "Correct-Horse-7!")
authenticated = authenticate(DATABASE, "alice", "Correct-Horse-7!")
token = create_session(DATABASE, authenticated)
still_valid = authenticate_session(DATABASE, token)

Passwords are validated (validate_password) and hashed (hash_password/verify_password) before storage; sessions are opaque tokens, never a value that encodes the user directly.

Password policy#

The default (validate_password: 12+ characters, lower/upper/digit/ symbol) isn't hardcoded into create_user -- it's just the default validators list ([validate_password]). Pass your own to create_user/create_superuser/reset_password to replace it entirely:

from hayavega.auth import common_password_validator, create_user, min_length_validator

create_user(
    DATABASE,
    "alice",
    "alice@example.com",
    "a-different-policy-pw",
    validators=[min_length_validator(8), common_password_validator(["password123", "qwerty123456"])],
)

Each validator is callable(password) -> None, raising ValueError with a message to reject it; the first rejection wins (validators after it never run). Two ready-made ones ship alongside the default: min_length_validator(n) (a relaxed length-only check) and common_password_validator(denylist) (case-insensitive deny-list match -- pair it with a length/complexity validator, since alone it enforces neither).

manage.py createadmin reads Settings.password_validators (dotted paths to already-built validators, resolved the same way job_handlers is) when set, falling back to the default otherwise:

config/settings.py
class Settings:
    password_validators = ["myapp.validators.relaxed_policy"]
myapp/validators.py
from hayavega.auth import min_length_validator

relaxed_policy = min_length_validator(8)

A dotted path must point at an already-built validator (a plain callable) -- not a factory call like min_length_validator(8) written inline, since only a bare attribute can be resolved by name.

reset_password(..., validators=...) validates the new password before consuming the single-use reset token -- a rejected password never burns the user's reset link, which still works on a later, valid attempt.

Gating a view#

from hayavega.security import login_required, permission_required, superuser_required, email_verified_required


@login_required(DATABASE)
def dashboard(request):
    return Response(f"Welcome, {request.user.username}")


@superuser_required(DATABASE)
def admin_panel(request): ...


@permission_required(DATABASE, lambda user: user.is_staff)
def staff_only(request): ...


@email_verified_required(DATABASE)
def verified_only(request): ...

permission_required(database, predicate) is the general form — login_required/superuser_required/email_verified_required are all built on it with a specific predicate. Every one of these composes correctly with async def views under ASGI.

Groups & permissions#

A lighter analog of Django's Group/Permission models: permissions are free-form string codenames (e.g. "blog.add_post"), not tied to a contenttypes framework Hayavega doesn't have. Group a set of codenames once, assign users to as many groups as needed, and check membership at authorization time.

from hayavega.auth import add_user_to_group, create_group, user_permissions
from hayavega.security import has_permission_required

editors = create_group(DATABASE, "editors", ["blog.add_post", "blog.change_post"])
add_user_to_group(DATABASE, alice, editors)

user_permissions(DATABASE, alice)  # {"blog.add_post", "blog.change_post"}


@has_permission_required(DATABASE, "blog.add_post")
def create_post(request): ...

user_permissions returns the union of every group a user belongs to -- it does not special-case is_superuser. has_permission_required does: a superuser always passes, the same bypass superuser_required already gives every other permission check in this codebase, kept consistent rather than making groups a special case.

Password reset and email verification#

from hayavega.auth import create_password_reset_token, reset_password, create_email_verification_token, verify_email

token = create_password_reset_token(DATABASE, user)  # 1 hour, single-use
reset_password(DATABASE, token, "New-Password-9!")  # revokes every existing session

verify_token = create_email_verification_token(DATABASE, user)  # 24 hours, single-use
verify_email(DATABASE, verify_token)

Tokens are stored only as a SHA-256 hash, never in plaintext, and are single-use — a reused or expired token is rejected. Mail delivery is pluggable via a notify(kind, user, token) callable passed to auth_routes(...); the default only logs the token, so the flow works without a mail provider configured during development. See Email for real console/file/SMTP backends and mail_notifier(), which adapts one into this exact notify shape.

Two-factor authentication (TOTP)#

from hayavega.auth import enable_totp, verify_totp

secret = enable_totp(DATABASE, "alice", secret_key=SECRET_KEY)  # returns the secret to show as a QR code
authenticate(DATABASE, "alice", password, totp_code="123456", secret_key=SECRET_KEY)

secret_key (pass Settings.secret_key) is optional but recommended: when given, and cryptography is installed (uv add cryptography -- kept optional, like the PostgreSQL/MySQL drivers, rather than a mandatory dependency for every project), the stored TOTP secret is encrypted with a key derived from secret_key, not stored in plaintext. Without it, the secret is stored as-is -- hayavega diagnose reports that fallback. If a user's secret was stored encrypted, every later authenticate() call for them needs the same secret_key or the TOTP check fails closed (treated as wrong, never silently skipped) rather than accepting any code.

Sessions: listing, rotation, revoke-all#

from hayavega.auth import list_sessions, rotate_session, revoke_all_sessions

sessions = list_sessions(DATABASE, user)  # id/timestamps only, never a token
new_token = rotate_session(DATABASE, old_token)  # fresh identifier, same session
revoke_all_sessions(DATABASE, user, except_token=current_token)

Expired sessions are swept lazily (at most once a minute per database, not on every authenticate_session check) -- a still-physically-present expired row is never treated as valid either way, so this only affects how promptly hayavega_sessions itself shrinks back down, not whether an expired token is rejected.

Audit events#

Every step above emits a persistent audit event (auth.password_reset_requested/completed, auth.email_verification_requested, auth.session_rotated, auth.sessions_revoked_all, ...) — see Signals for how to subscribe to these in-process, and Security for the audit log's own storage guarantees (field names, never values, for changed data).

CSRF#

from hayavega.security import csrf_protect


@csrf_protect
def submit_form(request): ...

Accepts either an X-Csrf-Token header (API/JSON clients) or a csrf_token form field (server-rendered <form> submissions) — the same decorator covers both without extra wiring.