Files
UmbraTrans/umbratrans_web/migrations/0002_password_reset_purpose.sql
umbra audio e034061ec1 Add password reset and self-service account settings
An administrator could not reset a password for bdmurph73@gmail.com, and
there was no way for a signed-in person to change their own. Both are now
in place.

Migration 0002 gives single-use tokens a purpose. 0001 reused the
control plane's unused bootstrap_tokens for email verification, and the
table has no notion of what a token is for, which was invisible while
there was only one kind. With a second kind it stops being invisible: a
reset link clicked by anyone holding it would confirm an address, and a
verification link would set a password. The column defaults to the
existing purpose, so no row is rewritten and verification is unchanged.
Every token lookup now filters on purpose, and the adapter reads the
purpose from the row rather than hardcoding it.

An administrator may start a reset but never finish one. The link goes to
the address on the account, only its holder can complete it, and the
endpoint returns a bool rather than a password. An administrator who can
set someone's password can also sign in as them indefinitely. A reset
revokes every session, because a reset is what someone reaches for when
they think their password is compromised, and revoking only the session
that completed it would leave a stolen cookie working.

Reset links live one hour against the two days a verification link gets.
A verification link confirms an address the person already controls; a
reset link is a live credential for as long as it is valid.

Three bugs found while building it, all mine:

- request_password_reset did not call _authorize_target, so an organization
  administrator could start a reset on an account in another tenant. Caught
  by a test written for the scoping rule that revoke_sessions already
  followed.

- The in-memory repository never checked token expiry, only PostgreSQL did.
  So an expired link was honoured in every test and refused in production -
  the wrong direction for a divergence between two stores of the same rule.

- The PostgreSQL adapter's get_live_verification_token took a purpose but
  not the now its in-memory counterpart needed, so the first call against a
  real database raised TypeError and returned a 500. The conformance test
  only checked that a method existed. It now compares signatures, which
  immediately caught that the protocol itself was missing the parameter.

The reported failure was a deadlock rather than a missing feature. The
account was invited: it could not sign in because the address was
unverified, and I refused to reset anything not yet active. It is now
allowed, and completing the reset also confirms the address, since holding
the link proves control of it. Without that the flow would not converge -
reset the password, sign in, refused again, using a verification link
that was never sent. Suspended and deactivated accounts are still refused,
because a reset restores sign-in and would otherwise undo a suspension
without going through the status controls.

Verified live: the account that could not be reset is now active, verified
and able to sign in, with password.reset_requested, password.reset_completed
and email.verified all in the audit trail. A reset token is refused by the
verification endpoint, an invented token gives the same answer as a used
one, and a token in a reset URL is redacted from the access log.

327 tests pass, 337 with the integration suite.
2026-09-27 23:46:59 -07:00

95 lines
4.1 KiB
PL/PgSQL

-- 0002: give single-use tokens a purpose.
--
-- 0001 reused the control plane's existing, unused `bootstrap_tokens` table for
-- email verification. That table has no notion of what a token is for, so with
-- only one kind of token in play the omission was invisible.
--
-- It stops being invisible the moment a second kind exists. A password-reset
-- token and an email-verification token would be interchangeable: a reset link
-- clicked by anyone holding it would confirm an address, and a verification link
-- would set a password. Neither is acceptable, and the table as it stands cannot
-- tell them apart.
--
-- The column defaults to the existing purpose, so every row already written stays
-- valid and the verification flow is unchanged. No existing row is rewritten, and
-- the verification code continues to work without modification.
--
-- `0002_password_reset_purpose.sql`, additive, and safe to apply more than once.
BEGIN;
-- ---------------------------------------------------------------------
-- 1. Purpose
-- ---------------------------------------------------------------------
-- A default, not a null, so the column can be NOT NULL and an insert that
-- forgets to name a purpose is still valid and still safe: it lands as
-- email_verification, which is the only purpose that existed before this
-- migration and therefore the conservative choice.
ALTER TABLE public.bootstrap_tokens
ADD COLUMN IF NOT EXISTS purpose text NOT NULL DEFAULT 'email_verification';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'bootstrap_tokens'
AND c.conname = 'bootstrap_tokens_purpose_check'
) THEN
ALTER TABLE public.bootstrap_tokens
ADD CONSTRAINT bootstrap_tokens_purpose_check
CHECK (purpose IN ('email_verification', 'password_reset'));
RAISE NOTICE 'attached bootstrap_tokens_purpose_check';
ELSE
RAISE NOTICE 'bootstrap_tokens_purpose_check already present; leaving it alone';
END IF;
END
$$;
COMMENT ON COLUMN public.bootstrap_tokens.purpose IS
'What this token authorises: email_verification confirms an address, password_reset sets a new password. They are deliberately not interchangeable, because each would otherwise grant the other''s capability.';
-- ---------------------------------------------------------------------
-- 2. Lookup
-- ---------------------------------------------------------------------
-- Every token read filters on user and purpose together, and an unconsumed token
-- for one purpose is looked up on every reset and every verification. The index
-- covers that; without it each of those is a sequential scan of the table, which
-- on a busy deployment is also a cheap way for an unauthenticated endpoint to
-- generate load.
CREATE INDEX IF NOT EXISTS bootstrap_tokens_user_purpose_idx
ON public.bootstrap_tokens (user_id, purpose, expires_at)
WHERE consumed_at IS NULL;
-- ---------------------------------------------------------------------
-- 3. Rollback
-- ---------------------------------------------------------------------
-- Additive, so a rollback is dropping what was added. The column is dropped
-- before the index, since the index depends on it. Any token of a purpose other
-- than email_verification must be deleted first, or the drop will leave rows the
-- column no longer describes; the guarded delete below handles that, and the
-- NOTICE says how many rows it removed rather than passing over them.
--
-- DROP INDEX IF EXISTS public.bootstrap_tokens_user_purpose_idx;
--
-- DO $$
-- BEGIN
-- IF EXISTS (
-- SELECT 1 FROM information_schema.columns
-- WHERE table_schema = 'public'
-- AND table_name = 'bootstrap_tokens'
-- AND column_name = 'purpose'
-- ) THEN
-- DELETE FROM public.bootstrap_tokens WHERE purpose <> 'email_verification';
-- ALTER TABLE public.bootstrap_tokens DROP COLUMN purpose;
-- END IF;
-- END
-- $$;
COMMIT;