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.
- {regstack-0.8.0 → regstack-0.8.1}/PKG-INFO +2 -2
- {regstack-0.8.0 → regstack-0.8.1}/README.md +1 -1
- {regstack-0.8.0 → regstack-0.8.1}/pyproject.toml +1 -1
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/lockout.py +13 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/rate_limit.py +3 -0
- regstack-0.8.1/src/regstack/cli/_paths.py +84 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/admin.py +9 -4
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/doctor.py +13 -5
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/init.py +57 -15
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/migrate.py +14 -5
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/cli.py +3 -1
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/config/schema.py +6 -4
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/composer.py +4 -1
- regstack-0.8.1/src/regstack/email/templates/sms_phone_setup.txt +1 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/verification.html +1 -1
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/verification.txt +1 -1
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/hooks/events.py +1 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/account.py +4 -2
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/admin.py +6 -1
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/login.py +24 -7
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/password.py +18 -3
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/phone.py +6 -1
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/register.py +1 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/verify.py +3 -2
- regstack-0.8.1/src/regstack/ui/templates/auth/_token_handoff.html +11 -0
- regstack-0.8.1/src/regstack/ui/templates/auth/email_change_confirm.html +6 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/reset.html +1 -0
- regstack-0.8.1/src/regstack/ui/templates/auth/verify.html +6 -0
- regstack-0.8.1/src/regstack/version.py +1 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/cli.py +58 -14
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/writer.py +23 -7
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/cli.py +60 -16
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/writer.py +16 -6
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/cli.py +64 -12
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/writer.py +8 -3
- regstack-0.8.0/src/regstack/email/templates/sms_phone_setup.txt +0 -1
- regstack-0.8.0/src/regstack/ui/templates/auth/email_change_confirm.html +0 -10
- regstack-0.8.0/src/regstack/ui/templates/auth/verify.html +0 -10
- regstack-0.8.0/src/regstack/version.py +0 -1
- {regstack-0.8.0 → regstack-0.8.1}/.gitignore +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/CHANGELOG.md +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/LICENSE +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/NOTICE +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/SECURITY.md +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/regstack.toml.example +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/app.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/clock.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/dependencies.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/jwt.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/mfa.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/password.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/auth/tokens.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/base.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/factory.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/backend.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/client.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/indexes.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/protocols.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/backend.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/env.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/oauth_state_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/schema.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/backends/sql/types.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/__main__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/_results.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/_runtime.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/capture.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/http.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/logtail.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/account.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/cleanup.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/core_auth.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/feature_discover.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/oauth.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/password_reset.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/reachability.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/sms_mfa.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/report.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/cli/validate/runner.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/config/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/config/loader.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/config/secrets.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/base.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/console.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/factory.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/ses.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/smtp.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/email_change.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/email_change.subject.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/email_change.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/password_reset.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/password_reset.subject.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/password_reset.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/email/templates/verification.subject.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/hooks/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/_objectid.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/login_attempt.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/mfa_code.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/oauth_identity.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/oauth_state.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/pending_registration.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/models/user.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/base.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/errors.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/providers/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/providers/google.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/oauth/registry.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/_helpers.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/_schemas.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/logout.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/routers/oauth.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/base.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/factory.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/null.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/sns.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/sms/twilio.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/pages.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/static/css/core.css +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/static/css/theme.css +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/static/js/regstack.js +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/forgot.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/login.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/me.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/register.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/ui/templates/base.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/routes.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/server.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/validators.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/window.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/_aws.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/routes.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/server.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/static/wizard.css +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/static/wizard.js +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/templates/wizard.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/validators.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/ses/window.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/routes.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/server.py +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/templates/designer.html +0 -0
- {regstack-0.8.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/validators.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
-
"
|
|
25
|
-
type=click.Path(exists=True,
|
|
25
|
+
"config_path_in",
|
|
26
|
+
type=click.Path(exists=True, path_type=Path),
|
|
26
27
|
default=None,
|
|
27
|
-
help=
|
|
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,
|
|
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
|
-
"
|
|
27
|
-
type=click.Path(exists=True,
|
|
27
|
+
"config_path_in",
|
|
28
|
+
type=click.Path(exists=True, path_type=Path),
|
|
28
29
|
default=None,
|
|
29
|
-
help=
|
|
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(
|
|
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
|
-
|
|
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
|
-
"
|
|
25
|
+
"target_path_in",
|
|
19
26
|
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
|
|
20
|
-
default=
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
129
|
+
if email_backend == "ses":
|
|
99
130
|
click.echo(
|
|
100
131
|
click.style(
|
|
101
|
-
|
|
102
|
-
"
|
|
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
|
-
"
|
|
19
|
-
type=click.Path(exists=True,
|
|
19
|
+
"config_path_in",
|
|
20
|
+
type=click.Path(exists=True, path_type=Path),
|
|
20
21
|
default=None,
|
|
21
|
-
help=
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
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(
|
|
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
|
|
|
@@ -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=
|
|
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="
|
|
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(
|
|
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
|
|
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
|
|
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="
|
|
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=
|
|
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
|
|
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
|
|
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="
|
|
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
|
|