regstack 0.8.0__tar.gz → 0.8.1__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 (187) hide show
  1. {regstack-0.8.0 → regstack-0.8.1}/PKG-INFO +2 -2
  2. {regstack-0.8.0 → regstack-0.8.1}/README.md +1 -1
  3. {regstack-0.8.0 → regstack-0.8.1}/pyproject.toml +1 -1
  4. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/lockout.py +13 -0
  5. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/rate_limit.py +3 -0
  6. regstack-0.8.1/src/regstack/cli/_paths.py +84 -0
  7. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/admin.py +9 -4
  8. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/doctor.py +13 -5
  9. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/init.py +57 -15
  10. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/migrate.py +14 -5
  11. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/cli.py +3 -1
  12. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/config/schema.py +6 -4
  13. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/composer.py +4 -1
  14. regstack-0.8.1/src/regstack/email/templates/sms_phone_setup.txt +1 -0
  15. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/verification.html +1 -1
  16. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/verification.txt +1 -1
  17. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/hooks/events.py +1 -0
  18. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/account.py +4 -2
  19. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/admin.py +6 -1
  20. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/login.py +24 -7
  21. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/password.py +18 -3
  22. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/phone.py +6 -1
  23. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/register.py +1 -0
  24. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/verify.py +3 -2
  25. regstack-0.8.1/src/regstack/ui/templates/auth/_token_handoff.html +11 -0
  26. regstack-0.8.1/src/regstack/ui/templates/auth/email_change_confirm.html +6 -0
  27. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/reset.html +1 -0
  28. regstack-0.8.1/src/regstack/ui/templates/auth/verify.html +6 -0
  29. regstack-0.8.1/src/regstack/version.py +1 -0
  30. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/cli.py +58 -14
  31. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/writer.py +23 -7
  32. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/cli.py +60 -16
  33. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/writer.py +16 -6
  34. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/cli.py +64 -12
  35. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/writer.py +8 -3
  36. regstack-0.8.0/src/regstack/email/templates/sms_phone_setup.txt +0 -1
  37. regstack-0.8.0/src/regstack/ui/templates/auth/email_change_confirm.html +0 -10
  38. regstack-0.8.0/src/regstack/ui/templates/auth/verify.html +0 -10
  39. regstack-0.8.0/src/regstack/version.py +0 -1
  40. {regstack-0.8.0 → regstack-0.8.1}/.gitignore +0 -0
  41. {regstack-0.8.0 → regstack-0.8.1}/CHANGELOG.md +0 -0
  42. {regstack-0.8.0 → regstack-0.8.1}/LICENSE +0 -0
  43. {regstack-0.8.0 → regstack-0.8.1}/NOTICE +0 -0
  44. {regstack-0.8.0 → regstack-0.8.1}/SECURITY.md +0 -0
  45. {regstack-0.8.0 → regstack-0.8.1}/regstack.toml.example +0 -0
  46. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/__init__.py +0 -0
  47. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/app.py +0 -0
  48. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/__init__.py +0 -0
  49. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/clock.py +0 -0
  50. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/dependencies.py +0 -0
  51. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/jwt.py +0 -0
  52. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/mfa.py +0 -0
  53. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/password.py +0 -0
  54. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/tokens.py +0 -0
  55. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/__init__.py +0 -0
  56. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/base.py +0 -0
  57. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/factory.py +0 -0
  58. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/__init__.py +0 -0
  59. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/backend.py +0 -0
  60. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/client.py +0 -0
  61. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/indexes.py +0 -0
  62. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
  63. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
  64. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
  65. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
  66. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
  67. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +0 -0
  68. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
  69. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
  70. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/protocols.py +0 -0
  71. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/__init__.py +0 -0
  72. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/backend.py +0 -0
  73. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/__init__.py +0 -0
  74. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/env.py +0 -0
  75. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
  76. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
  77. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +0 -0
  78. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/__init__.py +0 -0
  79. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
  80. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
  81. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
  82. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
  83. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/oauth_state_repo.py +0 -0
  84. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
  85. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
  86. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/schema.py +0 -0
  87. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/types.py +0 -0
  88. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/__init__.py +0 -0
  89. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/__main__.py +0 -0
  90. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/_results.py +0 -0
  91. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/_runtime.py +0 -0
  92. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/__init__.py +0 -0
  93. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/capture.py +0 -0
  94. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/http.py +0 -0
  95. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/logtail.py +0 -0
  96. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/__init__.py +0 -0
  97. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/account.py +0 -0
  98. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/cleanup.py +0 -0
  99. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/core_auth.py +0 -0
  100. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/feature_discover.py +0 -0
  101. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/oauth.py +0 -0
  102. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/password_reset.py +0 -0
  103. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/reachability.py +0 -0
  104. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/sms_mfa.py +0 -0
  105. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/report.py +0 -0
  106. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/runner.py +0 -0
  107. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/config/__init__.py +0 -0
  108. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/config/loader.py +0 -0
  109. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/config/secrets.py +0 -0
  110. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/__init__.py +0 -0
  111. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/base.py +0 -0
  112. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/console.py +0 -0
  113. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/factory.py +0 -0
  114. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/ses.py +0 -0
  115. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/smtp.py +0 -0
  116. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/email_change.html +0 -0
  117. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/email_change.subject.txt +0 -0
  118. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/email_change.txt +0 -0
  119. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/password_reset.html +0 -0
  120. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/password_reset.subject.txt +0 -0
  121. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/password_reset.txt +0 -0
  122. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
  123. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/verification.subject.txt +0 -0
  124. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/hooks/__init__.py +0 -0
  125. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/__init__.py +0 -0
  126. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/_objectid.py +0 -0
  127. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/login_attempt.py +0 -0
  128. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/mfa_code.py +0 -0
  129. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/oauth_identity.py +0 -0
  130. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/oauth_state.py +0 -0
  131. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/pending_registration.py +0 -0
  132. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/user.py +0 -0
  133. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/__init__.py +0 -0
  134. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/base.py +0 -0
  135. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/errors.py +0 -0
  136. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/providers/__init__.py +0 -0
  137. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/providers/google.py +0 -0
  138. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/registry.py +0 -0
  139. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/__init__.py +0 -0
  140. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/_helpers.py +0 -0
  141. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/_schemas.py +0 -0
  142. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/logout.py +0 -0
  143. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/oauth.py +0 -0
  144. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/__init__.py +0 -0
  145. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/base.py +0 -0
  146. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/factory.py +0 -0
  147. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/null.py +0 -0
  148. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/sns.py +0 -0
  149. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/twilio.py +0 -0
  150. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/__init__.py +0 -0
  151. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/pages.py +0 -0
  152. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/static/css/core.css +0 -0
  153. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/static/css/theme.css +0 -0
  154. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/static/js/regstack.js +0 -0
  155. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/forgot.html +0 -0
  156. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/login.html +0 -0
  157. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/me.html +0 -0
  158. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
  159. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
  160. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/register.html +0 -0
  161. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/base.html +0 -0
  162. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/__init__.py +0 -0
  163. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/__init__.py +0 -0
  164. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/routes.py +0 -0
  165. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/server.py +0 -0
  166. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
  167. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
  168. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
  169. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/validators.py +0 -0
  170. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/window.py +0 -0
  171. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/__init__.py +0 -0
  172. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/_aws.py +0 -0
  173. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/routes.py +0 -0
  174. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/server.py +0 -0
  175. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/static/wizard.css +0 -0
  176. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/static/wizard.js +0 -0
  177. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/templates/wizard.html +0 -0
  178. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/validators.py +0 -0
  179. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/window.py +0 -0
  180. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/__init__.py +0 -0
  181. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/routes.py +0 -0
  182. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/server.py +0 -0
  183. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
  184. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
  185. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/templates/designer.html +0 -0
  186. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/validators.py +0 -0
  187. {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/window.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regstack
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB.
5
5
  Project-URL: Homepage, https://github.com/jdrumgoole/regstack
6
6
  Project-URL: Repository, https://github.com/jdrumgoole/regstack
@@ -151,7 +151,7 @@ result everywhere is what regstack is for.
151
151
  ✔ Server-rendered HTML pages, theme with one CSS file
152
152
  ✔ Pluggable email (console / SMTP / Amazon SES) and SMS (Amazon SNS / Twilio)
153
153
  ✔ Argon2 password hashing, CSP-friendly templates
154
- ✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
154
+ ✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), `regstack ses setup` (guided SES configuration that validates against AWS), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
155
155
  ✔ Three storage backends: SQLite, PostgreSQL, MongoDB — chosen by URL
156
156
  ```
157
157
 
@@ -72,7 +72,7 @@ result everywhere is what regstack is for.
72
72
  ✔ Server-rendered HTML pages, theme with one CSS file
73
73
  ✔ Pluggable email (console / SMTP / Amazon SES) and SMS (Amazon SNS / Twilio)
74
74
  ✔ Argon2 password hashing, CSP-friendly templates
75
- ✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
75
+ ✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), `regstack ses setup` (guided SES configuration that validates against AWS), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
76
76
  ✔ Three storage backends: SQLite, PostgreSQL, MongoDB — chosen by URL
77
77
  ```
78
78
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regstack"
3
- version = "0.8.0"
3
+ version = "0.8.1"
4
4
  description = "Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -93,6 +93,19 @@ class LockoutService:
93
93
  )
94
94
  return LockoutDecision(locked=False, retry_after_seconds=0)
95
95
 
96
+ async def attempts_remaining(self, email: str) -> int | None:
97
+ """Return how many failures are left before lockout fires.
98
+
99
+ Used by the login route after a wrong-password 401 so we can
100
+ surface the same "N attempts remaining" message MFA shows.
101
+ Returns ``None`` when rate-limiting is disabled (tests) — the
102
+ route should then suppress the line entirely.
103
+ """
104
+ if self._config.rate_limit_disabled:
105
+ return None
106
+ count = await self._attempts.count_recent(email, window=self._window, now=self._clock.now())
107
+ return max(self._config.login_lockout_threshold - count, 0)
108
+
96
109
  async def record_failure(self, email: str, *, ip: str | None = None) -> None:
97
110
  """Record one failed login. No-op when rate limiting is disabled.
98
111
 
@@ -54,6 +54,9 @@ ROUTE_LIMIT_MAP: dict[str, str] = {
54
54
  "confirm_email_change_rate_limit": "/confirm-email-change",
55
55
  "delete_account_rate_limit": "/account",
56
56
  "oauth_exchange_rate_limit": "/oauth/exchange",
57
+ "phone_start_rate_limit": "/phone/start",
58
+ "phone_confirm_rate_limit": "/phone/confirm",
59
+ "phone_disable_rate_limit": "/phone",
57
60
  }
58
61
 
59
62
 
@@ -0,0 +1,84 @@
1
+ """Shared CLI helpers for resolving the regstack config target.
2
+
3
+ As of 0.8.x the canonical CLI flag is ``--config``. It accepts either:
4
+
5
+ * a path to a regstack TOML file (``./regstack.toml``), or
6
+ * a path to a directory containing or to-receive that file (``./conf/``).
7
+
8
+ A legacy ``--target`` flag is still accepted on commands that write
9
+ config (``init``, ``oauth setup``, ``ses setup``, ``theme design``);
10
+ using it emits a deprecation warning.
11
+
12
+ This module is the single source of truth for that resolution so each
13
+ command stays a thin Click wrapper.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ import click
21
+
22
+ CONFIG_FILE = "regstack.toml"
23
+
24
+ _DEPRECATION_HINT = (
25
+ "Deprecation: --target is deprecated; use --config (accepts a file or a "
26
+ "directory). --target will be removed in 1.0."
27
+ )
28
+
29
+
30
+ def resolve_target_dir(
31
+ *,
32
+ config: Path | None,
33
+ target: Path | None,
34
+ ) -> Path:
35
+ """Resolve the directory the command should write into.
36
+
37
+ ``config`` is the canonical flag. ``target`` is the legacy alias;
38
+ if it's the one supplied, a deprecation warning is emitted to
39
+ stderr (once per invocation) before the value is honoured.
40
+
41
+ Raises ``click.UsageError`` if both are supplied.
42
+ """
43
+ if config is not None and target is not None:
44
+ raise click.UsageError(
45
+ "--config and --target are mutually exclusive. Use --config "
46
+ "(--target is the deprecated alias)."
47
+ )
48
+
49
+ if target is not None:
50
+ click.echo(click.style(_DEPRECATION_HINT, fg="yellow"), err=True)
51
+ value: Path | None = target
52
+ else:
53
+ value = config
54
+
55
+ if value is None:
56
+ return Path.cwd().resolve()
57
+
58
+ p = value.resolve()
59
+ # File or dir? If it's a file (or looks like one — name ends with .toml),
60
+ # operate in its parent directory.
61
+ if p.is_file() or p.suffix == ".toml":
62
+ return p.parent
63
+ return p
64
+
65
+
66
+ def resolve_toml_path(config: Path | None) -> Path | None:
67
+ """Resolve ``--config`` for read-only commands (doctor, migrate, create-admin).
68
+
69
+ Returns the path to the TOML file, or ``None`` if the flag was
70
+ omitted (caller falls back to env/cwd discovery).
71
+
72
+ If a directory is supplied, looks for ``regstack.toml`` inside it.
73
+ The returned path is not required to exist — load_runtime_config
74
+ surfaces the error with a clearer message.
75
+ """
76
+ if config is None:
77
+ return None
78
+ p = config.resolve()
79
+ if p.is_dir():
80
+ return p / CONFIG_FILE
81
+ return p
82
+
83
+
84
+ __all__ = ["CONFIG_FILE", "resolve_target_dir", "resolve_toml_path"]
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
 
7
7
  import click
8
8
 
9
+ from regstack.cli._paths import resolve_toml_path
9
10
  from regstack.cli._runtime import open_regstack
10
11
 
11
12
 
@@ -21,17 +22,21 @@ from regstack.cli._runtime import open_regstack
21
22
  )
22
23
  @click.option(
23
24
  "--config",
24
- "toml_path",
25
- type=click.Path(exists=True, dir_okay=False, path_type=Path),
25
+ "config_path_in",
26
+ type=click.Path(exists=True, path_type=Path),
26
27
  default=None,
27
- help="Path to regstack.toml (default: search cwd / $REGSTACK_CONFIG).",
28
+ help=(
29
+ "Path to regstack.toml (or a directory containing it). "
30
+ "Default: search cwd / $REGSTACK_CONFIG."
31
+ ),
28
32
  )
29
- def create_admin(email: str, password: str | None, toml_path: Path | None) -> None:
33
+ def create_admin(email: str, password: str | None, config_path_in: Path | None) -> None:
30
34
  if password is None:
31
35
  password = click.prompt("Password", hide_input=True, confirmation_prompt=True)
32
36
  if len(password) < 8:
33
37
  raise click.UsageError("Password must be at least 8 characters.")
34
38
 
39
+ toml_path = resolve_toml_path(config_path_in)
35
40
  asyncio.run(_run(email=email, password=password, toml_path=toml_path))
36
41
 
37
42
 
@@ -9,6 +9,7 @@ import click
9
9
  import dns.resolver
10
10
 
11
11
  from regstack.backends.factory import build_backend, detect_backend_kind
12
+ from regstack.cli._paths import resolve_toml_path
12
13
  from regstack.cli._results import CheckResult
13
14
  from regstack.cli._runtime import load_runtime_config
14
15
  from regstack.email.factory import build_email_service
@@ -23,10 +24,13 @@ if TYPE_CHECKING:
23
24
  )
24
25
  @click.option(
25
26
  "--config",
26
- "toml_path",
27
- type=click.Path(exists=True, dir_okay=False, path_type=Path),
27
+ "config_path_in",
28
+ type=click.Path(exists=True, path_type=Path),
28
29
  default=None,
29
- help="Path to regstack.toml (default: search cwd / $REGSTACK_CONFIG).",
30
+ help=(
31
+ "Path to regstack.toml (or a directory containing it). "
32
+ "Default: search cwd / $REGSTACK_CONFIG."
33
+ ),
30
34
  )
31
35
  @click.option(
32
36
  "--check-dns",
@@ -39,7 +43,8 @@ if TYPE_CHECKING:
39
43
  default=None,
40
44
  help="Send a probe email to this address through the configured backend.",
41
45
  )
42
- def doctor(toml_path: Path | None, check_dns: bool, test_recipient: str | None) -> None:
46
+ def doctor(config_path_in: Path | None, check_dns: bool, test_recipient: str | None) -> None:
47
+ toml_path = resolve_toml_path(config_path_in)
43
48
  results = asyncio.run(
44
49
  _run(toml_path=toml_path, check_dns=check_dns, test_recipient=test_recipient)
45
50
  )
@@ -49,7 +54,10 @@ def doctor(toml_path: Path | None, check_dns: bool, test_recipient: str | None)
49
54
  click.echo(f"{symbol} {r.name}: {r.detail}")
50
55
  if failed:
51
56
  click.echo(click.style(f"\n{failed} check(s) failed.", fg="red"), err=True)
52
- sys.exit(failed)
57
+ # Clamp to 0/1 so a shell `regstack doctor && deploy` is predictable;
58
+ # the failure count appears on the stderr line above for operators
59
+ # who want it. (Review #4.)
60
+ sys.exit(1 if failed else 0)
53
61
 
54
62
 
55
63
  async def _run(
@@ -6,34 +6,65 @@ from urllib.parse import urlsplit
6
6
 
7
7
  import click
8
8
 
9
+ from regstack.cli._paths import CONFIG_FILE, resolve_target_dir
9
10
  from regstack.config.secrets import generate_secret
10
11
 
11
- CONFIG_FILE = "regstack.toml"
12
12
  SECRETS_FILE = "regstack.secrets.env"
13
13
 
14
14
 
15
15
  @click.command(help="Interactive wizard that writes regstack.toml + regstack.secrets.env.")
16
+ @click.option(
17
+ "--config",
18
+ "config_path_in",
19
+ type=click.Path(path_type=Path),
20
+ default=None,
21
+ help=("Path to regstack.toml or the directory to write it into (default: current directory)."),
22
+ )
16
23
  @click.option(
17
24
  "--target",
18
- "target_dir",
25
+ "target_path_in",
19
26
  type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
20
- default=Path.cwd,
21
- show_default="current directory",
22
- help="Directory to write config files into.",
27
+ default=None,
28
+ help="DEPRECATED: use --config. Directory to write config files into.",
23
29
  )
24
30
  @click.option("--force", is_flag=True, help="Overwrite existing config files without prompting.")
25
- def init(target_dir: Path, *, force: bool) -> None:
26
- target_dir = Path(target_dir).resolve()
31
+ @click.option(
32
+ "--if-missing",
33
+ is_flag=True,
34
+ help=(
35
+ "Exit 0 silently when either config file already exists. Useful "
36
+ "for idempotent infrastructure-as-code: `regstack init --if-missing` "
37
+ "in a Dockerfile entrypoint produces config the first time and "
38
+ "no-ops on every subsequent boot."
39
+ ),
40
+ )
41
+ def init(
42
+ config_path_in: Path | None,
43
+ target_path_in: Path | None,
44
+ *,
45
+ force: bool,
46
+ if_missing: bool,
47
+ ) -> None:
48
+ if force and if_missing:
49
+ raise click.UsageError("--force and --if-missing are mutually exclusive.")
50
+ target_dir = resolve_target_dir(config=config_path_in, target=target_path_in)
27
51
  target_dir.mkdir(parents=True, exist_ok=True)
28
52
 
29
53
  config_path = target_dir / CONFIG_FILE
30
54
  secrets_path = target_dir / SECRETS_FILE
31
55
 
32
- if (config_path.exists() or secrets_path.exists()) and not force:
33
- click.confirm(
34
- f"Config already exists at {config_path} or {secrets_path}. Overwrite?",
35
- abort=True,
36
- )
56
+ if config_path.exists() or secrets_path.exists():
57
+ if if_missing:
58
+ click.echo(
59
+ f"Config already present at {config_path} or {secrets_path}; "
60
+ "no action taken (--if-missing).",
61
+ )
62
+ sys.exit(0)
63
+ if not force:
64
+ click.confirm(
65
+ f"Config already exists at {config_path} or {secrets_path}. Overwrite?",
66
+ abort=True,
67
+ )
37
68
 
38
69
  click.echo(click.style("regstack init — app configuration only.\n", bold=True))
39
70
  click.echo("This wizard never provisions infrastructure. It only writes config files.\n")
@@ -95,11 +126,12 @@ def init(target_dir: Path, *, force: bool) -> None:
95
126
  type=click.Choice(["console", "smtp", "ses"]),
96
127
  default="console",
97
128
  )
98
- if email_backend != "console":
129
+ if email_backend == "ses":
99
130
  click.echo(
100
131
  click.style(
101
- f"Note: {email_backend!r} backend lands in M2; the wizard will write your "
102
- "config, but the running app will refuse to send mail until then.",
132
+ "Tip: `regstack ses setup` is a guided wizard that validates "
133
+ "credentials and sender-domain verification against AWS before "
134
+ "writing the config. Consider that instead.",
103
135
  fg="yellow",
104
136
  )
105
137
  )
@@ -116,6 +148,16 @@ def init(target_dir: Path, *, force: bool) -> None:
116
148
  smtp_starttls = click.confirm("Use STARTTLS?", default=True)
117
149
  smtp_user = click.prompt("SMTP username", default="")
118
150
  smtp_pass = click.prompt("SMTP password", default="", hide_input=True) or None
151
+ if smtp_user and smtp_pass is None:
152
+ click.echo(
153
+ click.style(
154
+ "Warning: SMTP username set but no password — leaving "
155
+ "REGSTACK_EMAIL__SMTP_PASSWORD unset. Set it via the secrets "
156
+ "file or env var before the app starts, or authenticated "
157
+ "SMTP sends will fail.",
158
+ fg="yellow",
159
+ )
160
+ )
119
161
  elif email_backend == "ses":
120
162
  ses_region = click.prompt("AWS region", default="eu-west-1")
121
163
 
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  import click
7
7
 
8
8
  from regstack.backends.factory import detect_backend_kind
9
+ from regstack.cli._paths import resolve_toml_path
9
10
  from regstack.cli._runtime import load_runtime_config
10
11
 
11
12
 
@@ -15,18 +16,26 @@ from regstack.cli._runtime import load_runtime_config
15
16
  )
16
17
  @click.option(
17
18
  "--config",
18
- "toml_path",
19
- type=click.Path(exists=True, dir_okay=False, path_type=Path),
19
+ "config_path_in",
20
+ type=click.Path(exists=True, path_type=Path),
20
21
  default=None,
21
- help="Path to regstack.toml (default: search cwd / $REGSTACK_CONFIG).",
22
+ help=(
23
+ "Path to regstack.toml (or a directory containing it). "
24
+ "Default: search cwd / $REGSTACK_CONFIG."
25
+ ),
22
26
  )
23
27
  @click.option(
24
28
  "--target",
25
29
  default="head",
26
30
  show_default=True,
27
- help="Revision to upgrade to (e.g. 'head', '0001', '+1').",
31
+ help=(
32
+ "Alembic revision to upgrade to (e.g. 'head', '0001', '+1'). "
33
+ "Note: distinct from the global --target/--config flag pair on "
34
+ "config-writing commands."
35
+ ),
28
36
  )
29
- def migrate(toml_path: Path | None, target: str) -> None:
37
+ def migrate(config_path_in: Path | None, target: str) -> None:
38
+ toml_path = resolve_toml_path(config_path_in)
30
39
  """Idempotent: re-running on a DB at the target revision is a no-op.
31
40
 
32
41
  Mongo backends are silently skipped — TTL indexes are installed by
@@ -251,7 +251,9 @@ def validate(
251
251
  timeout=timeout,
252
252
  )
253
253
  )
254
- sys.exit(exit_code)
254
+ # Clamp to 0/1 for predictable shell composition; the per-check
255
+ # failure count is rendered in the report above. (Review #4.)
256
+ sys.exit(1 if exit_code else 0)
255
257
 
256
258
 
257
259
  async def _run(
@@ -229,10 +229,12 @@ class RegStackConfig(BaseSettings):
229
229
  confirm_email_change_rate_limit: str | None = None
230
230
  delete_account_rate_limit: str | None = None
231
231
  oauth_exchange_rate_limit: str | None = None
232
- # DEPRECATED in favour of the per-route fields above; kept for
233
- # back-compat with configs that set them. Unused by the router.
234
- login_max_per_minute: Annotated[int, Field(ge=1)] = 5
235
- login_max_per_hour: Annotated[int, Field(ge=1)] = 20
232
+ # Phone routes send paid SMS and brute-force a 6-digit code, so
233
+ # they need IP throttles regardless of the per-code attempt counter
234
+ # in mfa_codes. (Review #6.)
235
+ phone_start_rate_limit: str | None = None
236
+ phone_confirm_rate_limit: str | None = None
237
+ phone_disable_rate_limit: str | None = None
236
238
 
237
239
  # Sub-configs
238
240
  email: EmailConfig = Field(default_factory=EmailConfig)
@@ -89,7 +89,9 @@ class MailComposer:
89
89
 
90
90
  # --- Public renderers -------------------------------------------------
91
91
 
92
- def verification(self, *, to: str, full_name: str | None, url: str) -> EmailMessage:
92
+ def verification(
93
+ self, *, to: str, full_name: str | None, url: str, ttl_hours: int
94
+ ) -> EmailMessage:
93
95
  return self._compose(
94
96
  kind="verification",
95
97
  to=to,
@@ -97,6 +99,7 @@ class MailComposer:
97
99
  "app_name": self._app_name,
98
100
  "full_name": full_name or "",
99
101
  "url": url,
102
+ "ttl_hours": ttl_hours,
100
103
  },
101
104
  )
102
105
 
@@ -0,0 +1 @@
1
+ {{ app_name }} verification code: {{ code }}. Expires in {{ ttl_minutes }} minutes.
@@ -6,7 +6,7 @@
6
6
  </head>
7
7
  <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #222; line-height: 1.5;">
8
8
  <p>Hi{% if full_name %} {{ full_name }}{% endif %},</p>
9
- <p>Thanks for signing up to <strong>{{ app_name }}</strong>. Please confirm your email address by clicking the link below.</p>
9
+ <p>Thanks for signing up to <strong>{{ app_name }}</strong>. Please confirm your email address by clicking the link below. It is valid for {{ ttl_hours }} hours.</p>
10
10
  <p><a href="{{ url }}" style="display:inline-block;padding:10px 20px;background:#222;color:#fff;text-decoration:none;border-radius:4px;">Confirm my account</a></p>
11
11
  <p>If the button doesn't work, paste this URL into your browser:</p>
12
12
  <p style="word-break: break-all;"><a href="{{ url }}">{{ url }}</a></p>
@@ -1,6 +1,6 @@
1
1
  Hi{% if full_name %} {{ full_name }}{% endif %},
2
2
 
3
- Thanks for signing up to {{ app_name }}. Please confirm your email address by visiting the link below:
3
+ Thanks for signing up to {{ app_name }}. Please confirm your email address by visiting the link below. It is valid for {{ ttl_hours }} hours.
4
4
 
5
5
  {{ url }}
6
6
 
@@ -24,6 +24,7 @@ KNOWN_EVENTS = {
24
24
  "email_change_requested",
25
25
  "email_changed",
26
26
  "phone_setup_started",
27
+ "phone_setup_disabled",
27
28
  "mfa_login_started",
28
29
  "mfa_enabled",
29
30
  "mfa_disabled",
@@ -137,7 +137,9 @@ def build_account_router(rs: RegStack) -> APIRouter:
137
137
  # via the users.update_email unique constraint.
138
138
  clash = await rs.users.get_by_email(payload.new_email)
139
139
  accepted = MessageResponse(
140
- message="If that email address is not already in use, a confirmation link has been sent.",
140
+ message=(
141
+ "If the address is available, a confirmation link has been sent. Check your email."
142
+ ),
141
143
  )
142
144
  if clash is not None:
143
145
  log.info(
@@ -176,7 +178,7 @@ def build_account_router(rs: RegStack) -> APIRouter:
176
178
  except TokenError as exc:
177
179
  raise HTTPException(
178
180
  status_code=status.HTTP_400_BAD_REQUEST,
179
- detail="Token is invalid or has expired.",
181
+ detail="Email-change link is invalid or has expired. Request a new one.",
180
182
  ) from exc
181
183
 
182
184
  user = await rs.users.get_by_id(user_id)
@@ -184,7 +184,12 @@ def build_admin_router(rs: RegStack) -> APIRouter:
184
184
  await rs.pending.upsert(pending)
185
185
 
186
186
  url = rs.config.resolve_verify_url(raw)
187
- message = rs.mail.verification(to=user.email, full_name=user.full_name, url=url)
187
+ message = rs.mail.verification(
188
+ to=user.email,
189
+ full_name=user.full_name,
190
+ url=url,
191
+ ttl_hours=max(ttl // 3600, 1),
192
+ )
188
193
  await rs.email.send(message)
189
194
  await rs.hooks.fire("verification_requested", email=user.email, url=url)
190
195
  return MessageResponse(message=f"Verification email sent to {user.email}.")
@@ -19,13 +19,30 @@ if TYPE_CHECKING:
19
19
  from regstack.models.user import BaseUser
20
20
 
21
21
 
22
- _INVALID = HTTPException(
23
- status_code=status.HTTP_401_UNAUTHORIZED,
24
- detail="Invalid email or password.",
25
- )
26
22
  _LOGIN_MFA_PURPOSE = "login_mfa"
27
23
 
28
24
 
25
+ async def _build_invalid(rs: RegStack, email: str) -> HTTPException:
26
+ """Build a 401 that includes the user-facing attempts-remaining
27
+ count when lockout is enabled — matches the shape MFA uses for
28
+ its wrong-code response so the two flows feel symmetrical to a
29
+ user typing the wrong credentials. (Review #15.)
30
+
31
+ Returns a fresh exception each call because ``attempts_remaining``
32
+ changes between calls; the previous module-level constant was
33
+ cached and couldn't carry per-call state.
34
+ """
35
+ remaining = await rs.lockout.attempts_remaining(email)
36
+ if remaining is None or remaining == 0:
37
+ # remaining == 0 means lockout is about to fire on the next
38
+ # attempt; the 429 from `lockout.check` carries the
39
+ # "Try again in N seconds" message — don't pre-announce it.
40
+ detail = "Invalid email or password."
41
+ else:
42
+ detail = f"Invalid email or password. {remaining} attempt(s) remaining."
43
+ return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
44
+
45
+
29
46
  class MfaPendingResponse(BaseModel):
30
47
  status: str = "mfa_required"
31
48
  mfa_pending_token: str
@@ -69,7 +86,7 @@ def build_login_router(rs: RegStack) -> APIRouter:
69
86
  user = await rs.users.get_by_email(payload.email)
70
87
  if user is None or user.id is None:
71
88
  await rs.lockout.record_failure(payload.email)
72
- raise _INVALID
89
+ raise await _build_invalid(rs, payload.email)
73
90
  # Password verification runs first so the is_active and
74
91
  # is_verified branches below are only reachable by an attacker
75
92
  # who already knows the password. Without this ordering, an
@@ -83,7 +100,7 @@ def build_login_router(rs: RegStack) -> APIRouter:
83
100
  payload.password, user.hashed_password
84
101
  ):
85
102
  await rs.lockout.record_failure(payload.email)
86
- raise _INVALID
103
+ raise await _build_invalid(rs, payload.email)
87
104
  # Even with the right password, disabled / unverified accounts
88
105
  # must still increment the lockout counter — otherwise a
89
106
  # password-stuffing attacker who happens to be holding the
@@ -122,7 +139,7 @@ def build_login_router(rs: RegStack) -> APIRouter:
122
139
  except TokenError as exc:
123
140
  raise HTTPException(
124
141
  status_code=status.HTTP_400_BAD_REQUEST,
125
- detail="MFA token is invalid or has expired.",
142
+ detail="Sign-in session is invalid or has expired. Start over from sign-in.",
126
143
  ) from exc
127
144
 
128
145
  result = await rs.mfa_codes.verify(user_id=user_id, kind="login_mfa", raw_code=payload.code)
@@ -44,7 +44,10 @@ def build_password_router(rs: RegStack) -> APIRouter:
44
44
 
45
45
  # Anti-enumeration: same response regardless of whether the user exists.
46
46
  ack = MessageResponse(
47
- message="If an account exists for that email, a reset link has been sent."
47
+ message=(
48
+ "If the address matches an active account, a reset link has been sent. "
49
+ "Check your email."
50
+ )
48
51
  )
49
52
  user = await rs.users.get_by_email(payload.email)
50
53
  if user is None or user.id is None or not user.is_active:
@@ -84,14 +87,24 @@ def build_password_router(rs: RegStack) -> APIRouter:
84
87
  except TokenError as exc:
85
88
  raise HTTPException(
86
89
  status_code=status.HTTP_400_BAD_REQUEST,
87
- detail="Reset token is invalid or has expired.",
90
+ detail="Reset link is invalid or has expired. Request a new one.",
88
91
  ) from exc
89
92
 
93
+ # Refuse to re-use a reset token. The token's jti is added to the
94
+ # blacklist on first success, so a second click of the same link
95
+ # gets the same "invalid or has expired" response that an
96
+ # expired-token attempt sees. (Review #17.)
97
+ if await rs.blacklist.is_revoked(token_payload.jti):
98
+ raise HTTPException(
99
+ status_code=status.HTTP_400_BAD_REQUEST,
100
+ detail="Reset link is invalid or has expired. Request a new one.",
101
+ )
102
+
90
103
  user = await rs.users.get_by_id(token_payload.sub)
91
104
  if user is None or user.id is None or not user.is_active:
92
105
  raise HTTPException(
93
106
  status_code=status.HTTP_400_BAD_REQUEST,
94
- detail="Reset token does not match an active account.",
107
+ detail="Reset link does not match an active account.",
95
108
  )
96
109
 
97
110
  new_hash = rs.password_hasher.hash(payload.new_password)
@@ -100,6 +113,8 @@ def build_password_router(rs: RegStack) -> APIRouter:
100
113
  # stolen session token would otherwise outlive the password change.
101
114
  await rs.users.update_password(user.id, new_hash)
102
115
  await rs.lockout.clear(user.email)
116
+ # Blacklist the reset token so the same link can't be used twice.
117
+ await rs.blacklist.revoke(token_payload.jti, token_payload.exp)
103
118
  await rs.hooks.fire("password_reset_completed", user=user)
104
119
  return MessageResponse(message="Password has been reset. Please sign in.")
105
120
 
@@ -122,7 +122,7 @@ def build_phone_router(rs: RegStack) -> APIRouter:
122
122
  except TokenError as exc:
123
123
  raise HTTPException(
124
124
  status_code=status.HTTP_400_BAD_REQUEST,
125
- detail="Pending token is invalid or has expired.",
125
+ detail="Phone-setup session is invalid or has expired. Start setup again.",
126
126
  ) from exc
127
127
 
128
128
  result = await rs.mfa_codes.verify(
@@ -164,6 +164,11 @@ def build_phone_router(rs: RegStack) -> APIRouter:
164
164
  await rs.users.set_phone(user.id, None)
165
165
  await rs.users.set_mfa_enabled(user.id, is_mfa_enabled=False)
166
166
  await rs.mfa_codes.delete(user_id=user.id)
167
+ # Fire phone_setup_disabled (factor-specific) before mfa_disabled
168
+ # (any factor). Subscribers that care which factor was removed
169
+ # listen for the former; subscribers that just want "MFA is off
170
+ # for this user" listen for the latter.
171
+ await rs.hooks.fire("phone_setup_disabled", user=user)
167
172
  await rs.hooks.fire("mfa_disabled", user=user)
168
173
  return MessageResponse(message="SMS 2FA disabled.")
169
174
 
@@ -91,6 +91,7 @@ async def _start_verification(
91
91
  to=payload.email,
92
92
  full_name=payload.full_name,
93
93
  url=url,
94
+ ttl_hours=max(ttl // 3600, 1),
94
95
  )
95
96
  await rs.email.send(message)
96
97
  await rs.hooks.fire("verification_requested", email=payload.email, url=url)