Security¶
Session-based web auth, OAuth2/OIDC login, SAML SSO, two-factor auth (TOTP), API token auth, RBAC-style authorization, CSRF protection, security response headers, password hashing, and multi-tenancy.
auth ¶
Session-based authentication.
A deliberately small design: one signed cookie (Starlette's SessionMiddleware,
keyed off APP_SECRET_KEY) holding the authenticated user's ID, CSRF
protection (:mod:zeython.csrf) that comes with it automatically -- cookie
auth without it is forgeable from any other site the user's browser happens
to have open -- an :class:AuthManager that knows how to look up and verify
credentials against whichever model you designate as your user model, and a
handful of functions (login, logout, current_user, require_auth)
that operate on a request.
No server-side session store, no token issuance/rotation — that's a deliberate scope boundary, not an oversight. Token-based auth (for an API consumed by a separate frontend) is a reasonable future addition; it does not belong in the same code path as cookie sessions.
Authenticatable ¶
Mixin adding password helpers to a user model.
The concrete model declares its own password column — conventionally
password_hash: Mapped[str] = mapped_column(String(255)) — this mixin
only adds behavior on top of it::
class User(Model, Authenticatable):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
AuthManager ¶
AuthManager(
user_model: type[Model],
*,
username_field: str = "email",
password_field: str = "password_hash",
)
Looks up and verifies users against a configured model and field names.
Source code in src/zeython/auth.py
attempt
async
¶
Verify credentials, returning the user on success or None on failure.
Source code in src/zeython/auth.py
AuthServiceProvider ¶
AuthServiceProvider(
app: Application,
user_model: type[Model],
*,
username_field: str = "email",
password_field: str = "password_hash",
csrf_exempt: Callable[[Request], bool] | None = None,
)
Bases: ServiceProvider
Wires up session-backed authentication for a chosen user model.
Adds Starlette's signed-cookie SessionMiddleware (keyed off
APP_SECRET_KEY, so that must be set), CSRF protection
(:class:~zeython.csrf.CsrfMiddleware -- see docs/csrf.md), and binds
an :class:AuthManager into the container::
app.register(AuthServiceProvider(app, user_model=User))
Configurable via .env: SESSION_COOKIE_NAME, SESSION_MAX_AGE
(seconds, default 14 days), SESSION_HTTPS_ONLY (default false;
set true once you're serving over HTTPS). CSRF_ENABLED (default
true), CSRF_COOKIE_NAME, CSRF_HEADER_NAME configure the CSRF
protection that comes with it -- turning it off is rarely the right
call, since it's exactly what makes cookie-based auth safe to use from
a browser.
csrf_exempt is passed straight through to
:class:~zeython.csrf.CsrfMiddleware's own exempt -- see its
docstring for why this exists at all. The one built-in use: pair this
with :mod:zeython.saml so the IdP's cross-site POST to your ACS
route isn't rejected::
from zeython.saml import is_saml_acs_request
app.register(AuthServiceProvider(app, user_model=User, csrf_exempt=is_saml_acs_request))
Source code in src/zeython/auth.py
hash_password ¶
Hash password for storage.
Returns a self-describing string: pbkdf2_sha256$<iterations>$<salt>$<hash>
(salt and hash base64-encoded), so the iteration count can be raised later
without invalidating hashes already in the database.
Raises ValueError for an empty password, or one over
_MAX_PASSWORD_BYTES (UTF-8) bytes -- see that constant's own
docstring.
Source code in src/zeython/hashing.py
verify_password ¶
Constant-time check of password against a hash from :func:hash_password.
Returns False (never raises) for any malformed input, including a
password over _MAX_PASSWORD_BYTES -- no genuine password is that
long, and a login endpoint deserves the same DoS hardening a
registration endpoint gets from :func:hash_password.
Source code in src/zeython/hashing.py
login ¶
logout ¶
current_user
async
¶
The authenticated user for this request, or None if not logged in.
Source code in src/zeython/auth.py
require_auth
async
¶
Return the authenticated user, or raise UnauthorizedException.
Call this at the top of any handler that requires a logged-in user::
async def show(self, request):
user = await require_auth(request)
Source code in src/zeython/auth.py
oauth ¶
OAuth2 / OIDC login: "Sign in with Google/GitHub/Microsoft/your own IdP", without your app ever seeing a password for that account.
Handles the protocol -- building the authorization redirect, the
CSRF-protected state round-trip, exchanging the authorization code for
an access token, and fetching + normalizing the provider's profile info
into an :class:OAuthUser -- and stops there: like Laravel Socialite,
this module hands you an identity, not an opinion about how your User
model is structured. Your callback route decides how to find-or-create a
local user from it (see docs/oauth.md for the two-line version -- find by
email, create if missing, then :func:zeython.auth.login).
Built for confidential (server-side) clients only -- the client secret
lives in your app's own configuration, never shipped to a browser -- so
there's no PKCE dance here; that exists to protect public clients (an
SPA or mobile app that can't keep a secret), which this isn't. The
state parameter is the CSRF protection that does apply here, and it's
mandatory: a random token stored server-side in the session before the
redirect, and checked (then discarded, so it can't be replayed) when the
provider calls back.
Every provider is reached through its userinfo endpoint (OAuth2), not by
decoding an OIDC id_token -- avoids needing a JWT/JWKS verification
dependency for what the userinfo endpoint already gives you over an
authenticated HTTPS request. Ships builders for Google, GitHub, and
Microsoft (Azure AD), plus :func:generic_oidc for any standards-compliant
OIDC provider (Okta, Auth0, Keycloak, your own IdP) that exposes the usual
sub/email/name/picture claims.
OAuthUser
dataclass
¶
OAuthUser(
provider: str,
provider_user_id: str,
email: str | None,
name: str | None,
avatar_url: str | None,
access_token: str,
raw: dict[str, Any] = dict(),
)
The identity :meth:OAuthManager.handle_callback hands back --
normalized the same way regardless of which provider it came from.
email is None if the provider didn't share one (GitHub, with a
private email and no user:email scope granted); decide for
yourself whether that's acceptable for your app. raw is the
provider's own userinfo response, for anything not already surfaced.
OAuthProvider
dataclass
¶
OAuthProvider(
name: str,
client_id: str,
client_secret: str,
authorize_url: str,
token_url: str,
userinfo_url: str,
redirect_uri: str,
scope: str,
map_userinfo: UserInfoMapper,
)
One configured provider -- built by :func:google, :func:github,
:func:microsoft, or :func:generic_oidc rather than constructed
directly in ordinary use. redirect_uri must exactly match what's
registered in the provider's own console -- it's never derived from
the incoming request (that would let a spoofed Host header send
the authorization code somewhere else).
OAuthManager ¶
Builds the authorization redirect and completes the callback for
every provider registered with it. Bound in the container by
:class:OAuthServiceProvider.
Source code in src/zeython/oauth.py
redirect_url ¶
The URL to send the browser to at provider_name -- stashes a
fresh CSRF state token in the session first, checked by
:meth:handle_callback when the provider redirects back.
Source code in src/zeython/oauth.py
handle_callback
async
¶
Validate the callback's state, exchange its code for an
access token, fetch the provider's userinfo, and return it
normalized. Raises :class:~zeython.exceptions.ForbiddenException
on a missing/mismatched state (CSRF, or a stale/replayed
callback -- state is popped from the session on first use, so a
second attempt with the same one fails the same way) and
:class:~zeython.exceptions.BadRequestException if the provider
didn't come back with an authorization code (the user denied
consent, or something else went wrong at the provider).
Source code in src/zeython/oauth.py
OAuthServiceProvider ¶
OAuthServiceProvider(
app: Any,
*,
providers: list[OAuthProvider],
timeout: float = DEFAULT_TIMEOUT,
)
Bases: ServiceProvider
Binds an :class:OAuthManager configured with the given providers::
app.register(OAuthServiceProvider(app, providers=[
google(client_id=..., client_secret=..., redirect_uri="https://app.example.com/auth/google/callback"),
github(client_id=..., client_secret=..., redirect_uri="https://app.example.com/auth/github/callback"),
]))
Needs AuthServiceProvider registered too (for the session
state lives in, and for :func:zeython.auth.login your callback
route calls) -- registration order between the two doesn't matter.
See docs/oauth.md.
Source code in src/zeython/oauth.py
google ¶
google(
*,
client_id: str,
client_secret: str,
redirect_uri: str,
scope: str = "openid email profile",
) -> OAuthProvider
A Google Cloud Console <https://console.cloud.google.com/apis/credentials>_
OAuth 2.0 Client ID's credentials, wired up to Google's own endpoints.
Source code in src/zeython/oauth.py
github ¶
github(
*,
client_id: str,
client_secret: str,
redirect_uri: str,
scope: str = "read:user user:email",
) -> OAuthProvider
A GitHub OAuth App's (or GitHub App's) client credentials --
see Settings > Developer settings <https://github.com/settings/developers>_.
Source code in src/zeython/oauth.py
microsoft ¶
microsoft(
*,
client_id: str,
client_secret: str,
redirect_uri: str,
tenant: str = "common",
scope: str = "openid email profile User.Read",
) -> OAuthProvider
An Azure AD app registration's credentials. tenant scopes who can
sign in: "common" (personal + any work/school account, the
default), "organizations" (work/school accounts only), or a
specific tenant ID/domain to restrict sign-in to one organization --
see Microsoft identity platform docs
<https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc>_.
Source code in src/zeython/oauth.py
generic_oidc ¶
generic_oidc(
*,
name: str,
client_id: str,
client_secret: str,
authorize_url: str,
token_url: str,
userinfo_url: str,
redirect_uri: str,
scope: str = "openid email profile",
) -> OAuthProvider
Any standards-compliant OIDC provider exposing the usual
sub/email/name/picture userinfo claims -- Okta, Auth0,
Keycloak, or an enterprise customer's own identity provider. Look up
authorize_url/token_url/userinfo_url from the provider's
/.well-known/openid-configuration discovery document (fetched once,
by hand, when you set this up -- not at request time).
Source code in src/zeython/oauth.py
oauth_redirect ¶
Send the browser to provider's login page -- call this from your
GET /auth/{provider}/redirect route::
async def oauth_redirect(self, request):
return oauth_redirect(request, request.path_params["provider"])
Source code in src/zeython/oauth.py
oauth_callback
async
¶
Complete the login for provider and return the resulting
identity -- call this from your GET /auth/{provider}/callback
route, then find-or-create your own User from it and call
:func:zeython.auth.login::
async def oauth_callback(self, request):
identity = await oauth_callback(request, request.path_params["provider"])
user = await User.first_where(email=identity.email)
if user is None:
user = await User.create(email=identity.email, name=identity.name)
login(request, user)
return JSONResponse(user.to_dict())
Source code in src/zeython/oauth.py
saml ¶
SAML 2.0 SSO login: "Sign in with Okta/Azure AD/ADFS/your enterprise IdP", for the identity providers (and enterprise customers) that specifically require SAML rather than OAuth2/OIDC.
Built on python3-saml <https://github.com/SAML-Toolkits/python3-saml>_
(pip install zeython[saml]), which does the part worth not
reimplementing: building the AuthnRequest, and parsing + validating the
IdP's signed Response (XML signature verification, replay/expiry/audience/
recipient checks). Zeython wraps it in an async, container-bound
:class:SamlManager with the same "hands you a normalized identity, not
an opinion about your User model" shape as :mod:zeython.oauth, so
both flows can sit side by side in one app and share the same
find-or-create-a-user callback.
Service-provider-initiated flow: your app redirects the user to the IdP
(:meth:SamlManager.login_url), and the IdP posts a signed assertion back
to your Assertion Consumer Service (ACS) URL
(:meth:SamlManager.handle_acs). An IdP-initiated login (the IdP sends an
unsolicited assertion -- common from an admin console's "test connection"
button) lands at the same ACS endpoint and validates the same way, since
python3-saml doesn't require a matching InResponseTo when none was
ever sent.
There's no universal attribute-naming standard the way OIDC's userinfo
claims are -- an IdP's admin configures which attribute names it sends,
often a URN (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/
emailaddress) or a short name (email), and it varies by IdP and by
how that IdP's admin set it up. :class:SamlUser recognizes a handful of
common ones for email/name automatically; set
email_attribute=/name_attribute= on :class:SamlProvider when
yours isn't one of them, and use :meth:SamlUser.attribute for anything
else your app needs from the assertion.
SamlUser
dataclass
¶
SamlUser(
name_id: str,
email: str | None,
name: str | None,
attributes: dict[str, list[str]] = dict(),
session_index: str | None = None,
)
The identity :meth:SamlManager.handle_acs hands back.
attributes is exactly what the IdP's assertion included, keyed
however the IdP named them -- see the module docstring on why there's
no universal naming standard here. Use :meth:attribute for anything
beyond email/name.
attribute ¶
The first value of attribute name, or None if the
assertion didn't include it.
SamlProvider
dataclass
¶
SamlProvider(
name: str,
idp_entity_id: str,
idp_sso_url: str,
idp_x509_cert: str,
sp_entity_id: str,
acs_url: str,
sp_x509_cert: str | None = None,
sp_private_key: str | None = None,
email_attribute: str | None = None,
name_attribute: str | None = None,
)
One configured IdP connection -- built by :func:saml_provider
rather than constructed directly in ordinary use. acs_url (your
app's Assertion Consumer Service) must exactly match what's registered
at the IdP.
to_settings ¶
The dict shape python3-saml's OneLogin_Saml2_Settings expects.
Source code in src/zeython/saml.py
SamlManager ¶
SamlManager(
providers: dict[str, SamlProvider],
*,
replay_cache: Cache | None = None,
replay_window: float = DEFAULT_REPLAY_WINDOW,
)
Builds the login redirect, validates the ACS callback, and generates
SP metadata for every provider registered with it. Bound in the
container by :class:SamlServiceProvider.
Tracks every assertion ID it accepts in replay_cache (a fresh
:class:~zeython.cache.InMemoryCache by default) for replay_window
seconds, and rejects a second callback presenting the same ID --
without this, a signed SAMLResponse a network observer captures (or an
IdP-side bug/misconfiguration that redelivers one) stays valid and
replayable for its entire signature-validity window, letting an
attacker complete the same login again by simply resending the
original request. Signature validation alone doesn't catch this: a
replayed response is, cryptographically, exactly as valid the second
time as the first.
Source code in src/zeython/saml.py
login_url ¶
handle_acs
async
¶
Validate the IdP's POSTed assertion and return the identity it
asserts. Raises :class:~zeython.exceptions.BadRequestException
if the callback carried no SAMLResponse, and
:class:~zeython.exceptions.ForbiddenException if the response
failed validation (bad/missing signature, expired, wrong
audience/recipient, already used once before, ...).
Source code in src/zeython/saml.py
metadata_xml ¶
The SP metadata XML to hand your IdP admin when they ask for it, instead of entering entity ID/ACS URL/certificate by hand.
Raises :class:RuntimeError if the generated metadata is invalid
(a configuration error in the :class:SamlProvider -- this is a
setup-time check, not something a real request can trigger).
Source code in src/zeython/saml.py
SamlServiceProvider ¶
SamlServiceProvider(
app: Application,
*,
providers: list[SamlProvider],
replay_cache: Cache | None = None,
replay_window: float = DEFAULT_REPLAY_WINDOW,
)
Bases: ServiceProvider
Binds a :class:SamlManager configured with the given providers::
app.register(SamlServiceProvider(app, providers=[
saml_provider(
name="okta",
idp_entity_id="http://www.okta.com/exk...",
idp_sso_url="https://your-org.okta.com/app/.../sso/saml",
idp_x509_cert="-----BEGIN CERTIFICATE-----...",
sp_entity_id="https://app.example.com/saml/okta/metadata",
acs_url="https://app.example.com/saml/okta/acs",
),
]))
Needs AuthServiceProvider registered too (for
:func:zeython.auth.login your ACS route calls) -- registration order
between the two doesn't matter. See docs/saml.md.
Pass replay_cache (a :class:~zeython.cache.RedisCache) to share
the used-assertion tracking across every process/machine instead of
each one only remembering what it itself has seen -- see
:class:SamlManager for why this tracking exists at all.
Source code in src/zeython/saml.py
saml_provider ¶
saml_provider(
*,
name: str,
idp_entity_id: str,
idp_sso_url: str,
idp_x509_cert: str,
sp_entity_id: str,
acs_url: str,
sp_x509_cert: str | None = None,
sp_private_key: str | None = None,
email_attribute: str | None = None,
name_attribute: str | None = None,
) -> SamlProvider
Configure one IdP connection -- everything here comes from your
IdP admin's console (entity ID, SSO URL, signing certificate) plus the
ACS URL you register there in return. sp_x509_cert/sp_private_key
are optional -- set both to have Zeython sign outgoing AuthnRequests
(some IdPs require it); without them, the request goes unsigned, which
most IdPs accept for SP-initiated login since the security-critical
direction is the IdP's signed response, always required.
Source code in src/zeython/saml.py
saml_login ¶
Send the browser to provider's IdP login page -- call this from
your GET /saml/{provider}/login route::
async def saml_login(self, request):
return saml_login(request, request.path_params["provider"])
Source code in src/zeython/saml.py
saml_acs
async
¶
Complete the login for provider and return the resulting
identity -- call this from your POST /saml/{provider}/acs route
(the Assertion Consumer Service URL registered at the IdP), then
find-or-create your own User from it and call
:func:zeython.auth.login::
async def saml_acs(self, request):
identity = await saml_acs(request, request.path_params["provider"])
user = await User.first_where(email=identity.email)
if user is None:
user = await User.create(email=identity.email, name=identity.name)
login(request, user)
return RedirectResponse("/", status_code=303)
Source code in src/zeython/saml.py
is_saml_acs_request ¶
True for a POST to the conventional
/saml/{provider}/acs path this module's own docs/examples use --
pass this straight to :class:~zeython.auth.AuthServiceProvider's
csrf_exempt (or :class:~zeython.csrf.CsrfMiddleware's own
exempt) so a SAML IdP's cross-site POST carrying the
assertion isn't rejected by CSRF -- see
:class:~zeython.csrf.CsrfMiddleware's docstring for why that POST
can never carry this app's CSRF cookie in the first place, and why
exempting it is safe (the assertion's own XML signature is the proof
of authenticity CSRF would otherwise provide).
If your ACS route lives somewhere else, write your own predicate instead of using this one -- it only recognizes the documented convention, not whatever path your app actually registered.
Source code in src/zeython/saml.py
saml_metadata
async
¶
Serve provider's SP metadata XML -- call this from a
GET /saml/{provider}/metadata route and hand your IdP admin the
URL, instead of entering entity ID/ACS URL/certificate by hand::
async def saml_metadata(self, request):
return await saml_metadata(request, request.path_params["provider"])
Source code in src/zeython/saml.py
mfa ¶
Time-based one-time password (TOTP) two-factor authentication.
RFC 6238 TOTP layered on top of the existing session-auth login flow
(:mod:zeython.auth): a user enrolls a secret, confirms it with a live
code from their authenticator app to turn MFA on (issuing one-time
recovery codes for when they lose that device), and from then on a
password check alone isn't enough -- login is gated behind a second
step. No new dependency: HMAC-SHA1 and base32 are both in the standard
library, so this needs nothing beyond what's already imported for
password hashing.
MfaEnrollable ¶
Mixin adding TOTP two-factor auth to a user model.
Declare the columns yourself, the same convention
:class:~zeython.auth.Authenticatable uses for password_hash::
class User(Model, Authenticatable, MfaEnrollable):
__tablename__ = "users"
__hidden__ = ("password_hash", "mfa_secret", "mfa_recovery_codes")
mfa_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
mfa_recovery_codes: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
mfa_last_counter: Mapped[int | None] = mapped_column(Integer, nullable=True)
mfa_secret and mfa_recovery_codes belong in __hidden__ --
they're as sensitive as password_hash and must never round-trip
through to_dict().
mfa_last_counter is optional -- :func:verify_and_consume uses it,
if present, to reject a TOTP code whose 30-second step was already
consumed, closing a capture-replay window a plain TOTP check leaves
open: without it, a valid code stays usable for its entire ~90s
validity window (valid_window=1 tolerates one step either side),
so an attacker who captures one in flight -- a phishing proxy, a
compromised logging middlebox, shoulder-surfing -- can submit that
same code themselves and get in too, even though the victim already
used it. Omit the column and behavior is unchanged from before this
existed (no replay tracking) -- it's opt-in, the same as
tenant_id/__hidden__ elsewhere, so an existing app adds it
only when ready to run the migration.
Enrollment
dataclass
¶
The result of :func:enroll -- both forms an authenticator app
accepts: uri for a QR code (rendered client-side; this module has
no image dependency), secret for manual entry as a fallback.
generate_secret ¶
A fresh random base32 TOTP secret, 160 bits -- RFC 4226's recommended minimum key length, twice what a bare 80-bit secret would give an attacker brute-forcing offline.
Source code in src/zeython/mfa.py
provisioning_uri ¶
The otpauth://totp/... URI an authenticator app scans as a QR
code (e.g. via a client-side JS QR library) or accepts pasted directly.
Source code in src/zeython/mfa.py
verify_totp ¶
True if code matches secret for the current 30s step, or
up to valid_window steps either side (tolerating ordinary clock
drift between server and the authenticator app -- the default of 1
accepts a ±30s window).
Source code in src/zeython/mfa.py
enroll
async
¶
Start enrollment: generates a new secret and stores it on user --
not yet enabled, :func:confirm must verify a live code first. Calling
this again before confirming replaces the pending secret, so an
abandoned enrollment can't be confirmed later with a stale one.
Source code in src/zeython/mfa.py
confirm
async
¶
Verify code against the pending secret from :func:enroll,
turning MFA on and issuing recovery codes -- returned here in
plaintext, the only time they're ever visible; only their hash is
stored, the same way a password is. Raises :class:ValidationException
if there's no enrollment in progress or the code doesn't verify.
If the model declares mfa_last_counter (see :class:MfaEnrollable),
this code's step is recorded as consumed the same way
:func:verify_and_consume would -- so the exact code used to confirm
enrollment can't also be replayed against the first login challenge.
Source code in src/zeython/mfa.py
disable
async
¶
Turn MFA off and forget the secret and recovery codes.
verify_and_consume
async
¶
True if code is a valid live TOTP code, or an unused recovery
code. A matching recovery code is consumed -- removed from storage --
on success, since each is one-time use; a spent code never verifies
again, including under two requests racing to spend it at once (on
Postgres/MySQL -- see the note below for SQLite).
The recovery-code check re-fetches user with
find(..., for_update=True) rather than trusting whatever's already
loaded on the user passed in: without a lock, two concurrent
requests presenting the same code could each read it as still unused
before either had written its removal, and both would be told the
code was valid. Locking the row means the second request's re-fetch
blocks until the first's transaction actually commits, so it then
correctly sees the code already gone.
On SQLite specifically, this guarantee doesn't hold -- see
:meth:~zeython.db.Model.find's for_update docs -- so two
genuinely concurrent requests there can still both succeed, same as
before this existed.
If the model declares mfa_last_counter (see
:class:MfaEnrollable), a TOTP code is additionally rejected if its
30s step was already consumed -- the same locked re-fetch the
recovery-code path uses, so two requests racing to replay the same
captured code can't both succeed on Postgres/MySQL either (not on
SQLite, same caveat as above).
Source code in src/zeython/mfa.py
start_challenge ¶
Mark user as having passed the password check but still needing
their second factor -- call this from a login handler instead of
:func:zeython.auth.login whenever user.mfa_enabled is true::
user = await manager.attempt(email, password)
if user is None:
raise UnauthorizedException("Invalid email or password.")
if user.mfa_enabled:
start_challenge(request, user)
return JSONResponse({"mfa_required": True})
auth_login(request, user)
The user is not authenticated yet -- :func:~zeython.auth.current_user
still returns None -- until :func:complete_challenge succeeds.
Source code in src/zeython/mfa.py
pending_user_id ¶
The id of the user awaiting their second factor, or None if
there's no challenge in progress for this session.
complete_challenge
async
¶
complete_challenge(
request: Request,
code: str,
*,
rate_limit: bool = True,
rate_limit_attempts: int = 5,
rate_limit_window: float = 60.0,
) -> Model | None
Verify code for the user :func:start_challenge left pending
and, on success, complete login (equivalent to calling
:func:zeython.auth.login directly) and return that user. Returns
None if there's no pending challenge or code doesn't verify --
the pending state is left in place on failure so the caller can retry.
Rate-limited by default, keyed to the pending user (not the caller's
IP) -- a 6-digit TOTP code has only ~1,000,000 live combinations, a
brute-forceable range for an attacker who already has the victim's
password and is only missing the second factor, and keying by account
rather than IP still catches an attacker spreading guesses across
many source IPs/proxies against the one account they're targeting.
This only takes effect if :class:~zeython.rate_limit.RateLimiter is
actually bound in the container (i.e. RateLimitServiceProvider is
registered, as it is by default in a generated project) -- an app
that hasn't registered rate limiting at all gets no change in
behavior rather than a container-resolution error the first time
someone submits a code. Raises
:class:~zeython.exceptions.TooManyRequestsException past
rate_limit_attempts per rate_limit_window seconds; pass
rate_limit=False if you'd rather apply your own
:func:~zeython.rate_limit.throttle call instead (e.g. to share one
limiter key with a different endpoint).
Source code in src/zeython/mfa.py
api_auth ¶
API token authentication: stateless bearer tokens for clients that can't use cookies -- a mobile app, a separate SPA, a server-to-server caller.
Deliberately a separate code path from :mod:zeython.auth's cookie
sessions, not a mode bolted onto the same functions -- a bearer token and a
session cookie are verified differently, travel in different places (an
Authorization header vs. a cookie jar), and a handler should be
unambiguous about which one it expects.
Tokens are signed with itsdangerous (already a framework dependency,
used by Starlette's own session cookie signing) rather than a JWT library or
a database-backed token table -- no new dependency, and no migration
required to get started. The trade-off that buys: a token can't be revoked
before it expires. There's no server-side record of it to delete. If your
app needs "log this device out remotely," implement a real
:class:TokenManager yourself against a token table you can delete rows
from -- this one is the zero-setup default, not the only correct design.
TokenManager ¶
Issues and verifies bearer tokens for a chosen user model.
Source code in src/zeython/api_auth.py
issue ¶
A signed token encoding user.id.
Signed, not encrypted: itsdangerous protects against
tampering (a client can't forge or edit a valid token without
the secret key) but not against reading -- the payload is only
base64-encoded, trivially decodable by anyone holding the token,
the client it was issued to included. Fine for user_id alone;
don't extend this to encode anything that shouldn't be readable
by whoever holds the token.
Source code in src/zeython/api_auth.py
verify
async
¶
The user the token was issued for, or None if it's missing, tampered, or expired.
Source code in src/zeython/api_auth.py
ApiAuthServiceProvider ¶
Bases: ServiceProvider
Binds a :class:TokenManager into the container.
Reuses APP_SECRET_KEY (the same key session cookies are signed
with) -- rotating it invalidates every issued token, same as it already
invalidates every session. .env: API_TOKEN_EXPIRES_IN (seconds,
default 30 days).
Source code in src/zeython/api_auth.py
current_api_user
async
¶
The user identified by this request's Authorization: Bearer <token> header, or None.
Source code in src/zeython/api_auth.py
require_api_auth
async
¶
Return the token-authenticated user, or raise UnauthorizedException (401).
Call this at the top of any handler meant for bearer-token clients, the
same way :func:~zeython.auth.require_auth guards cookie-session ones::
async def me(self, request):
user = await require_api_auth(request)
Source code in src/zeython/api_auth.py
authorization ¶
Authorization: "can this specific user do this specific thing", answered separately from authentication.
require_auth() (see :mod:zeython.auth) only answers "is anyone logged
in" -- a materially different, and much weaker, question than "can the
logged-in user edit this post". Almost every mutating endpoint in a real
app needs the second question answered, and there was previously nothing in
the framework that helped with it beyond hand-rolled if checks scattered
across controllers.
Modeled on Laravel's Gate/Policy split: named closures for one-off checks
(gate.define(...)), resource-bound Policy classes (gate.policy(...))
for the common case of many abilities against one model, a global
gate.before(...) hook for cross-cutting rules like "an admin can do
anything", and a light :class:HasRoles mixin plus :meth:Gate.role/
:meth:Gate.permission sugar for role- or permission-gated abilities. All
of it is optional and additive -- a project that only ever needs
gate.define("delete-post", lambda user, post: ...) never has to touch
the rest.
Gate ¶
A registry of named authorization checks ("abilities").
Source code in src/zeython/authorization.py
define ¶
Register check(user, *args) -> bool (sync or async) under ability::
gate.define("update-post", lambda user, post: post.author_id == user.id)
Source code in src/zeython/authorization.py
policy ¶
Register a Policy for model: a plain class with one method per
ability, e.g. def update(self, user, post) -> bool. policy
may be the class itself (instantiated once, here) or an existing
instance::
class PostPolicy:
def update(self, user, post) -> bool:
return post.author_id == user.id
def create(self, user) -> bool:
return user.is_verified
gate.policy(Post, PostPolicy)
An ability not covered by :meth:define falls back to the policy
registered for type(args[0]) (or args[0] itself, when it's a
class -- for abilities like "create" checked before an instance
exists: authorize(request, "create", Post)). A policy method
named before(self, user, ability) runs first if present, and a
non-None result short-circuits the specific ability method --
the per-policy equivalent of :meth:before.
Source code in src/zeython/authorization.py
before ¶
Register a global hook run before every :meth:allows check,
as check(user, ability, *args). A non-None result
short-circuits the specific ability/policy check entirely --
typically used for a blanket bypass::
gate.before(lambda user, ability, *args: True if getattr(user, "is_admin", False) else None)
Returning None (the default for a check that only cares about
specific abilities) defers to the normal ability/policy lookup.
Source code in src/zeython/authorization.py
role
staticmethod
¶
A check requiring the user to have any of names, via
:class:HasRoles::
gate.define("manage-users", Gate.role("admin"))
Source code in src/zeython/authorization.py
permission
staticmethod
¶
A check requiring the user to have permission name, via
:class:HasRoles::
gate.define("delete-post", Gate.permission("posts.delete"))
Source code in src/zeython/authorization.py
allows
async
¶
Whether user passes the ability check against args.
Checked in order: any :meth:before hook, then a :meth:define-d
closure, then a :meth:policy method for type(args[0]).
Raises KeyError if none of those resolve -- an authorization
check for an ability that doesn't exist is a bug in the calling
code, not a "deny by default" situation to swallow silently.
Source code in src/zeython/authorization.py
HasRoles ¶
Mixin adding role/permission checks to a user model, for
:meth:Gate.role/:meth:Gate.permission and for direct use in
templates/controllers.
Duck-typed against a roles relationship of objects with a name
attribute, each optionally with its own permissions relationship of
objects with a name attribute -- the conventional Role/Permission
many-to-many shape (a user has roles, a role has permissions), which
this framework doesn't impose a schema for since it's already just
regular models and relationships (see docs/authorization.md for the
table definitions and :mod:zeython.database.seeder for seeding them)::
class User(Model, Authenticatable, HasRoles):
__tablename__ = "users"
roles: Mapped[list["Role"]] = relationship(secondary=user_roles, lazy="selectin")
class Role(Model):
__tablename__ = "roles"
name: Mapped[str] = mapped_column(String(50), unique=True)
permissions: Mapped[list["Permission"]] = relationship(secondary=role_permissions, lazy="selectin")
class Permission(Model):
__tablename__ = "permissions"
name: Mapped[str] = mapped_column(String(100), unique=True)
AuthorizationServiceProvider ¶
Bases: ServiceProvider
Binds an empty :class:Gate into the container.
Define your app's abilities in your own provider's boot() (register
this provider first, or anywhere -- boot() order doesn't matter,
only that every provider's register() has already run)::
class AppAuthorizationProvider(ServiceProvider):
def boot(self) -> None:
gate: Gate = self.container.make(Gate)
gate.define("delete-post", lambda user, post: post.author_id == user.id)
gate.policy(Post, PostPolicy)
gate.before(lambda user, ability, *args: True if getattr(user, "is_admin", False) else None)
Source code in src/zeython/providers.py
authorize
async
¶
Require the current user to pass ability, or raise.
Authorization presupposes authentication: this calls :func:~zeython.auth.require_auth
first, so an anonymous request gets UnauthorizedException (401) --
only a logged-in user who fails the ability check gets
ForbiddenException (403). Returns the authenticated user on success::
async def destroy(self, request):
post = await Post.find(int(request.path_params["id"]))
await authorize(request, "delete-post", post)
await post.delete()
Source code in src/zeython/authorization.py
csrf ¶
CSRF protection for cookie-authenticated requests.
A browser attaches cookies to a request automatically, even one triggered
by a completely different site -- that's exactly what session-cookie auth
(:mod:zeython.auth) relies on, and exactly what makes it forgeable
without protection: a malicious page can trigger a POST to this app
and the victim's session cookie rides along, no user interaction beyond
"visited a page" required.
This uses the double-submit-cookie pattern: a random token is set as a
readable (non-HttpOnly) cookie, and any unsafe request (POST,
PUT, PATCH, DELETE) must also send that same value back in a
header. A cross-site attacker's page can trigger the cookie to be sent,
but can't read its value (the same-origin policy blocks that) to also
set the matching header -- so a forged request is missing the header, or
has the wrong value, and gets rejected. See docs/csrf.md.
CsrfMiddleware ¶
CsrfMiddleware(
app: Any,
*,
cookie_name: str = DEFAULT_COOKIE_NAME,
header_name: str = DEFAULT_HEADER_NAME,
form_field_name: str = DEFAULT_FORM_FIELD_NAME,
secure: bool = False,
protect_if_cookie_present: str | None = None,
exempt: Callable[[Request], bool] | None = None,
)
Pure ASGI middleware implementing the double-submit-cookie check.
A request is exempt if:
- its method is safe (
GET/HEAD/OPTIONS/TRACEnever change state, so there's nothing to forge), - it carries an
Authorizationheader -- a bearer token isn't attached to cross-site requests automatically the way a cookie is, so it isn't vulnerable to this in the first place (see :mod:zeython.api_auth), or protect_if_cookie_presentis set and this request doesn't carry that cookie -- CSRF only matters when there's an existing cookie-authenticated session to forge an action within; a request with no session cookie at all (a token-issuing endpoint like/api/token, or the very first request from a brand new client) has nothing ambient for a forged cross-site request to ride on. :class:~zeython.auth.AuthServiceProvidersets this to its own session cookie's name; leave unset for blanket protection of every unsafe request regardless of cookies, orexemptis given and returnsTruefor this request -- for an endpoint that is itself a legitimate, necessarily cross-sitePOSTwith its own independent proof the request is genuine, the same reasoning theAuthorizationheader exemption above already relies on. The one this exists for: :func:~zeython.saml.is_saml_acs_request(a SAML IdP's HTTP-POST binding auto-submits the assertion as a cross-sitePOSTfrom the IdP's own origin, so this app's CSRF cookie is never attached to it -- the double-submit check would fail every single legitimate login, not just forged ones, and the response's own signature is the proof of authenticity CSRF would otherwise provide). Nothing wires this in automatically -- pass it explicitly (e.g. via :class:~zeython.auth.AuthServiceProvider'scsrf_exempt) once you know which endpoint(s) genuinely need it; it's opt-in precisely because exempting the wrong route reopens the hole this middleware exists to close.
Every other response gets a fresh csrf_token cookie if one isn't
already present; every other request must send that same value back
either via the X-CSRF-Token header (client-configurable name) -- the
only option for a JSON/fetch request -- or, for a real HTML
<form> submission (which can't set a custom header), a _token
field in the form body itself (client-configurable name via
form_field_name). Zeython Blade's @csrf directive renders
exactly that hidden field -- see docs/blade.md.
Source code in src/zeython/csrf.py
csrf_token ¶
The current request's CSRF token, if :class:CsrfMiddleware is installed.
Useful for embedding in a server-rendered form as a hidden field, for
apps that submit real HTML forms instead of driving everything through
fetch/XHR (which can just read the cookie directly instead).
Source code in src/zeython/csrf.py
security_headers ¶
Common HTTP security response headers -- opt-in, since sensible defaults
for some of these (a Content-Security-Policy above all) are genuinely
application-specific: a wrong default here doesn't fail loudly, it just
silently breaks a legitimate asset/script your own pages load. Register
:class:SecurityHeadersServiceProvider explicitly once you've decided
what belongs in your own policy, rather than getting one imposed on you.
SecurityHeadersMiddleware ¶
SecurityHeadersMiddleware(
app: Any,
*,
content_security_policy: str | None = None,
frame_options: str | None = "DENY",
content_type_options: bool = True,
referrer_policy: str
| None = "strict-origin-when-cross-origin",
hsts: bool = False,
hsts_max_age: int = 60 * 60 * 24 * 365,
)
Pure ASGI middleware that adds security response headers to every response.
X-Content-Type-Options: nosniff-- stops a browser from second-guessing a response's declaredContent-Type(the classic case: an uploaded file served back and "sniffed" as HTML, letting it execute as a page instead of staying inert).X-Frame-Options--DENYby default, so this app can't be framed by another site (clickjacking).Referrer-Policy--strict-origin-when-cross-originby default: full URL on same-origin navigation, origin-only cross-origin, nothing on a downgrade to plain HTTP.Content-Security-Policy-- unset by default. There's no universal safe default: a policy that's too strict breaks your own inline scripts or CDN-loaded assets (this framework's own Swagger UI and dev-mode Tailwind both load from a CDN -- see docs/security-headers.md), and one that's too loose isn't worth sending. Pass your own.Strict-Transport-Security(HSTS) -- off by default. Turning it on before every path to this app is actually served over HTTPS can lock users out of a plain-HTTP fallback for as long asmax_agesays; only enable it once you mean it.
Source code in src/zeython/security_headers.py
SecurityHeadersServiceProvider ¶
Bases: ServiceProvider
Registers :class:SecurityHeadersMiddleware, configured entirely via .env.
Not registered by default -- see the module docstring. Add it explicitly::
app.register(SecurityHeadersServiceProvider)
SECURITY_HEADERS_CSP-- yourContent-Security-Policyvalue. Unset by default; no header is sent until you provide one.SECURITY_HEADERS_FRAME_OPTIONS-- defaultDENY.SECURITY_HEADERS_CONTENT_TYPE_OPTIONS-- defaulttrue.SECURITY_HEADERS_REFERRER_POLICY-- defaultstrict-origin-when-cross-origin.SECURITY_HEADERS_HSTS-- defaultfalse.SECURITY_HEADERS_HSTS_MAX_AGE-- default31536000(1 year).
Source code in src/zeython/providers.py
hashing ¶
Password hashing: PBKDF2-HMAC-SHA256, no C-extension dependency required.
PBKDF2 was chosen over bcrypt/argon2 deliberately: it needs no third-party
crypto library (stdlib hashlib only), which keeps the framework installable
everywhere pip and a C compiler don't necessarily agree, while still meeting
OWASP's current guidance for PBKDF2-HMAC-SHA256 iteration counts.
hash_password ¶
Hash password for storage.
Returns a self-describing string: pbkdf2_sha256$<iterations>$<salt>$<hash>
(salt and hash base64-encoded), so the iteration count can be raised later
without invalidating hashes already in the database.
Raises ValueError for an empty password, or one over
_MAX_PASSWORD_BYTES (UTF-8) bytes -- see that constant's own
docstring.
Source code in src/zeython/hashing.py
verify_password ¶
Constant-time check of password against a hash from :func:hash_password.
Returns False (never raises) for any malformed input, including a
password over _MAX_PASSWORD_BYTES -- no genuine password is that
long, and a login endpoint deserves the same DoS hardening a
registration endpoint gets from :func:hash_password.
Source code in src/zeython/hashing.py
tenancy ¶
Row-level multi-tenancy: isolating one tenant's rows from another's in a single shared database, rather than a database (or schema) per tenant.
A model opts in just by declaring a tenant_id column -- no mixin, no
per-query flag. :class:~zeython.db.Model's own query methods
(find/all/find_by/paginate) check for that column and, if
present, scope every read to :func:current_tenant_id automatically (see
Model._base_select()); a new row gets tenant_id assigned from the
same source on save() if it wasn't already set explicitly. Multi-tenant
awareness lives in one place -- the column's presence -- rather than
scattered .where(tenant_id=...) calls a future query is one missed line
away from leaking across tenants.
:class:TenantMiddleware resolves which tenant a request belongs to
and makes it available to :func:current_tenant_id for the request's
duration via a :class:~contextvars.ContextVar, the same technique
:func:~zeython.request_id.request_id and
:func:~zeython.localization.current_locale use -- readable from a
Model query with no request in hand.
TenantMiddleware ¶
Pure ASGI middleware: resolves the request's tenant via resolver
and sets it as a contextvar for :func:current_tenant_id for the
request's duration.
Source code in src/zeython/tenancy.py
TenancyServiceProvider ¶
Bases: ServiceProvider
Registers :class:TenantMiddleware with resolver.
resolver is a required argument -- there is deliberately no
default. How a request maps to a tenant (a subdomain, a header, the
logged-in user's own tenant_id) is entirely app-specific, and
guessing wrong here is a cross-tenant data leak, not a cosmetic
mistake -- the same reasoning :class:~zeython.admin.AdminServiceProvider's
required guard has.
::
from zeython import Application, TenancyServiceProvider
def resolve_tenant(request):
# e.g. acme.example.com -> "acme"
return request.url.hostname.split(".")[0]
app.register(TenancyServiceProvider(app, resolver=resolve_tenant))
See docs/multi-tenancy.md.
Source code in src/zeython/tenancy.py
current_tenant_id ¶
The current request's resolved tenant ID.
None outside a request handled by :class:TenantMiddleware, or
when the resolver returned nothing for this request -- in either case,
Model query methods apply no tenant filter at all (not "filter to
tenant None"), the same way an unauthenticated request has no locale
override and just gets the default. A background job or script that
needs tenant scoping sets it explicitly with :func:as_tenant.
Source code in src/zeython/tenancy.py
as_tenant ¶
Scope every Model query inside this block to tenant_id --
for a job, a script, or a test that has no request (and therefore no
:class:TenantMiddleware) to resolve one from::
with as_tenant(tenant.id):
posts = await Post.all() # only this tenant's rows