jfastframework
Docs Español PyPI GitHub

Accounts

Users, password login, roles and permissions -- the user store the auth plugin deliberately leaves out, as a plugin you turn on.

[plugins]
enabled = ["observability", "database", "cache", "auth", "accounts"]

[plugin.auth]
mode = "secret"
algorithms = ["HS256"]
issue_tokens = true

[plugin.accounts]
bootstrap_admin_email = "admin@example.com"
pip install "jfastframework[accounts]"
JFAST_AUTH_SECRET=...                           # at least 32 bytes
JFAST_ACCOUNTS_BOOTSTRAP_ADMIN_PASSWORD=...     # first start only

On the first start the plugin creates its tables, a role called admin with the permission accounts:admin, and the administrator. Remove the password from the environment afterwards: the user exists, and the variable is only read when it does not.


The model

Thing What it is
Permission A string: invoices:write, reports:read. Yours to name.
Role A named set of permissions: billing = invoices:read + invoices:write.
User An email, a password (or a provider identity), and roles.

A signed-in user's permissions travel in the access token as scopes, so the check on a route is one dependency and no database query:

from fastapi import Depends
from jfastframework.accounts import require_permission
from jfastframework.auth import Principal

@router.post("/invoices")
async def create(caller: Principal = Depends(require_permission("invoices:write"))):
    ...

require_permission is require_scopes under the name that says what it is for here. No token is 401; a token without the permission is 403.


Endpoints

Under prefix (default /auth), next to auth's own /auth/refresh and /auth/logout:

Method Path Does
POST /auth/login {email, password} → a token pair, or the second step (below)
POST /auth/login/mfa {mfa_token, code} → a token pair. With mfa = true
POST /auth/register Creates an account. Only with allow_registration = true
GET /auth/account The signed-in user, with roles, permissions, email_verified, mfa_enabled
POST /auth/password {current_password, new_password}
POST /auth/logout/all Ends every session of the caller, this one included
GET /auth/features What is on: registration, verification, reset, MFA -- for a frontend
POST /auth/verify {token} from the email. With email_verification on
POST /auth/verify/resend {email} → 202, always
POST /auth/password/forgot {email} → 202, always. With password_reset = true
POST /auth/password/reset {token, new_password} → 204, and every session ends
POST /auth/mfa/setup Starts enrolment: {secret, otpauth_uri}
POST /auth/mfa/confirm {code} turns it on and returns the recovery codes, once
POST /auth/mfa/disable {password, code}
POST /auth/mfa/recovery-codes {password, code} → a new set; the old ones stop working

Errors are RFC 7807 as everywhere else, and the ones a frontend has to tell apart carry a code: email_not_verified, token_invalid, mfa_code_invalid, mfa_token_invalid, password_invalid, mfa_required_by_role.

Under admin_prefix (default /accounts), for holders of accounts:admin:

Method Path Does
GET /accounts/users Users, paginated
POST /accounts/users {email, password?, display_name?, roles}
PATCH /accounts/users/{id} {is_active?, display_name?, roles?}
GET /accounts/roles Roles and their permissions
POST /accounts/roles {name, description?, permissions}
PATCH /accounts/roles/{id} {description?, permissions?}
DELETE /accounts/roles/{id} Removes the role from everyone who had it
DELETE /accounts/users/{id}/mfa Turns a user's MFA off: lost phone and lost recovery codes

POST /accounts/users takes email_verified: true when the administrator vouches for the address; otherwise, with verification on, the user is sent the link. Deactivating a user (PATCH ... {is_active: false}) ends their sessions at once -- the access token included, not at its expiry.


Email verification

[plugins]
enabled = ["observability", "database", "cache", "queue", "auth", "mail", "accounts"]

[plugin.accounts]
allow_registration = true
email_verification = "required"          # off | optional | required
frontend_url = "https://app.example.com" # where the link in the email points

optional sends the link at sign-up and the account works meanwhile; /auth/account says email_verified: false until it is used. required gives no session until then: registration answers 202 with no tokens, and a sign-in with the right password answers 403 email_not_verified -- only with the right password, so the answer tells nobody else the account exists.

The link is frontend_url + verify_email_path (/verify-email) + ?token=.... The frontend posts the token to /auth/verify. The token is 256 random bits, stored only as its SHA-256, good for verification_token_minutes (a day), and single use: consuming it is one conditional UPDATE, so two clicks on one link produce one success. A resend leaves the first link working -- the first email is often the one opened.

Mail goes through the mail plugin, queued when queue is on. The three messages -- accounts/verify_email, accounts/reset_password, accounts/already_registered -- have plain built-in versions; drop <name>.html (and .txt) into the mail templates_dir to replace one. They get app_name, link, email, display_name and expires_minutes.

Signing up with a taken address, in required mode, answers the same 202 as a new one, and the owner of the address gets an email saying so, with a reset link. The sign-up form never says "that email is taken". In off and optional, registration signs the user in, so a taken address is a 409, as before: there is no way to hand out a session and hide that.

Administrators and the bootstrap admin are verified when created (the operator wrote the address), a provider-verified email counts, and completing a password reset does too -- the link reached the inbox.

Enabling verification on a service with users: every existing account is unverified. With required, they get the 403 and can ask for a link. To treat the accounts that existed before as verified, run once: UPDATE jfast_users SET email_verified_at = created_at WHERE email_verified_at IS NULL.

Password reset

[plugin.accounts]
password_reset = true
frontend_url = "https://app.example.com"

POST /auth/password/forgot {email} answers 202, whatever the address -- and at the same moment: the lookup, the token and the email all happen after the response has been sent, so the response time says nothing about whether the address has an account. The link is reset_password_path (/reset-password), good for reset_token_minutes (30), single use, hashed at rest.

POST /auth/password/reset {token, new_password} checks the password policy before spending the token (a password that is too short does not cost the link), sets the password, clears a lockout, and ends every session of the account: each refresh family recorded at sign-in is revoked in auth's store, which stops the access tokens that carry it too. It does not sign the user in: that would skip the second factor.

At most one email per address per email_cooldown_seconds (60), however often the form is sent.

Two-factor authentication

[plugin.accounts]
mfa = true
mfa_required_roles = ["admin"]    # optional
JFAST_ENCRYPTION_KEYS=k1:...      # see encryption.md; required with mfa = true

TOTP (RFC 6238): six digits, thirty seconds, SHA-1 -- what every authenticator app reads. Written against the standard library (hmac, hashlib, base64), no new dependency, and tested against the RFC's own vectors.

Enrolment is two steps. POST /auth/mfa/setup (with the current password) stores a new secret and returns it with an otpauth:// URI; nothing is guarded yet. POST /auth/mfa/confirm {code} turns it on with a code the app computed, and returns ten recovery codes -- shown once, stored as SHA-256, each good for one sign-in.

Signing in becomes two steps:

POST /auth/login       {email, password}   -> {"mfa_required": true, "mfa_token": "...", "expires_in": 300}
POST /auth/login/mfa   {mfa_token, code}   -> the token pair

code is the six digits or a recovery code. The MFA token is single use and lasts mfa_token_minutes (5). A code one step either side of now is accepted (a phone thirty seconds off still works); the same code is never accepted twice, nor an older one -- the last accepted step is stored and moved forward with a conditional UPDATE, so two requests racing with one code get one yes. A wrong code counts against the MFA token (mfa_max_attempts, 5, then sign in again) and against the account's lockout, so knowing the password buys no extra guesses at the code: the failure count is cleared only when a session is actually issued.

Per role. A user holding a role in mfa_required_roles and no MFA is sent to enrol at sign-in -- {"mfa_enrollment_required": true, "mfa_token": ...}; /auth/mfa/setup and /confirm accept that token in the body instead of a session, and confirm then returns the session with the recovery codes. A session that gains such a role ends at its next refresh, and MFA cannot be turned off while the role is held.

The secret is the one credential that cannot be hashed -- the server computes the code from it -- so it is encrypted with JFAST_ENCRYPTION_KEYS, bound to its user. The plugin refuses to start with mfa = true and no key.

A provider sign-in (auth social login) of an account with MFA returns the same mfa_required challenge instead of tokens: a provider is the first factor.

Rate limits

With the cache plugin on, sign-in is rate limited without configuring anything, in addition to the lockout:

Bucket Default Applies to
per address 20 per 5 min (login_limit_per_ip) login, the MFA step, verify, reset
per account 10 per 5 min (login_limit_per_account) login (keyed on the email typed), the MFA step
emails per address 5 per 15 min (email_limit_per_ip) register, resend, forgot

A token bucket in Redis -- the ratelimit plugin's own limiter, on the cache's connection; the ratelimit plugin itself need not be on. Over the limit is a 429 with Retry-After. The per-account bucket is keyed on what was typed, so it answers the same for an address with no account. If Redis stops answering, it fails open and the lockout still applies. rate_limit = false turns it off; without cache it is off and says so at startup.


What it defends against

Guessing. After max_failed_logins wrong passwords or codes (5) the account locks for lockout_minutes (15). Counted per account, not per address, so a guesser cannot get round it by changing IP; the rate limits above slow down guessing across many accounts.

Finding out who has an account. A wrong password, an unknown email and a locked account all answer the same 401 with the same message -- and take the same time, because an unknown email is still checked against a decoy hash. "Send me a link" (reset, resend) answers 202 before looking the address up, and sign-up with verification required answers the same for a taken address.

A permission that outlives its removal. accounts registers auth's on_refresh hook, so every refresh re-reads the user's roles. Take a permission away, or deactivate the user, and it applies at their next refresh -- deactivation ends the session there -- instead of at the end of a thirty-day refresh token. The access token already issued keeps working until it expires (access_lifetime_minutes, 15), which is the reason to keep that short -- except on deactivation, a password reset and /auth/logout/all, which revoke every session at once, access tokens included.

A stolen table of tokens. Verification and reset tokens and recovery codes are stored as SHA-256; the TOTP secret is encrypted. A copy of the database resets nobody's password. None of them is ever logged.

A stolen password table. Passwords are hashed with argon2id, off the event loop, and rehashed at the next login when the parameters change.

Taking over an account through a provider. With social login configured in auth, a provider identity is linked to an existing account only through a verified email. An unverified one would let anybody who can type an address into a provider sign in as its owner.


Tenants

Every user and role belongs to a tenant, or to none. Login, registration and social sign-in read the tenant from the request -- the tenancy plugin's subdomain or path source; the token source cannot, because there is no token yet. The same email in two tenants is two people, and an administrator sees and changes only the users and roles of their own tenant.

A SaaS where each account is its own tenant needs no tenant table at all: add the tenancy plugin with sources = ["token", "user"] and the signed-in user's id becomes the tenant for everything they create. The users themselves stay in no tenant, which is right: the account is the boundary. See the user source.


Settings

Setting Default
prefix / admin_prefix /auth / /accounts
allow_registration false Also governs whether social login may create users
default_roles [] Roles a self-registered user starts with
min_password_length 10
max_failed_logins / lockout_minutes 5 / 15
admin_permission accounts:admin
bootstrap_admin_email "" Password from JFAST_ACCOUNTS_BOOTSTRAP_ADMIN_PASSWORD
social_login true Only acts when auth has providers
email_verification off off, optional or required. Needs mail
verification_token_minutes 1440
password_reset false Needs mail
reset_token_minutes 30
frontend_url "" Required when either of the two above is on (JFAST_ACCOUNTS_FRONTEND_URL)
verify_email_path / reset_password_path / login_path /verify-email / /reset-password / /login The frontend routes the links point at
email_cooldown_seconds 60 One email of a kind per address per this long
mfa false Needs JFAST_ENCRYPTION_KEYS
mfa_required_roles [] Needs mfa = true
mfa_issuer the app name What the authenticator app shows
mfa_token_minutes / mfa_max_attempts 5 / 5 The second step of a sign-in
recovery_codes 10
rate_limit true Only acts with the cache plugin
login_limit_per_ip / login_limit_per_account / login_window_seconds 20 / 10 / 300
email_limit_per_ip / email_window_seconds 5 / 900

Every combination that cannot work stops the service at startup, naming the fix: verification or reset without mail or without frontend_url, mfa_required_roles without mfa, mfa without an encryption key.

The tables (jfast_users, jfast_roles, jfast_role_permissions, jfast_user_roles, jfast_account_tokens, jfast_recovery_codes, jfast_user_sessions) are the plugin's: created at startup and skipped by the service's Alembic autogenerate. A jfast_users created by an earlier release gains its new columns at startup too (all nullable, so the ALTER is instant): no migration to write.

What is not here