Metadata-Version: 2.4
Name: quart-security
Version: 2.0.0
Summary: Native async auth for Quart
Author: quart-security maintainers
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: quart>=0.20.0
Requires-Dist: sqlalchemy[asyncio]>=2.0
Requires-Dist: libpass[bcrypt]>=1.9.3
Requires-Dist: pyotp>=2.9
Requires-Dist: qrcode[pil]>=8.0
Requires-Dist: webauthn>=2.7
Requires-Dist: cryptography>=50.0.0
Requires-Dist: cbor2>=5.9.0
Requires-Dist: wtforms>=3.1
Requires-Dist: aiosmtplib>=5.1.2
Requires-Dist: blinker>=1.7
Requires-Dist: email-validator>=2.0
Requires-Dist: argon2-cffi>=23.1
Requires-Dist: httpx>=0.27
Requires-Dist: pytest>=8.0 ; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23 ; extra == "dev"
Requires-Dist: ruff>=0.6 ; extra == "dev"
Project-URL: Homepage, https://github.com/level09/quart-security
Project-URL: Issues, https://github.com/level09/quart-security/issues
Project-URL: Repository, https://github.com/level09/quart-security
Provides-Extra: dev

# quart-security

`quart-security` is a native async authentication extension for Quart.

It is designed as a practical replacement path for Flask-Security style session auth in Quart applications, without Flask shims and without Flask-Login.

## What You Get

### Core auth
- Session-based login and logout
- Email/password registration
- Password change flow (including OAuth-style users that don’t know an initial random password)
- `current_user` proxy
- `@auth_required("session")` and `@roles_required(...)`

### MFA
- TOTP setup and verification
- Recovery code generation and one-time consumption

### WebAuthn (passkeys / security keys)
- Credential registration
- Passwordless sign-in (first factor)
- Authenticated verification flow (step-up / second factor)
- Credential deletion

### Extension and compatibility surface
- Quart extension pattern (`Security(app, datastore)`)
- Flask-Security-style endpoint naming through `url_for_security()`
- Signals for auth lifecycle events
- Overridable templates under `templates/security/`
- Datastore hooks can be implemented as async methods or simple sync methods

## Non-Goals (Current Scope)

This project intentionally focuses on session auth and MFA currently in active use:
- No token-based API auth
- No SMS/email OTP
- No account locking workflow
- No remember-me token system

## Installation

### Install from repository

```bash
uv add git+https://github.com/level09/quart-security.git
```

### Local development

```bash
uv sync --group dev
uv run pytest -q
```

Package build backend: `flit`.

## Quick Integration

```python
from quart import Quart
from quart_security import Security, SQLAlchemyUserDatastore

import os

# Models must include the fields required by enabled features.
from myapp.models import db, User, Role, WebAuthnCredential


def create_app():
    app = Quart(__name__)

    app.config.update(
        SECRET_KEY=os.environ["SECRET_KEY"],
        SECURITY_POST_LOGIN_VIEW="/dashboard",
        SECURITY_POST_REGISTER_VIEW="/login",
    )

    db.init_app(app)
    datastore = SQLAlchemyUserDatastore(
        db,
        User,
        Role,
        webauthn_model=WebAuthnCredential,
    )

    Security(app, datastore)
    return app
```

## Required Model Surface

Your user/role models are expected to provide the fields used by active features.

Minimum practical user fields:
- `fs_uniquifier`
- `email`
- `password`
- `active`
- `roles`

For tracking / MFA / WebAuthn features:
- `last_login_at`, `current_login_at`, `last_login_ip`, `current_login_ip`, `login_count`
- `tf_primary_method`, `tf_totp_secret`, `mf_recovery_codes`
- `fs_webauthn_user_handle`
- relationship/association for stored WebAuthn credentials

Required for the default lockout policy:
- `failed_login_count`
- `locked_until`

These fields are required when login or MFA lockout is enabled. The built-in
counter limits account-level guesses. Deploy an application or edge rate limiter
for source IP and distributed abuse controls.

The database must enforce unique normalized emails, `fs_uniquifier` values,
WebAuthn user handles, and credential IDs. Use a stable primary key separate from
`fs_uniquifier`: this value changes when security settings change.

## Key Configuration

The extension uses `SECURITY_*` keys for migration-friendly configuration.

Core:
- `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
- `SECURITY_PASSWORD_SALT` (legacy salted-hash verification only)
- `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
- `SECURITY_PASSWORD_BREACH_CHECK` (default: `True`) - HIBP k-anonymity check on register/change
- `SECURITY_PASSWORD_BREACH_COUNT_MIN` (default: `1`) - minimum breach count to reject
- `SECURITY_ARGON2_MEMORY_COST` / `SECURITY_ARGON2_TIME_COST` / `SECURITY_ARGON2_PARALLELISM` - override argon2 params (production defaults: 19456/2/1)
- `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
- `SECURITY_LOCKOUT_MINUTES` (default: `15`)
- `SECURITY_REGISTERABLE`
- `SECURITY_CHANGEABLE`
- `SECURITY_TRACKABLE`
- `SECURITY_CSRF_PROTECT` (default: `True`)
- `SECURITY_COOKIE_SECURE` (default: `True`; sets `SESSION_COOKIE_SECURE`)
- `SECURITY_FRESHNESS` (default: `60` minutes)

Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 default change. They are transparently rehashed to argon2id on the user's next successful login.

2FA:
- `SECURITY_TWO_FACTOR`
- `SECURITY_TOTP_ISSUER`
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES`
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES_N`

Recovery codes are displayed only when generated and are stored as keyed hashes.
Existing plaintext codes remain valid until their next successful use, when the
remaining codes are migrated.

Accepted TOTP time steps, recovery codes, and WebAuthn challenges cannot be reused.
Disabling an authenticator requires a current TOTP or unused recovery code.
Wait for the next TOTP code if the current one was just used to sign in.

WebAuthn:
- `SECURITY_WEBAUTHN`
- `SECURITY_WAN_ALLOW_AS_FIRST_FACTOR`
- `SECURITY_WAN_ALLOW_AS_MULTI_FACTOR`
- `SECURITY_WAN_RP_ID` (optional override)
- `SECURITY_WAN_RP_NAME` (optional override)
- `SECURITY_WAN_EXPECTED_ORIGIN` (optional override)
- `SECURITY_WAN_REQUIRE_USER_VERIFICATION` (default: `True`)

Routing:
- `SECURITY_POST_LOGIN_VIEW`
- `SECURITY_POST_REGISTER_VIEW`

## Route Map

Core:
- `/login`
- `/register`
- `/logout` (POST only)
- `/change`

2FA:
- `/tf-setup`
- `/tf-validate`
- `/tf-select`
- `/mf-recovery-codes`
- `/mf-recovery`

WebAuthn:
- `/wan-register`
- `/wan-register-response`
- `/wan-signin`
- `/wan-signin-response`
- `/wan-verify`
- `/wan-verify-response`
- `/wan-delete`

## Template Overrides

Default templates are intentionally simple and framework-neutral.

Override by placing templates with the same names under your app’s
`templates/security/` directory.

## Public API

```python
from quart_security import (
    Security,
    SQLAlchemyUserDatastore,
    current_user,
    auth_required,
    roles_required,
    UserMixin,
    RoleMixin,
    hash_password,
    verify_password,
    user_authenticated,
    user_logged_out,
    password_changed,
    tf_profile_changed,
    user_registered,
    url_for_security,
)
```

## Testing

Project tests cover:
- password hashing/validation
- auth and role decorators
- register/login/logout/change-password
- TOTP and recovery code flows
- WebAuthn register/sign-in/verify/delete route behavior

Run:

```bash
uv run pytest -q
```

### WebAuthn staging validation (recommended before release tags)

Run this in a HTTPS staging environment with production-like hostnames and real browser prompts:

1. Register a passkey from `/wan-register` and verify it is persisted with expected `name`, `usage`, and `sign_count`.
2. Complete passwordless sign-in from `/wan-signin` with the same credential.
3. Complete authenticated verify flow from `/wan-verify` while already signed in.
4. Delete credential from `/wan-delete` and confirm subsequent passkey auth fails for that credential.
5. Repeat step 1 and step 2 with a second authenticator type (for example platform passkey + hardware key) to validate device portability assumptions.

## Notes for Production

- Run behind HTTPS for WebAuthn in non-local environments.
- Set explicit WebAuthn RP values (`SECURITY_WAN_RP_ID`, `SECURITY_WAN_EXPECTED_ORIGIN`) when behind proxies or multiple domains.
- Keep CSRF protection enabled unless you have a deliberate replacement.

## Version 2.0.0 migration

This update requires a shared `quart_security_state` table. It stores expiring
authentication records and verification state. Cookie contents contain opaque
references, not pending authenticator secrets. All workers must use the same
database. Existing login cookies are rejected after this update.

Create the table through your application's database migration before deploying
the new library. For example, in an Alembic migration:

```python
from alembic import op
from quart_security import SecurityState


def upgrade():
    SecurityState.__table__.create(op.get_bind())
```

This includes the expiry index. Startup checks that the table is present. Protect
database access and backups because pending TOTP secrets are stored there.
Expired records are removed when new state is written. Maintenance jobs can also
call `await security.state_store.purge_expired()` in an application context and
close the datastore after the job.

For a session factory, use `async_sessionmaker(engine, expire_on_commit=False)`.
The library retains the session through commits and closes factory-owned sessions
at the end of each HTTP request or WebSocket connection. Read-only and failed
requests also close their sessions. A supplied `db.session` or session instance
remains owned by the host application; the host must close or roll it back.
Outside a request, close factory-owned sessions with `await datastore.close()`.
Do not share one session across concurrent tasks.

Password and MFA profile changes rotate the user's `fs_uniquifier`, which rejects
older authenticated and pending login sessions. Logout revokes the current
authentication record, including copied cookies for that session. Password
changes keep the requesting session authenticated. Passkey registration and
deletion also revoke older sessions. A `secondary` passkey cannot perform
passwordless sign-in. A primary passkey uses its own user verification and does
not require the account's TOTP code.

Password helpers now use the current application's settings. Outside an app
context, pass `app=app`, for example `hash_password(password, app=app)`.

Cookies default to Secure, HttpOnly, and SameSite=Lax. For local HTTP development
only, set `SECURITY_COOKIE_SECURE=False`. An explicit SameSite value is preserved.
Use a strong random secret key and explicit RP/origin settings behind proxies.
The host still needs source-IP and global rate limits; account lockout does not
limit registration or anonymous challenge generation. Lockout fields are required
when `SECURITY_LOGIN_MAX_ATTEMPTS` is greater than zero. Setting it to zero
explicitly disables account lockout.

Custom datastores require a shared `state_store` passed to `Security`. Its async
methods must implement this contract and propagate storage errors:

| Method | Contract |
| --- | --- |
| `put(payload, ttl=seconds, token=None)` | Persist a dictionary with an expiry and return an unpredictable reference. |
| `get(token)` | Return unexpired state or None. |
| `pop(token)` | Atomically consume unexpired state; exactly one caller receives it. |
| `claim(token, ttl=seconds)` | Atomically reserve a key; return False while another unexpired claim exists. |

A process-local dictionary is suitable only for tests. Custom datastores must also
provide `record_auth_failure(user, max_attempts=..., lockout_minutes=...)` as an
atomic increment/lock update and
`replace_recovery_codes(user, expected, remaining)` as an atomic conditional
replacement returning a boolean. `rotate_uniquifier(user, expected, replacement)`
must atomically check the old value and persist both the new value and staged
profile changes. Return False and roll back staged changes when the old value no
longer matches. Concurrent profile updates return HTTP 409; the user must sign in
again. Persist these changes before returning. The
extension fails at initialization if an enabled control lacks its required hook
or SQLAlchemy model fields.

Dependencies now include the Pillow, bcrypt, and SQLAlchemy asyncio extras and patched
minimum versions of aiosmtplib, cryptography, and cbor2. The release workflow runs
lint and tests before building and publishing. CI tests Python 3.11 through 3.14.

