EnxDev opened a new pull request, #45277:
URL: https://github.com/apache/superset/pull/45277
### SUMMARY
Adds the self-service "Forgot password?" flow for `AUTH_TYPE = AUTH_DB`.
This is the remaining self-service recovery piece of SIP-201 (#37927). Today a
user who forgets their password needs an admin with shell access to run
`superset fab reset-password`. With this change they can request a reset link
from the sign-in page and pick a new password themselves.
The feature is off by default. Operators turn it on with
`AUTH_PASSWORD_RECOVERY = {"enabled": True, "base_url": "https://..."}` once
SMTP is configured.
On architecture: this deliberately builds on the primitives that landed
after the review of #39469, rather than adding parallel infrastructure.
Following @dpgaspar's feedback there, it adds no new hashing library, password
policy config, session table, migration or email transport. Password hashing,
the complexity check, session invalidation, audit logging, SMTP delivery and
rate limiting all go through what Superset and FAB already use for the
`/api/v1/me/` password change (#40670, #42934, #44626).
#### Architecture
**Request flow.** `POST /api/v1/security/password_recovery/` with `{"email":
...}`.
1. The account is looked up by email without case (`lower(email) =
lower(:email)`). Zero or several matches both count as "no account".
2. A per-address resend cooldown row is written for every address, matched
or not (`resend_cooldown_seconds`, default 120). While it's active, nothing
else happens.
3. For an eligible account (active, has a local password, has an email), a
256-bit token is generated, stored, and the email is rendered inside the
request. The SMTP round-trip runs on a background thread that starts once the
response is closed. Running it inside `call_on_close` itself wasn't enough: the
server only closes the connection, and a sync worker only takes its next
request, after that callback returns.
4. The response is always `200 {"message": "If an account with that email
exists, a password reset link has been sent."}`.
**Stand-in (decoy) path.** When the address gets no link (unknown,
ambiguous, OAuth/LDAP-provisioned, inactive, or no public base URL configured),
the command does the same work against a stand-in account. It generates a
token, fingerprints the stand-in, inserts the token row inside a savepoint that
is then rolled back, and renders the email without sending it. Nothing from
that path is persisted or delivered.
**Token persistence.** Tokens live in the existing `key_value` table under
the new `password_recovery_token` resource, so there's no migration. The raw
token is never stored: the row key is derived from a SHA-256 digest of the
token, and the value holds the user id plus a fingerprint of the account's id,
password hash and email at issue time. Rows carry `expires_on`
(`token_ttl_minutes`, default 30, capped at 24 hours) and expired rows are
swept on each new request, as well as by the optional `prune_key_value` beat
task. Cooldown rows use a separate `password_recovery_throttle` resource keyed
by an HMAC of the case-folded address under `SECRET_KEY`, so the table doesn't
reveal which addresses were submitted.
**Reset flow.** `POST /api/v1/security/password_recovery/reset/` with
`{"token": ..., "password": ...}`.
- The schema runs FAB's `PasswordComplexityValidator` first, so
`FAB_PASSWORD_COMPLEXITY_VALIDATOR` is the single password policy. A rejected
password returns `400 {"message": {"password": [...]}}` and leaves the token
usable.
- The token row is looked up, the account row is locked (`SELECT ... FOR
UPDATE`), and the fingerprint is compared in constant time. Then the token is
deleted with a single conditional `DELETE` (new `KeyValueDAO.consume_entry`),
whose row count decides the one winner among concurrent requests.
- The password is hashed with werkzeug's `generate_password_hash` using
`FAB_PASSWORD_HASH_METHOD` and `FAB_PASSWORD_HASH_SALT_LENGTH`, the same as
`PUT /api/v1/me/`, and saved with FAB's `update_user`.
`invalidate_sessions_for_user` signs out existing browser sessions and
`clear_password_must_change` satisfies a pending forced change.
- Token consumption, the new hash, the invalidation epoch and the cleared
flag commit in one transaction, so a failed write doesn't burn the link.
- Missing, expired, used, superseded and tampered tokens all return the same
`400 {"message": {"token": ["This password reset link is invalid or has
expired."]}}`.
FAB's `reset_password` isn't called directly because it commits on its own
and swallows a failed write.
**Feature flag behaviour.** `AUTH_PASSWORD_RECOVERY["enabled"]` and
`AUTH_TYPE == AUTH_DB` together decide everything. When either is false, both
API routes and both SPA pages (`/login/forgot_password/`,
`/login/reset_password/`) return 404 and the bootstrap sends
`PASSWORD_RECOVERY_ENABLED: false`, which hides the link on the sign-in page.
**Frontend.** Two new SPA pages reuse the sign-in card styles (moved into
`src/pages/Login/styles.ts`).
- The forgot-password page shows the same confirmation for every outcome
except 429.
- The reset page reads the token from the URL fragment, keeps it in a ref,
strips it from the address bar and history on load, and shows the server's
password errors verbatim. There is no client-side copy of the policy.
**Logging.** Neither endpoint is wrapped in `event_logger.log_this`, because
that would copy the token and new password into the `logs` table. The commands
write curated security audit events through the existing `_log_audit_event`
helper:
- `PasswordRecoveryRequested` with an outcome (`sent`, `no_account`,
`ineligible`, `throttled`, `not_configured`) and the target user id
- `PasswordRecoveryCompleted`
- `PasswordRecoveryFailed`
Tokens, passwords and submitted addresses are never logged. The
ambiguous-match warning doesn't include the address.
#### Security
- **Enumeration.** The request endpoint returns byte-identical responses for
existing, unknown, ambiguous, ineligible, throttled and SMTP-failing addresses.
The forgot-password page shows one confirmation for all of them.
- **Timing.** The request does the same database and rendering work whether
or not the address matches: same lookup query, same cooldown write, same token
insert (rolled back for the stand-in), same email rendering. SMTP runs on its
own thread after the response, so mail-server latency doesn't show up in the
response time, the connection close, or the worker's next request. That removes
the obvious timing signals. It isn't a constant-time guarantee, though.
Savepoint commit versus rollback and differences in database caching can still
vary a little. Measured under gunicorn (one sync worker) with an SMTP server
that takes 2 s per message, an eligible and an unknown address both closed the
connection in about 20 ms. Before the email moved to its own thread, the
eligible one took 2.1 s, and so did the next request on that worker.
- **Tokens.** 256 bits from `secrets` (`random_key(32)`), URL-safe. They're
stored only as a SHA-256-derived key, expire, are single-use, and become stale
when the account's password or email changes for any reason (another reset,
`/api/v1/me/`, an admin, the CLI). Replays and concurrent double-submits get
the generic invalid-token response.
- **Links.** Reset links are built only from
`AUTH_PASSWORD_RECOVERY["base_url"]` (falling back to
`WEBDRIVER_BASEURL_USER_FRIENDLY`), never from `Host` or `X-Forwarded-Host`. No
link is sent while the base URL is still the shipped `http://0.0.0.0:8080/`,
and Superset logs an error instead. A warning is logged when the link isn't
HTTPS. The token sits in the URL fragment, so it never reaches access logs or
`Referer` headers.
- **Case and ambiguity.** Lookup ignores case, so a user typing
`[email protected]` still gets their link, and it goes to the stored address
rather than the submitted spelling. Because email uniqueness is case-sensitive
on some backends, an address that matches several accounts gets no email at all
rather than a guess.
- **Rate limiting.** FAB's Flask-Limiter (`@limit`) applies `rate_limit`
(default `5 per 15 minutes`) per client address to each endpoint when
`RATELIMIT_ENABLED` is on. The docs call out two operator concerns:
- Behind a proxy the client address is the proxy's unless
`ENABLE_PROXY_FIX` is on.
- The default `memory://` storage counts per worker, so multi-worker
deployments should set `RATELIMIT_STORAGE_URI` to Redis or similar.
The per-address cooldown is in the metadata DB and doesn't depend on
either.
- **CSRF and auth.** Both endpoints are intentionally anonymous (they're for
people who can't sign in) and are added to the `test_views_are_secured`
allowlist with a comment. They keep Superset's normal CSRF protection: the
signed-out SPA pages render the usual `csrf_token` input, and `SupersetClient`
sends it.
- **External auth.** Only `AUTH_DB` is supported. Under OAuth, LDAP,
remote-user and the other backends, the routes 404. Accounts with no local
password (provisioned by another backend) never get a link, so this flow can't
turn them into password accounts.
- **Session scope.** The reset signs out existing browser sessions through
the session-invalidation epoch. JWT access tokens issued by
`/api/v1/security/login` aren't revoked and stay valid until they expire.
That's the existing behaviour for every password change path and is documented
here. Revoking them is better handled as its own change.
### BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF
Before: the sign-in page has no recovery option.
After, with `AUTH_PASSWORD_RECOVERY["enabled"] = True` on an `AUTH_DB`
deployment:
- The sign-in page shows "Forgot password?" under the password field.
- `/login/forgot_password/` asks for an email and then shows "Check your
email".
- `/login/reset_password/` asks for the new password twice, shows policy
errors inline, and ends on "Your password has been reset" or "This password
reset link is invalid or has expired".
### TESTING INSTRUCTIONS
Run on the final branch:
```bash
# backend: recovery, rate limit, password change/complexity, session
invalidation,
# audit log, /api/v1/me/, key-value DAO and key-value tests -> 300 passed
pytest tests/unit_tests/security/test_password_recovery.py \
tests/unit_tests/security/test_password_recovery_rate_limit.py \
tests/unit_tests/security/test_password_change.py \
tests/unit_tests/security/test_password_change_session_invalidation.py \
tests/unit_tests/security/test_session_invalidation.py \
tests/unit_tests/security/test_password_complexity.py \
tests/unit_tests/security/audit_log_test.py \
tests/unit_tests/views/test_current_user_api.py \
tests/unit_tests/daos/ tests/unit_tests/key_value/
# frontend -> 48 passed
npm run test -- src/pages/ForgotPassword src/pages/ResetPassword
src/pages/Login src/views/routes.test.tsx
npm run type
python scripts/translations/check_pot_drift.py # matches a fresh
extraction
SUPERSET__SQLALCHEMY_DATABASE_URI='sqlite:///:memory:'
FLASK_APP='superset.app:create_app()' superset update-api-docs # no drift
pre-commit run --from-ref master --to-ref HEAD # all hooks pass
```
`TestRolePermission.test_views_are_secured` was also run against the app
with the four new endpoints in the allowlist, and passes.
End-to-end check on a throwaway instance (SQLite metadata DB,
`WTF_CSRF_ENABLED = True`, `RATELIMIT_ENABLED = True`, SMTP pointed at a local
capture sink, webpack dev server), with accounts for an `AUTH_DB` user with a
mixed-case email, two users whose emails differ only in case, a user with no
password and an inactive user:
- Signed out, open `/login/`, click "Forgot password?" and submit an
address. The request carries the page's CSRF token; the same POST without it
gets a 400.
- Existing address in different case: one email, sent to the stored address.
Unknown, ambiguous, no-password and inactive addresses get byte-identical
responses, no email, and no token row in `key_value`. A repeat within the
cooldown sends nothing.
- The email link opens the SPA reset page and the fragment is removed from
the address bar. A common password shows the validator's error and the token
survives. A compliant password succeeds.
- Afterwards the old password fails, the new one works, and a session logged
in before the reset gets 401 on `/api/v1/me/`.
- Reusing the link, an invented token and an expired token (`expires_on`
moved into the past) all show "invalid or has expired".
- Pasting a fresh link into a tab already on the reset page picks up the new
token.
- `logs` rows and the server log contain no tokens, new passwords or the
ambiguous address.
- The 21st request within the window gets 429 with `rate_limit = "20 per 15
minutes"`.
- Under gunicorn with one sync worker and an SMTP sink that sleeps 2 s per
message, raw-socket timing of body, connection close and the next request shows
no difference between an eligible and an unknown address.
- With `"enabled": False`, the sign-in link is gone, both pages and both
endpoints return 404, and no email is sent.
To try it locally, set this in `superset_config.py` along with working
`SMTP_*` settings, then use "Forgot password?" on the sign-in page:
```python
AUTH_PASSWORD_RECOVERY = {"enabled": True, "base_url":
"http://localhost:8088/"}
```
### ADDITIONAL INFORMATION
- [x] Has associated issue: Related: #37927
- [ ] Required feature flags: none. Off by default via the new
`AUTH_PASSWORD_RECOVERY` config dict.
- [x] Changes UI
- [ ] Includes DB Migration (follow approval process in
[SIP-59](https://github.com/apache/superset/issues/13351))
- [ ] Migration is atomic, supports rollback & is backwards-compatible
- [ ] Confirm DB migration upgrade and downgrade tested
- [ ] Runtime estimates and downtime expectations provided
- [x] Introduces new feature or API
- [ ] Removes existing feature or API
No migration: tokens and cooldowns use two new `KeyValueResource` values in
the existing `key_value` table.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]