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.
Files changed (218) hide show
  1. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/CHANGELOG.md +22 -0
  2. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/PKG-INFO +9 -1
  3. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/sqlalchemy.md +10 -1
  4. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/hooks.md +2 -0
  5. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/configuration.md +10 -0
  6. fastapi_fullauth-0.18.0/docs/mfa.md +176 -0
  7. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/migrating-from-fastapi-users.md +1 -1
  8. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/security/threat-model.md +13 -2
  9. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/models.py +1 -1
  10. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/__init__.py +1 -1
  11. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/__init__.py +2 -0
  12. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/_sqlalchemy_base.py +115 -1
  13. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/base.py +43 -1
  14. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/beanie.py +77 -0
  15. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/sqlalchemy.py +3 -0
  16. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/sqlmodel.py +3 -0
  17. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/adapters/tortoise.py +74 -0
  18. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/config.py +28 -0
  19. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/tokens.py +31 -1
  20. fastapi_fullauth-0.18.0/fastapi_fullauth/flows/mfa.py +194 -0
  21. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/fullauth.py +32 -0
  22. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/hooks.py +6 -0
  23. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/__init__.py +2 -0
  24. fastapi_fullauth-0.18.0/fastapi_fullauth/models/beanie/mfa.py +34 -0
  25. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/__init__.py +2 -0
  26. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/base.py +3 -1
  27. fastapi_fullauth-0.18.0/fastapi_fullauth/models/sqlalchemy/mfa.py +40 -0
  28. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/__init__.py +2 -0
  29. fastapi_fullauth-0.18.0/fastapi_fullauth/models/sqlmodel/mfa.py +39 -0
  30. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/__init__.py +2 -0
  31. fastapi_fullauth-0.18.0/fastapi_fullauth/models/tortoise/mfa.py +36 -0
  32. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/_schemas.py +4 -1
  33. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/auth.py +21 -3
  34. fastapi_fullauth-0.18.0/fastapi_fullauth/routers/mfa.py +275 -0
  35. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/types.py +25 -1
  36. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/mkdocs.yml +2 -0
  37. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/pyproject.toml +7 -5
  38. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/SKILL.md +1 -1
  39. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/adapters.md +3 -1
  40. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/api-reference.md +10 -0
  41. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/adapter_conformance.py +67 -0
  42. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/conftest.py +6 -0
  43. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_beanie_adapter.py +7 -1
  44. fastapi_fullauth-0.18.0/tests/test_mfa.py +375 -0
  45. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_security.py +4 -1
  46. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_sqlalchemy_adapter.py +7 -1
  47. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_tokens.py +110 -0
  48. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_tortoise_adapter.py +7 -0
  49. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_user_id_types.py +12 -0
  50. fastapi_fullauth-0.18.0/uv.lock +3304 -0
  51. fastapi_fullauth-0.17.2/uv.lock +0 -2772
  52. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  53. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  54. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/pull_request_template.md +0 -0
  55. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/workflows/ci.yml +0 -0
  56. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/workflows/docs.yml +0 -0
  57. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.github/workflows/publish.yml +0 -0
  58. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.gitignore +0 -0
  59. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/.python-version +0 -0
  60. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/AGENTS.md +0 -0
  61. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/CONTRIBUTING.md +0 -0
  62. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/LICENSE +0 -0
  63. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/Makefile +0 -0
  64. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/README.md +0 -0
  65. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/SECURITY.md +0 -0
  66. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/beanie.md +0 -0
  67. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/custom.md +0 -0
  68. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/index.md +0 -0
  69. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/sqlmodel.md +0 -0
  70. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/adapters/tortoise.md +0 -0
  71. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/api-reference.md +0 -0
  72. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/architecture.md +0 -0
  73. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/custom-claims.md +0 -0
  74. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/dependencies.md +0 -0
  75. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/passwords.md +0 -0
  76. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/auth/sessions.md +0 -0
  77. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/contributing.md +0 -0
  78. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/customization.md +0 -0
  79. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/frontend-integration.md +0 -0
  80. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/getting-started.md +0 -0
  81. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/index.md +0 -0
  82. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/migrations.md +0 -0
  83. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/oauth.md +0 -0
  84. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/passkeys.md +0 -0
  85. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/recipes.md +0 -0
  86. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/security/middleware.md +0 -0
  87. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/security/rate-limiting.md +0 -0
  88. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/stylesheets/home.css +0 -0
  89. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/testing.md +0 -0
  90. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/docs/troubleshooting.md +0 -0
  91. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/__init__.py +0 -0
  92. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/auth.py +0 -0
  93. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/config.py +0 -0
  94. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/main.py +0 -0
  95. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/models.py +0 -0
  96. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/beanie_app/routes.py +0 -0
  97. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/__init__.py +0 -0
  98. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/auth.py +0 -0
  99. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/config.py +0 -0
  100. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/main.py +0 -0
  101. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlalchemy_app/routes.py +0 -0
  102. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/__init__.py +0 -0
  103. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/auth.py +0 -0
  104. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/config.py +0 -0
  105. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/main.py +0 -0
  106. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/models.py +0 -0
  107. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/sqlmodel_app/routes.py +0 -0
  108. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/__init__.py +0 -0
  109. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/auth.py +0 -0
  110. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/config.py +0 -0
  111. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/main.py +0 -0
  112. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/models.py +0 -0
  113. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/examples/tortoise_app/routes.py +0 -0
  114. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/backends/__init__.py +0 -0
  115. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/backends/base.py +0 -0
  116. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/backends/bearer.py +0 -0
  117. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/backends/cookie.py +0 -0
  118. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/cli.py +0 -0
  119. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/__init__.py +0 -0
  120. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/_redis.py +0 -0
  121. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/blacklist.py +0 -0
  122. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/core/crypto.py +0 -0
  123. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/dependencies/__init__.py +0 -0
  124. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/dependencies/current_user.py +0 -0
  125. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/dependencies/rbac.py +0 -0
  126. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/exceptions.py +0 -0
  127. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/__init__.py +0 -0
  128. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/change_password.py +0 -0
  129. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/credentials.py +0 -0
  130. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/email_verify.py +0 -0
  131. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/login.py +0 -0
  132. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/logout.py +0 -0
  133. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/oauth.py +0 -0
  134. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/passkey.py +0 -0
  135. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/password_reset.py +0 -0
  136. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/profile.py +0 -0
  137. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/reauth.py +0 -0
  138. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/refresh.py +0 -0
  139. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/register.py +0 -0
  140. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/sessions.py +0 -0
  141. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/flows/tokens.py +0 -0
  142. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/middleware/__init__.py +0 -0
  143. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/middleware/csrf.py +0 -0
  144. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/middleware/ratelimit.py +0 -0
  145. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/middleware/security_headers.py +0 -0
  146. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/__init__.py +0 -0
  147. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/_common.py +0 -0
  148. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/base.py +0 -0
  149. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/oauth.py +0 -0
  150. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/passkey.py +0 -0
  151. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/permission.py +0 -0
  152. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/beanie/role.py +0 -0
  153. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/oauth.py +0 -0
  154. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/passkey.py +0 -0
  155. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/permission.py +0 -0
  156. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlalchemy/role.py +0 -0
  157. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/base.py +0 -0
  158. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/oauth.py +0 -0
  159. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/passkey.py +0 -0
  160. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/permission.py +0 -0
  161. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/sqlmodel/role.py +0 -0
  162. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/_common.py +0 -0
  163. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/base.py +0 -0
  164. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/oauth.py +0 -0
  165. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/passkey.py +0 -0
  166. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/permission.py +0 -0
  167. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/models/tortoise/role.py +0 -0
  168. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/__init__.py +0 -0
  169. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/base.py +0 -0
  170. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/discord.py +0 -0
  171. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/github.py +0 -0
  172. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/gitlab.py +0 -0
  173. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/oauth/google.py +0 -0
  174. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/protection/__init__.py +0 -0
  175. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/protection/challenges.py +0 -0
  176. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/protection/lockout.py +0 -0
  177. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/protection/ratelimit.py +0 -0
  178. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/py.typed +0 -0
  179. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/__init__.py +0 -0
  180. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/_transport.py +0 -0
  181. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/admin.py +0 -0
  182. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/oauth.py +0 -0
  183. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/passkey.py +0 -0
  184. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/profile.py +0 -0
  185. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/sessions.py +0 -0
  186. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/routers/verify.py +0 -0
  187. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/utils.py +0 -0
  188. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/fastapi_fullauth/validators.py +0 -0
  189. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/scripts/llmstxt_preprocess.py +0 -0
  190. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/composable-design.md +0 -0
  191. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/getting-started.md +0 -0
  192. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/hooks.md +0 -0
  193. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/migrations.md +0 -0
  194. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/oauth.md +0 -0
  195. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/passkeys.md +0 -0
  196. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/production.md +0 -0
  197. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/rbac.md +0 -0
  198. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/sessions.md +0 -0
  199. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/testing.md +0 -0
  200. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/skills/fastapi-fullauth/references/troubleshooting.md +0 -0
  201. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/__init__.py +0 -0
  202. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_account_safety.py +0 -0
  203. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_auth.py +0 -0
  204. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_cli.py +0 -0
  205. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_config.py +0 -0
  206. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_cookie_backend.py +0 -0
  207. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_crypto.py +0 -0
  208. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_hooks.py +0 -0
  209. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_oauth.py +0 -0
  210. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_passkey.py +0 -0
  211. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_passkey_flows.py +0 -0
  212. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_polish.py +0 -0
  213. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_profile.py +0 -0
  214. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_rbac.py +0 -0
  215. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_session_revocation.py +0 -0
  216. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_sessions.py +0 -0
  217. {fastapi_fullauth-0.17.2 → fastapi_fullauth-0.18.0}/tests/test_sqlmodel_adapter.py +0 -0
  218. {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.17.2
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(lazy="noload")
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 | 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="noload")
33
+ refresh_tokens: Mapped[list[RefreshToken]] = relationship(lazy="raise", passive_deletes=True)
@@ -1,4 +1,4 @@
1
- __version__ = "0.17.2"
1
+ __version__ = "0.18.0"
2
2
 
3
3
  from fastapi_fullauth.config import AuthRateLimits, FullAuthConfig
4
4
  from fastapi_fullauth.fullauth import FullAuth
@@ -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",
@@ -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
- return await AsyncSession.execute(session, statement)
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
  }