feat(v1.1.8): password reset flow (migration 0029 + revokes sessions)
migration 0029: app_user_password_resets table — same shape as
verification (token_hash PK, app_id + user_id FKs, expires_at,
consumed_at). One-shot via atomic UPDATE WHERE consumed_at IS NULL.
Default TTL 1h (shorter than verification's 48h — reset tokens are
higher-risk).
UsersServiceImpl gains password_resets: Arc<dyn AppUserPasswordResetRepo>.
users::request_password_reset(email, opts):
* Returns Ok(()) regardless of whether the email matched — no
existence-leak signal in script-land (per brief).
* Email-not-configured surfaces as NotConfigured so scripts can
fall back to a synchronous reset path. Other email errors are
silently swallowed and logged server-side; surfacing them would
leak which addresses produced a "real send attempted" signal vs
a no-op.
users::complete_password_reset(token, new_password):
* Atomically consumes the token, updates the Argon2id hash, and
revokes EVERY active session for that user (anyone with a stale
token shouldn't be able to ride out the reset). Emits
users::password_changed.
* Returns the user on success, () on bad/expired/already-used.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
-- v1.1.8 User Management — password reset tokens.
|
||||
--
|
||||
-- Identical shape to `app_user_email_verifications`. Same one-shot
|
||||
-- semantics via atomic UPDATE WHERE consumed_at IS NULL. Default TTL
|
||||
-- is shorter (1h vs 48h) — reset tokens are higher-risk than email
|
||||
-- verification (whoever holds them can change the password).
|
||||
--
|
||||
-- `users::request_password_reset` deliberately returns no signal to
|
||||
-- script-land about whether the email matched, so probing this table
|
||||
-- via mass-replay isn't a clean enumeration vector. The application
|
||||
-- enforces "no existence leak"; this migration is just storage.
|
||||
|
||||
CREATE TABLE app_user_password_resets (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_app_user_password_resets_user
|
||||
ON app_user_password_resets (app_id, user_id);
|
||||
Reference in New Issue
Block a user