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]

Reply via email to