fastapi-fullauth 0.17.2__tar.gz → 0.18.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/CHANGELOG.md +22 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/PKG-INFO +9 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/sqlalchemy.md +10 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/hooks.md +2 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/configuration.md +10 -0
- fastapi_fullauth-0.18.0/docs/mfa.md +176 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/migrating-from-fastapi-users.md +1 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/security/threat-model.md +13 -2
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/models.py +1 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/__init__.py +1 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/__init__.py +2 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/_sqlalchemy_base.py +115 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/base.py +43 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/beanie.py +77 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/sqlalchemy.py +3 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/sqlmodel.py +3 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/tortoise.py +74 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/config.py +28 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/tokens.py +31 -1
- fastapi_fullauth-0.18.0/fastapi_fullauth/flows/mfa.py +194 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/fullauth.py +32 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/hooks.py +6 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/__init__.py +2 -0
- fastapi_fullauth-0.18.0/fastapi_fullauth/models/beanie/mfa.py +34 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/__init__.py +2 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/base.py +3 -1
- fastapi_fullauth-0.18.0/fastapi_fullauth/models/sqlalchemy/mfa.py +40 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/__init__.py +2 -0
- fastapi_fullauth-0.18.0/fastapi_fullauth/models/sqlmodel/mfa.py +39 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/__init__.py +2 -0
- fastapi_fullauth-0.18.0/fastapi_fullauth/models/tortoise/mfa.py +36 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/_schemas.py +4 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/auth.py +21 -3
- fastapi_fullauth-0.18.0/fastapi_fullauth/routers/mfa.py +275 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/types.py +25 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/mkdocs.yml +2 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/pyproject.toml +7 -5
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/SKILL.md +1 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/adapters.md +3 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/api-reference.md +10 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/adapter_conformance.py +67 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/conftest.py +6 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_beanie_adapter.py +7 -1
- fastapi_fullauth-0.18.0/tests/test_mfa.py +375 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_security.py +4 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_sqlalchemy_adapter.py +7 -1
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_tokens.py +110 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_tortoise_adapter.py +7 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_user_id_types.py +12 -0
- fastapi_fullauth-0.18.0/uv.lock +3304 -0
- fastapi_fullauth-0.17.2/uv.lock +0 -2772
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/pull_request_template.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/workflows/ci.yml +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/workflows/docs.yml +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/workflows/publish.yml +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.gitignore +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.python-version +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/AGENTS.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/CONTRIBUTING.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/LICENSE +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/Makefile +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/README.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/SECURITY.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/beanie.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/custom.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/index.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/sqlmodel.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/tortoise.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/api-reference.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/architecture.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/custom-claims.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/dependencies.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/passwords.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/sessions.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/contributing.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/customization.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/frontend-integration.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/getting-started.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/index.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/migrations.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/oauth.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/passkeys.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/recipes.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/security/middleware.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/security/rate-limiting.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/stylesheets/home.css +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/testing.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/troubleshooting.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/auth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/config.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/main.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/models.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/routes.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/auth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/config.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/main.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/routes.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/auth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/config.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/main.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/models.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/routes.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/auth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/config.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/main.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/models.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/routes.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/backends/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/backends/base.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/backends/bearer.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/backends/cookie.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/cli.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/_redis.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/blacklist.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/crypto.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/dependencies/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/dependencies/current_user.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/dependencies/rbac.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/exceptions.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/change_password.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/credentials.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/email_verify.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/login.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/logout.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/oauth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/passkey.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/password_reset.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/profile.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/reauth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/refresh.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/register.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/sessions.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/tokens.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/middleware/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/middleware/csrf.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/middleware/ratelimit.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/middleware/security_headers.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/_common.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/base.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/oauth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/passkey.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/permission.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/role.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/oauth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/passkey.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/permission.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/role.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/base.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/oauth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/passkey.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/permission.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/role.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/_common.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/base.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/oauth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/passkey.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/permission.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/role.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/base.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/discord.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/github.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/gitlab.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/google.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/protection/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/protection/challenges.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/protection/lockout.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/protection/ratelimit.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/py.typed +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/_transport.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/admin.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/oauth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/passkey.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/profile.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/sessions.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/verify.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/utils.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/validators.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/scripts/llmstxt_preprocess.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/composable-design.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/getting-started.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/hooks.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/migrations.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/oauth.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/passkeys.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/production.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/rbac.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/sessions.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/testing.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/troubleshooting.md +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/__init__.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_account_safety.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_auth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_cli.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_config.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_cookie_backend.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_crypto.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_hooks.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_oauth.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_passkey.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_passkey_flows.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_polish.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_profile.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_rbac.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_session_revocation.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_sessions.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_sqlmodel_adapter.py +0 -0
- {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_tortoise_int_keys.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Two-factor authentication (TOTP).** The one row where the fastapi-users comparison read "none" for both libraries. Install with the new `mfa` extra, set `MFA_ISSUER`, pass an `mfa_model` to your adapter, and `/auth/mfa/enroll` plus `/auth/mfa/confirm` turn it on for a user; the recovery codes come back from the confirm call and are shown once. A correct password on an account that has it answers `202` with a short-lived `mfa_token` rather than a session, which `/auth/mfa/verify` exchanges for the real token pair along with a code or a recovery code. Enrolling and disabling ask for the same proof of presence as `DELETE /me`. See [the guide](https://mdfarhankc.github.io/fastapi-fullauth/mfa/).
|
|
8
|
+
|
|
9
|
+
The ways around a second factor are what make it worth having, so: a code is refused once its time step has been accepted, which also means the code that confirms enrolment is spent and signing in needs the next one; `/mfa/verify` has its own rate-limit bucket and counts failures against the account through the lockout manager, since six digits is a small space; one step of clock drift either side is allowed, as RFC 6238 recommends; the pending token is burned when it buys a session and is rejected anywhere a session token is expected; recovery codes are stored hashed and are single use.
|
|
10
|
+
|
|
11
|
+
**MFA gates password sign-in only.** Passkeys are already phishing-resistant and an OAuth provider runs its own second factor, which this library cannot inspect. The TOTP secret is stored in plaintext, as the OAuth provider tokens are; the threat model says so.
|
|
12
|
+
|
|
13
|
+
- **`after_mfa_enabled` and `after_mfa_disabled` hooks,** both `async def(user)`. Turning a second factor off is what an attacker who has taken an account does first, so it is worth mailing about.
|
|
14
|
+
- **`JWT_ISSUER` and `JWT_AUDIENCE`.** Several services signing with one `SECRET_KEY` had no way to tell whose token they were holding, so an access token minted for an admin tool was accepted by the public API. Set either one and every token the engine mints carries the claim, purpose-scoped tokens included, and decoding requires it. Both are off by default, and an unset `JWT_AUDIENCE` means the claim is not checked, so a deployment can enable it on the issuing side and roll the verifying side forward afterwards rather than needing both at once. **Once a service requires them, tokens minted before they were configured stop validating, so expect one round of sign-outs.** This closes a limitation the threat model has been carrying.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **`pip install "fastapi-fullauth[sqlmodel]"` produced an installation that could not be imported.** The extra took `sqlmodel`, which depends on plain SQLAlchemy, and relied on SQLAlchemy installing greenlet by itself. SQLAlchemy 2.1 no longer does, so a fresh install resolved without greenlet and `import fastapi_fullauth` raised `ImportError` from the asyncio module. The extra now declares `sqlalchemy[asyncio]`, as the `sqlalchemy` extra always did. This affected 0.17.x installs made after SQLAlchemy 2.1 was released, not only this version.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **The SQLAlchemy model guidance no longer uses `lazy="noload"`.** SQLAlchemy 2.1 deprecates that strategy, so anyone who copied the documented `User` model from here saw a deprecation warning, and a failure outright if they treat warnings as errors. The documentation, the mixin docstring, the example app and the Agent Skill now use `relationship(lazy="raise", passive_deletes=True)`: nothing in the library reads `refresh_tokens`, so an accidental access should be a clear error rather than a lazy query in async code, and the cascade belongs to the `ON DELETE CASCADE` the mixin already declares. If you have the old line in your models, change it.
|
|
23
|
+
- **Dependencies refreshed and verified against the newest releases,** including SQLAlchemy 2.1, pydantic 2.14, pydantic-settings 2.16, FastAPI 0.143.3, SQLModel 0.0.48, mypy 2.4 and ruff 0.17.1. The declared floors are unchanged, and `sqlalchemy>=2.0.49` was re-checked by running the adapter suites against 2.0.54 as well as 2.1.4.
|
|
24
|
+
|
|
3
25
|
## 0.17.2
|
|
4
26
|
|
|
5
27
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fastapi-fullauth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.0
|
|
4
4
|
Summary: Production-grade, async-native authentication and authorization library for FastAPI
|
|
5
5
|
Project-URL: Changelog, https://github.com/mdfarhankc/fastapi-fullauth/blob/main/CHANGELOG.md
|
|
6
6
|
Project-URL: Documentation, https://mdfarhankc.github.io/fastapi-fullauth
|
|
@@ -36,8 +36,11 @@ Provides-Extra: beanie-standard
|
|
|
36
36
|
Requires-Dist: bcrypt>=4.1.0; extra == 'beanie-standard'
|
|
37
37
|
Requires-Dist: beanie>=2.0; extra == 'beanie-standard'
|
|
38
38
|
Requires-Dist: httpx>=0.28.1; extra == 'beanie-standard'
|
|
39
|
+
Requires-Dist: pyotp>=2.10.0; extra == 'beanie-standard'
|
|
39
40
|
Requires-Dist: redis>=7.4.0; extra == 'beanie-standard'
|
|
40
41
|
Requires-Dist: webauthn>=2.7.1; extra == 'beanie-standard'
|
|
42
|
+
Provides-Extra: mfa
|
|
43
|
+
Requires-Dist: pyotp>=2.10.0; extra == 'mfa'
|
|
41
44
|
Provides-Extra: oauth
|
|
42
45
|
Requires-Dist: httpx>=0.28.1; extra == 'oauth'
|
|
43
46
|
Provides-Extra: passkey
|
|
@@ -51,17 +54,21 @@ Provides-Extra: sqlalchemy-standard
|
|
|
51
54
|
Requires-Dist: alembic>=1.18.4; extra == 'sqlalchemy-standard'
|
|
52
55
|
Requires-Dist: bcrypt>=4.1.0; extra == 'sqlalchemy-standard'
|
|
53
56
|
Requires-Dist: httpx>=0.28.1; extra == 'sqlalchemy-standard'
|
|
57
|
+
Requires-Dist: pyotp>=2.10.0; extra == 'sqlalchemy-standard'
|
|
54
58
|
Requires-Dist: redis>=7.4.0; extra == 'sqlalchemy-standard'
|
|
55
59
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.49; extra == 'sqlalchemy-standard'
|
|
56
60
|
Requires-Dist: webauthn>=2.7.1; extra == 'sqlalchemy-standard'
|
|
57
61
|
Provides-Extra: sqlmodel
|
|
58
62
|
Requires-Dist: alembic>=1.18.4; extra == 'sqlmodel'
|
|
63
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.49; extra == 'sqlmodel'
|
|
59
64
|
Requires-Dist: sqlmodel>=0.0.38; extra == 'sqlmodel'
|
|
60
65
|
Provides-Extra: sqlmodel-standard
|
|
61
66
|
Requires-Dist: alembic>=1.18.4; extra == 'sqlmodel-standard'
|
|
62
67
|
Requires-Dist: bcrypt>=4.1.0; extra == 'sqlmodel-standard'
|
|
63
68
|
Requires-Dist: httpx>=0.28.1; extra == 'sqlmodel-standard'
|
|
69
|
+
Requires-Dist: pyotp>=2.10.0; extra == 'sqlmodel-standard'
|
|
64
70
|
Requires-Dist: redis>=7.4.0; extra == 'sqlmodel-standard'
|
|
71
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.49; extra == 'sqlmodel-standard'
|
|
65
72
|
Requires-Dist: sqlmodel>=0.0.38; extra == 'sqlmodel-standard'
|
|
66
73
|
Requires-Dist: webauthn>=2.7.1; extra == 'sqlmodel-standard'
|
|
67
74
|
Provides-Extra: tortoise
|
|
@@ -69,6 +76,7 @@ Requires-Dist: tortoise-orm>=1.0; extra == 'tortoise'
|
|
|
69
76
|
Provides-Extra: tortoise-standard
|
|
70
77
|
Requires-Dist: bcrypt>=4.1.0; extra == 'tortoise-standard'
|
|
71
78
|
Requires-Dist: httpx>=0.28.1; extra == 'tortoise-standard'
|
|
79
|
+
Requires-Dist: pyotp>=2.10.0; extra == 'tortoise-standard'
|
|
72
80
|
Requires-Dist: redis>=7.4.0; extra == 'tortoise-standard'
|
|
73
81
|
Requires-Dist: tortoise-orm>=1.0; extra == 'tortoise-standard'
|
|
74
82
|
Requires-Dist: webauthn>=2.7.1; extra == 'tortoise-standard'
|
|
@@ -46,9 +46,18 @@ class User(UserMixin, Base):
|
|
|
46
46
|
roles: Mapped[list[Role]] = relationship(
|
|
47
47
|
secondary="fullauth_user_roles", lazy="selectin",
|
|
48
48
|
)
|
|
49
|
-
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
|
|
49
|
+
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
|
|
50
|
+
lazy="raise", passive_deletes=True
|
|
51
|
+
)
|
|
50
52
|
```
|
|
51
53
|
|
|
54
|
+
`roles` is eagerly loaded because the library reads it; `refresh_tokens` is not read
|
|
55
|
+
at all, so `lazy="raise"` turns an accidental access into a clear error instead of
|
|
56
|
+
a lazy query in async code, and `passive_deletes=True` leaves the cascade to the
|
|
57
|
+
`ON DELETE CASCADE` the mixin already declares. (Earlier versions of this page
|
|
58
|
+
used `lazy="noload"`, which SQLAlchemy deprecated in 2.1 because it silently
|
|
59
|
+
returns nothing for related items.)
|
|
60
|
+
|
|
52
61
|
`UserMixin` provides `id`, `email`, `hashed_password` (nullable; `NULL` for OAuth-only users), `is_active`, `is_verified`, `is_superuser`, `created_at`. For integer or string user keys, see [Choosing a primary key type](index.md#choosing-a-primary-key-type).
|
|
53
62
|
|
|
54
63
|
### 2. Create the adapter
|
|
@@ -48,6 +48,8 @@ fullauth.hooks.on("after_register", on_register)
|
|
|
48
48
|
| `after_email_verify` | `async def(user: UserSchema)` | After email is verified |
|
|
49
49
|
| `after_oauth_login` | `async def(user: UserSchema, provider: str, is_new_user: bool)` | After OAuth login succeeds |
|
|
50
50
|
| `after_oauth_register` | `async def(user: UserSchema, user_info: OAuthUserInfo)` | After a new user is created via OAuth |
|
|
51
|
+
| `after_mfa_enabled` | `async def(user: UserSchema)` | After two-factor authentication is turned on |
|
|
52
|
+
| `after_mfa_disabled` | `async def(user: UserSchema)` | After it is turned off |
|
|
51
53
|
| `after_oauth_link` | `async def(user: UserSchema, provider: str)` | After a signed-in user links a provider |
|
|
52
54
|
| `after_oauth_unlink` | `async def(user: UserSchema, provider: str)` | After a signed-in user unlinks a provider |
|
|
53
55
|
| `send_verification_email` | `async def(email: str, token: str)` | When email verification is requested |
|
|
@@ -134,6 +134,8 @@ and `REAUTH_MAX_AGE_SECONDS` accept `0`, which means something for each of them.
|
|
|
134
134
|
| Option | Type | Default | Description |
|
|
135
135
|
|--------|------|---------|-------------|
|
|
136
136
|
| `SECRET_KEY` | `str \| None` | `None` | JWT signing key. Auto-generated in dev if not set. |
|
|
137
|
+
| `JWT_ISSUER` | `str \| None` | `None` | Written as `iss` on every token, and required when decoding. Set it when more than one service signs with the same `SECRET_KEY`. |
|
|
138
|
+
| `JWT_AUDIENCE` | `str \| None` | `None` | Written as `aud` on every token, and required when decoding. Left unset, the claim is not checked, so you can enable it on the issuing side first. |
|
|
137
139
|
| `ALGORITHM` | `str` | `"HS256"` | JWT signing algorithm. |
|
|
138
140
|
|
|
139
141
|
### Tokens
|
|
@@ -277,6 +279,14 @@ fullauth = FullAuth(config=config, adapter=adapter, backends=[backend])
|
|
|
277
279
|
!!! tip
|
|
278
280
|
Setting `FULLAUTH_REDIS_URL=redis://...` switches all subsystems to Redis at once; you don't also need `FULLAUTH_BACKEND=redis`. Override individual backends as needed (e.g. `LOCKOUT_BACKEND=memory` for local dev), or set `FULLAUTH_BACKEND=memory` to keep everything in-memory despite the URL.
|
|
279
281
|
|
|
282
|
+
### Two-factor authentication
|
|
283
|
+
|
|
284
|
+
| Option | Type | Default | Description |
|
|
285
|
+
|--------|------|---------|-------------|
|
|
286
|
+
| `MFA_ENABLED` | `bool` | `False` | Mount the MFA routes and ask for a code at sign-in. Inferred as `True` when `MFA_ISSUER` is set. |
|
|
287
|
+
| `MFA_ISSUER` | `str \| None` | `None` | The name an authenticator app shows beside the code. Required when enabled. |
|
|
288
|
+
| `MFA_PENDING_EXPIRE_SECONDS` | `int` | `300` | How long the token handed out after a correct password stays usable. |
|
|
289
|
+
|
|
280
290
|
### Passkeys
|
|
281
291
|
|
|
282
292
|
| Option | Type | Default | Description |
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Two-Factor Authentication (TOTP)
|
|
2
|
+
|
|
3
|
+
A second factor on top of the password: the six-digit codes an authenticator app
|
|
4
|
+
such as Google Authenticator, 1Password or Aegis produces. Off by default.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install "fastapi-fullauth[mfa]"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Configuration
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
config = FullAuthConfig(
|
|
16
|
+
SECRET_KEY="...",
|
|
17
|
+
MFA_ISSUER="Acme", # the name shown beside the code in the app
|
|
18
|
+
)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Setting `MFA_ISSUER` is taken as opting in, so `MFA_ENABLED` becomes `True` on its
|
|
22
|
+
own. Set `MFA_ENABLED=False` explicitly to configure it while keeping the routes
|
|
23
|
+
off.
|
|
24
|
+
|
|
25
|
+
Your adapter needs the MFA model, exactly like passkeys:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from fastapi_fullauth.models.sqlmodel import MFAMixin
|
|
29
|
+
|
|
30
|
+
class MFA(MFAMixin, table=True):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
adapter = SQLModelAdapter(session_maker, user_model=User, refresh_token_model=RefreshToken, mfa_model=MFA)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Without it the routes are not mounted, sign-in never asks for a code, and a
|
|
37
|
+
warning says so at startup.
|
|
38
|
+
|
|
39
|
+
## Routes
|
|
40
|
+
|
|
41
|
+
| Method | Path | Auth | Description |
|
|
42
|
+
|--------|------|------|-------------|
|
|
43
|
+
| `GET` | `/auth/mfa` | session | Whether it is on, and how many recovery codes remain |
|
|
44
|
+
| `POST` | `/auth/mfa/enroll` | session + recent | Start enrolment; returns the secret and provisioning URI |
|
|
45
|
+
| `POST` | `/auth/mfa/confirm` | session | Finish enrolment with a code; returns the recovery codes |
|
|
46
|
+
| `DELETE` | `/auth/mfa` | session + recent | Turn it off |
|
|
47
|
+
| `POST` | `/auth/mfa/verify` | pending token | Exchange a code for the real token pair |
|
|
48
|
+
|
|
49
|
+
"Recent" means the same proof `DELETE /me` asks for: the current password in the
|
|
50
|
+
body, or a session whose credentials were checked within `REAUTH_MAX_AGE_SECONDS`.
|
|
51
|
+
Adding and removing a second factor are account-security actions.
|
|
52
|
+
|
|
53
|
+
## Enrolling
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
POST /api/v1/auth/mfa/enroll
|
|
57
|
+
Authorization: Bearer <access token>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"secret": "JBSWY3DPEHPK3PXP",
|
|
63
|
+
"provisioning_uri": "otpauth://totp/Acme:user@example.com?secret=...&issuer=Acme"
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Render the URI as a QR code, or show the secret for manual entry. Nothing is live
|
|
68
|
+
yet: the secret does nothing until a code proves the authenticator was set up,
|
|
69
|
+
so an abandoned enrolment cannot lock anyone out. Starting again simply replaces
|
|
70
|
+
it.
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
POST /api/v1/auth/mfa/confirm
|
|
74
|
+
{"code": "123456"}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{"recovery_codes": ["a1b2-c3d4-e5f6", "..."]}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
!!! warning "Show the recovery codes once"
|
|
82
|
+
They are stored hashed and cannot be shown again. Each one works instead of a
|
|
83
|
+
code, one time, for a user who has lost their authenticator. Tell the user to
|
|
84
|
+
save them before they close the page.
|
|
85
|
+
|
|
86
|
+
## Signing in
|
|
87
|
+
|
|
88
|
+
A correct password on an account with a second factor answers `202` instead of
|
|
89
|
+
`200`, with a short-lived token rather than a session:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{"mfa_required": true, "mfa_token": "eyJhbGciOi..."}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
That token buys the right to submit a code and nothing else: it is rejected
|
|
96
|
+
everywhere a session token is expected. Exchange it:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
POST /api/v1/auth/mfa/verify
|
|
100
|
+
{"mfa_token": "eyJhbGciOi...", "code": "123456"}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The response is an ordinary login response, with the access and refresh tokens.
|
|
104
|
+
A recovery code goes in the same `code` field.
|
|
105
|
+
|
|
106
|
+
### Client changes
|
|
107
|
+
|
|
108
|
+
```javascript
|
|
109
|
+
const r = await fetch("/api/v1/auth/login", { method: "POST", body: ... });
|
|
110
|
+
const data = await r.json();
|
|
111
|
+
|
|
112
|
+
if (r.status === 202) {
|
|
113
|
+
const code = await promptForCode();
|
|
114
|
+
const verified = await fetch("/api/v1/auth/mfa/verify", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: {"Content-Type": "application/json"},
|
|
117
|
+
body: JSON.stringify({ mfa_token: data.mfa_token, code }),
|
|
118
|
+
});
|
|
119
|
+
return verified.json();
|
|
120
|
+
}
|
|
121
|
+
return data;
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## What is defended against
|
|
125
|
+
|
|
126
|
+
- **A code used twice.** A code stays valid for its whole 30-second step, so
|
|
127
|
+
anyone who reads it over a shoulder has that long to use it. Accepting one
|
|
128
|
+
records its time step, and nothing at or below that step is accepted again.
|
|
129
|
+
One consequence worth knowing: the code that confirms enrolment is spent, so
|
|
130
|
+
signing in needs the next one.
|
|
131
|
+
- **Guessing.** Six digits is a small space. `/mfa/verify` has its own rate-limit
|
|
132
|
+
bucket (`AUTH_RATE_LIMITS.mfa`, 5 a minute) and failures are counted against
|
|
133
|
+
the account through the lockout manager, so rotating addresses does not help.
|
|
134
|
+
- **Clock drift.** One step either side of now is accepted, as RFC 6238
|
|
135
|
+
recommends. Wider would mean more codes valid at once.
|
|
136
|
+
- **A pending token reused.** It is burned once it buys a session.
|
|
137
|
+
- **Recovery codes.** Stored hashed, single use.
|
|
138
|
+
|
|
139
|
+
## What it does not cover
|
|
140
|
+
|
|
141
|
+
**MFA gates password sign-in only.** Passkeys are already phishing-resistant, and
|
|
142
|
+
an OAuth provider runs its own second factor which this library cannot inspect.
|
|
143
|
+
An account with a passkey or a linked provider can still sign in that way without
|
|
144
|
+
a code, by design.
|
|
145
|
+
|
|
146
|
+
The TOTP secret is stored in plaintext, as the OAuth provider tokens are. A
|
|
147
|
+
database leak hands over the second factor along with everything else; see the
|
|
148
|
+
[threat model](security/threat-model.md).
|
|
149
|
+
|
|
150
|
+
## Event hooks
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
@fullauth.hooks.on("after_mfa_enabled")
|
|
154
|
+
async def on_enabled(user):
|
|
155
|
+
await audit.record(user.id, "two-factor enabled")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@fullauth.hooks.on("after_mfa_disabled")
|
|
159
|
+
async def on_disabled(user):
|
|
160
|
+
await notify(user.email, "Two-factor authentication was turned off")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Both are worth sending mail about: turning a second factor off is exactly what an
|
|
164
|
+
attacker who has taken an account does first.
|
|
165
|
+
|
|
166
|
+
## If a user is locked out
|
|
167
|
+
|
|
168
|
+
A recovery code is the intended path. Failing that, an operator can clear the
|
|
169
|
+
factor directly, which returns the account to password-only sign-in:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
await fullauth.adapter.delete_mfa(user_id)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
There is deliberately no route for this: it would be a way around the second
|
|
176
|
+
factor.
|
|
@@ -16,7 +16,7 @@ The good news first: **password hashes carry over unchanged, and so does your us
|
|
|
16
16
|
| Lockout, rate limiting, CSRF, security headers | none | built in |
|
|
17
17
|
| User admin CRUD routes | `GET/PATCH/DELETE /{id}` | **not included**; write your own with `require_role`, or call the adapter |
|
|
18
18
|
| Link an OAuth account while signed in | `get_oauth_associate_router` | `/oauth/{provider}/link/authorize` and `/link/callback`; sign-in linking on a provider-verified email is automatic on top |
|
|
19
|
-
| MFA / TOTP | none |
|
|
19
|
+
| MFA / TOTP | none | TOTP with recovery codes, [see the guide](mfa.md) |
|
|
20
20
|
|
|
21
21
|
Read the [threat model](security/threat-model.md) before you commit: it states plainly what the library defends against and what stays your responsibility.
|
|
22
22
|
|
|
@@ -52,6 +52,18 @@ the current password, or credentials checked within `REAUTH_MAX_AGE_SECONDS`
|
|
|
52
52
|
credentials are checked and carried unchanged through refresh rotation, so
|
|
53
53
|
refreshing a stolen token does not renew it.
|
|
54
54
|
|
|
55
|
+
## Separating services that share a secret
|
|
56
|
+
|
|
57
|
+
Without `JWT_ISSUER` and `JWT_AUDIENCE`, any service holding the same `SECRET_KEY`
|
|
58
|
+
accepts tokens minted by any other: an access token for the admin tool works
|
|
59
|
+
against the public API. Set them per service and each one accepts only its own
|
|
60
|
+
tokens, password-reset links included.
|
|
61
|
+
|
|
62
|
+
They are off by default, and an unset `JWT_AUDIENCE` means the claim is not
|
|
63
|
+
checked, so you can turn it on at the issuing side and roll the verifying side
|
|
64
|
+
forward afterwards. Once a service requires them, tokens minted before they were
|
|
65
|
+
configured no longer validate, so expect one round of sign-outs.
|
|
66
|
+
|
|
55
67
|
## Your application's responsibilities
|
|
56
68
|
|
|
57
69
|
- **Cross-site scripting.** Any token readable by JavaScript can be stolen by XSS. Prefer the cookie backend with `CSRFMiddleware` for browser apps, and use a Content Security Policy.
|
|
@@ -63,7 +75,6 @@ refreshing a stolen token does not renew it.
|
|
|
63
75
|
|
|
64
76
|
These are tracked for future releases:
|
|
65
77
|
|
|
66
|
-
- OAuth provider access and refresh tokens are stored in plaintext.
|
|
67
|
-
- Tokens carry no `iss` or `aud` claim, so services sharing a `SECRET_KEY` accept each other's tokens.
|
|
78
|
+
- OAuth provider access and refresh tokens, and TOTP secrets, are stored in plaintext. A database leak hands over the second factor along with the rest.
|
|
68
79
|
- Pruning expired refresh tokens is available (`adapter.prune_expired_refresh_tokens()`) but has to be scheduled by the application; nothing runs it for you.
|
|
69
80
|
- A password-reset link is invalidated by a password change, but not by a later reset *request*: the newest link and any still-unused older one both work until one of them is used.
|
|
@@ -30,4 +30,4 @@ class User(UserMixin, Base):
|
|
|
30
30
|
phone: Mapped[str] = mapped_column(String(20), default="")
|
|
31
31
|
|
|
32
32
|
roles: Mapped[list[Role]] = relationship(secondary="fullauth_user_roles", lazy="selectin")
|
|
33
|
-
refresh_tokens: Mapped[list[RefreshToken]] = relationship(lazy="
|
|
33
|
+
refresh_tokens: Mapped[list[RefreshToken]] = relationship(lazy="raise", passive_deletes=True)
|
|
@@ -20,6 +20,7 @@ from fastapi_fullauth.adapters.base import (
|
|
|
20
20
|
PRIVILEGED_USER_FIELDS,
|
|
21
21
|
AbstractUserAdapter,
|
|
22
22
|
AdapterFeature,
|
|
23
|
+
MFAAdapterMixin,
|
|
23
24
|
OAuthAdapterMixin,
|
|
24
25
|
PasskeyAdapterMixin,
|
|
25
26
|
PermissionAdapterMixin,
|
|
@@ -33,6 +34,7 @@ __all__ = [
|
|
|
33
34
|
"AbstractUserAdapter",
|
|
34
35
|
"AdapterFeature",
|
|
35
36
|
"OAuthAdapterMixin",
|
|
37
|
+
"MFAAdapterMixin",
|
|
36
38
|
"PasskeyAdapterMixin",
|
|
37
39
|
"PermissionAdapterMixin",
|
|
38
40
|
"RoleAdapterMixin",
|
{fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/_sqlalchemy_base.py
RENAMED
|
@@ -24,6 +24,7 @@ from sqlalchemy.orm import selectinload
|
|
|
24
24
|
from fastapi_fullauth.adapters.base import (
|
|
25
25
|
AbstractUserAdapter,
|
|
26
26
|
AdapterFeature,
|
|
27
|
+
MFAAdapterMixin,
|
|
27
28
|
OAuthAdapterMixin,
|
|
28
29
|
PasskeyAdapterMixin,
|
|
29
30
|
PermissionAdapterMixin,
|
|
@@ -34,6 +35,7 @@ from fastapi_fullauth.adapters.base import (
|
|
|
34
35
|
from fastapi_fullauth.exceptions import UserAlreadyExistsError
|
|
35
36
|
from fastapi_fullauth.types import (
|
|
36
37
|
CreateUserSchemaType,
|
|
38
|
+
MFACredential,
|
|
37
39
|
OAuthAccount,
|
|
38
40
|
PasskeyCredential,
|
|
39
41
|
RefreshToken,
|
|
@@ -67,6 +69,7 @@ class _BaseSQLAlchemyAdapter(
|
|
|
67
69
|
OAuthAdapterMixin,
|
|
68
70
|
PasskeyAdapterMixin,
|
|
69
71
|
SessionAdapterMixin,
|
|
72
|
+
MFAAdapterMixin,
|
|
70
73
|
):
|
|
71
74
|
"""Concrete async adapter logic shared by the SQLAlchemy and SQLModel adapters.
|
|
72
75
|
|
|
@@ -92,6 +95,7 @@ class _BaseSQLAlchemyAdapter(
|
|
|
92
95
|
role_permission_model: Any = None,
|
|
93
96
|
oauth_account_model: Any = None,
|
|
94
97
|
passkey_model: Any = None,
|
|
98
|
+
mfa_model: Any = None,
|
|
95
99
|
user_schema: type[UserSchemaType],
|
|
96
100
|
create_user_schema: type[CreateUserSchemaType],
|
|
97
101
|
) -> None:
|
|
@@ -115,6 +119,7 @@ class _BaseSQLAlchemyAdapter(
|
|
|
115
119
|
self._role_permission_model = role_permission_model
|
|
116
120
|
self._oauth_account_model = oauth_account_model
|
|
117
121
|
self._passkey_model = passkey_model
|
|
122
|
+
self._mfa_model = mfa_model
|
|
118
123
|
self._user_schema = user_schema
|
|
119
124
|
self._create_user_schema = create_user_schema
|
|
120
125
|
self.validate_user_id_type()
|
|
@@ -131,6 +136,7 @@ class _BaseSQLAlchemyAdapter(
|
|
|
131
136
|
self._user_role_model,
|
|
132
137
|
self._oauth_account_model,
|
|
133
138
|
self._passkey_model,
|
|
139
|
+
self._mfa_model,
|
|
134
140
|
)
|
|
135
141
|
types: dict[str, Any] = {}
|
|
136
142
|
for model in related:
|
|
@@ -154,6 +160,7 @@ class _BaseSQLAlchemyAdapter(
|
|
|
154
160
|
),
|
|
155
161
|
"OAuth": ("oauth_account_model",),
|
|
156
162
|
"Passkeys": ("passkey_model",),
|
|
163
|
+
"Two-factor authentication": ("mfa_model",),
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
def _require(self, model: _T | None, feature: str) -> _T:
|
|
@@ -174,6 +181,7 @@ class _BaseSQLAlchemyAdapter(
|
|
|
174
181
|
"permission": self._permission_model is not None,
|
|
175
182
|
"oauth": self._oauth_account_model is not None,
|
|
176
183
|
"passkey": self._passkey_model is not None,
|
|
184
|
+
"mfa": self._mfa_model is not None,
|
|
177
185
|
"session": True,
|
|
178
186
|
}.get(feature, False)
|
|
179
187
|
|
|
@@ -272,7 +280,9 @@ class _BaseSQLAlchemyAdapter(
|
|
|
272
280
|
cannot do that through a helper, so a caller that needs the column type
|
|
273
281
|
narrows it itself.
|
|
274
282
|
"""
|
|
275
|
-
|
|
283
|
+
# Called unbound with an untyped statement, so the stubs widen the
|
|
284
|
+
# result to Any; the runtime type is the Result this returns.
|
|
285
|
+
return cast("Result[Any]", await AsyncSession.execute(session, statement))
|
|
276
286
|
|
|
277
287
|
def _user_query(self) -> Any:
|
|
278
288
|
# Eager-load roles so _to_schema() can read user.roles outside the session
|
|
@@ -901,3 +911,107 @@ class _BaseSQLAlchemyAdapter(
|
|
|
901
911
|
if row:
|
|
902
912
|
await session.delete(row)
|
|
903
913
|
await self._commit(session)
|
|
914
|
+
|
|
915
|
+
# ── Two-factor authentication ────────────────────────────────────
|
|
916
|
+
|
|
917
|
+
def _to_mfa(self, row: Any) -> MFACredential:
|
|
918
|
+
return MFACredential(
|
|
919
|
+
user_id=row.user_id,
|
|
920
|
+
secret=row.secret,
|
|
921
|
+
confirmed_at=row.confirmed_at,
|
|
922
|
+
last_counter=row.last_counter,
|
|
923
|
+
recovery_codes=row.recovery_codes.split(",") if row.recovery_codes else [],
|
|
924
|
+
created_at=row.created_at,
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
async def get_mfa(self, user_id: UserID) -> MFACredential | None:
|
|
928
|
+
mfa_model = self._require(self._mfa_model, "Two-factor authentication")
|
|
929
|
+
async with self._begin() as session:
|
|
930
|
+
result = await self._execute(
|
|
931
|
+
session, select(mfa_model).where(mfa_model.user_id == user_id)
|
|
932
|
+
)
|
|
933
|
+
row = result.scalars().first()
|
|
934
|
+
return self._to_mfa(row) if row else None
|
|
935
|
+
|
|
936
|
+
async def store_mfa(self, data: MFACredential) -> MFACredential:
|
|
937
|
+
mfa_model = self._require(self._mfa_model, "Two-factor authentication")
|
|
938
|
+
async with self._begin() as session:
|
|
939
|
+
# Replace rather than conflict: restarting an abandoned enrolment is
|
|
940
|
+
# normal. A confirmed row is protected by the flow, not here.
|
|
941
|
+
await self._execute(session, delete(mfa_model).where(mfa_model.user_id == data.user_id))
|
|
942
|
+
session.add(
|
|
943
|
+
mfa_model(
|
|
944
|
+
user_id=data.user_id,
|
|
945
|
+
secret=data.secret,
|
|
946
|
+
confirmed_at=data.confirmed_at,
|
|
947
|
+
last_counter=data.last_counter,
|
|
948
|
+
recovery_codes=",".join(data.recovery_codes),
|
|
949
|
+
)
|
|
950
|
+
)
|
|
951
|
+
await self._commit(session)
|
|
952
|
+
return data
|
|
953
|
+
|
|
954
|
+
async def confirm_mfa(self, user_id: UserID, recovery_codes: list[str]) -> None:
|
|
955
|
+
mfa_model = self._require(self._mfa_model, "Two-factor authentication")
|
|
956
|
+
async with self._begin() as session:
|
|
957
|
+
await self._execute(
|
|
958
|
+
session,
|
|
959
|
+
update(mfa_model)
|
|
960
|
+
.where(mfa_model.user_id == user_id)
|
|
961
|
+
.values(
|
|
962
|
+
confirmed_at=datetime.now(timezone.utc),
|
|
963
|
+
recovery_codes=",".join(recovery_codes),
|
|
964
|
+
),
|
|
965
|
+
)
|
|
966
|
+
await self._commit(session)
|
|
967
|
+
|
|
968
|
+
async def delete_mfa(self, user_id: UserID) -> None:
|
|
969
|
+
mfa_model = self._require(self._mfa_model, "Two-factor authentication")
|
|
970
|
+
async with self._begin() as session:
|
|
971
|
+
await self._execute(session, delete(mfa_model).where(mfa_model.user_id == user_id))
|
|
972
|
+
await self._commit(session)
|
|
973
|
+
|
|
974
|
+
async def advance_mfa_counter(self, user_id: UserID, counter: int) -> bool:
|
|
975
|
+
mfa_model = self._require(self._mfa_model, "Two-factor authentication")
|
|
976
|
+
async with self._begin() as session:
|
|
977
|
+
result = cast(
|
|
978
|
+
"CursorResult[Any]",
|
|
979
|
+
await self._execute(
|
|
980
|
+
session,
|
|
981
|
+
update(mfa_model)
|
|
982
|
+
.where(mfa_model.user_id == user_id)
|
|
983
|
+
.where(mfa_model.last_counter < counter)
|
|
984
|
+
.values(last_counter=counter),
|
|
985
|
+
),
|
|
986
|
+
)
|
|
987
|
+
await self._commit(session)
|
|
988
|
+
return result.rowcount == 1
|
|
989
|
+
|
|
990
|
+
async def consume_recovery_code(self, user_id: UserID, code_hash: str) -> bool:
|
|
991
|
+
mfa_model = self._require(self._mfa_model, "Two-factor authentication")
|
|
992
|
+
async with self._begin() as session:
|
|
993
|
+
result = await self._execute(
|
|
994
|
+
session, select(mfa_model).where(mfa_model.user_id == user_id)
|
|
995
|
+
)
|
|
996
|
+
row = result.scalars().first()
|
|
997
|
+
if row is None or not row.recovery_codes:
|
|
998
|
+
return False
|
|
999
|
+
codes = row.recovery_codes.split(",")
|
|
1000
|
+
if code_hash not in codes:
|
|
1001
|
+
return False
|
|
1002
|
+
codes.remove(code_hash)
|
|
1003
|
+
|
|
1004
|
+
# Conditional on the value we read, so two callers redeeming the same
|
|
1005
|
+
# code cannot both win: the loser's WHERE no longer matches.
|
|
1006
|
+
updated = cast(
|
|
1007
|
+
"CursorResult[Any]",
|
|
1008
|
+
await self._execute(
|
|
1009
|
+
session,
|
|
1010
|
+
update(mfa_model)
|
|
1011
|
+
.where(mfa_model.user_id == user_id)
|
|
1012
|
+
.where(mfa_model.recovery_codes == row.recovery_codes)
|
|
1013
|
+
.values(recovery_codes=",".join(codes)),
|
|
1014
|
+
),
|
|
1015
|
+
)
|
|
1016
|
+
await self._commit(session)
|
|
1017
|
+
return updated.rowcount == 1
|
|
@@ -11,6 +11,7 @@ from pydantic import BaseModel, TypeAdapter
|
|
|
11
11
|
|
|
12
12
|
from fastapi_fullauth.types import (
|
|
13
13
|
CreateUserSchemaType,
|
|
14
|
+
MFACredential,
|
|
14
15
|
OAuthAccount,
|
|
15
16
|
PasskeyCredential,
|
|
16
17
|
RefreshToken,
|
|
@@ -19,7 +20,7 @@ from fastapi_fullauth.types import (
|
|
|
19
20
|
UserSchemaType,
|
|
20
21
|
)
|
|
21
22
|
|
|
22
|
-
AdapterFeature = Literal["role", "permission", "oauth", "passkey", "session"]
|
|
23
|
+
AdapterFeature = Literal["role", "permission", "oauth", "passkey", "session", "mfa"]
|
|
23
24
|
|
|
24
25
|
# Columns only the library may set when creating a user. A CreateUserSchema that
|
|
25
26
|
# exposes them would otherwise let a registering client grant itself admin,
|
|
@@ -400,6 +401,46 @@ class PasskeyAdapterMixin(ABC):
|
|
|
400
401
|
async def delete_passkey(self, passkey_id: UUID) -> None: ...
|
|
401
402
|
|
|
402
403
|
|
|
404
|
+
class MFAAdapterMixin(ABC):
|
|
405
|
+
"""Mixin for TOTP second factors. Inherit alongside AbstractUserAdapter."""
|
|
406
|
+
|
|
407
|
+
@abstractmethod
|
|
408
|
+
async def get_mfa(self, user_id: UserID) -> MFACredential | None: ...
|
|
409
|
+
|
|
410
|
+
@abstractmethod
|
|
411
|
+
async def store_mfa(self, data: MFACredential) -> MFACredential:
|
|
412
|
+
"""Create or replace the user's credential.
|
|
413
|
+
|
|
414
|
+
Replacing is how a restarted enrolment works, so this must overwrite an
|
|
415
|
+
unconfirmed row rather than conflict with it.
|
|
416
|
+
"""
|
|
417
|
+
...
|
|
418
|
+
|
|
419
|
+
@abstractmethod
|
|
420
|
+
async def confirm_mfa(self, user_id: UserID, recovery_codes: list[str]) -> None:
|
|
421
|
+
"""Mark the credential live and store the hashed recovery codes."""
|
|
422
|
+
...
|
|
423
|
+
|
|
424
|
+
@abstractmethod
|
|
425
|
+
async def delete_mfa(self, user_id: UserID) -> None: ...
|
|
426
|
+
|
|
427
|
+
@abstractmethod
|
|
428
|
+
async def advance_mfa_counter(self, user_id: UserID, counter: int) -> bool:
|
|
429
|
+
"""Atomically move the accepted time step forward.
|
|
430
|
+
|
|
431
|
+
Must be a compare-and-swap: return True only when this call moved
|
|
432
|
+
``last_counter`` to a strictly greater value. A code stays valid for its
|
|
433
|
+
whole time step, so returning False is what stops the same code being
|
|
434
|
+
replayed, exactly as the passkey sign-count does.
|
|
435
|
+
"""
|
|
436
|
+
...
|
|
437
|
+
|
|
438
|
+
@abstractmethod
|
|
439
|
+
async def consume_recovery_code(self, user_id: UserID, code_hash: str) -> bool:
|
|
440
|
+
"""Atomically remove one hashed recovery code. True if this call removed it."""
|
|
441
|
+
...
|
|
442
|
+
|
|
443
|
+
|
|
403
444
|
# Maps the feature names used by supports_feature() to the mixin that implements
|
|
404
445
|
# them. Defined after the mixins so the references resolve.
|
|
405
446
|
_FEATURE_MIXINS: dict[str, type] = {
|
|
@@ -408,4 +449,5 @@ _FEATURE_MIXINS: dict[str, type] = {
|
|
|
408
449
|
"oauth": OAuthAdapterMixin,
|
|
409
450
|
"passkey": PasskeyAdapterMixin,
|
|
410
451
|
"session": SessionAdapterMixin,
|
|
452
|
+
"mfa": MFAAdapterMixin,
|
|
411
453
|
}
|