Skip to content

Recovery-factor verification: "verified" means the recovery factor, email is the special case#8

Merged
igorbenav merged 4 commits into
mainfrom
recovery-verification
Jun 20, 2026
Merged

Recovery-factor verification: "verified" means the recovery factor, email is the special case#8
igorbenav merged 4 commits into
mainfrom
recovery-verification

Conversation

@igorbenav

Copy link
Copy Markdown
Contributor

Recovery-factor verification: "verified" means the recovery factor, email is the special case

PR 1 made identity and recovery shape model-driven (recovery="email" | "phone" | None), but verified stayed email-specific: current_user(verified=True) checked email_verified, and configuring that gate without an email column raised. This PR generalizes "verified" to mean "the contract's recovery factor is proven controlled," so a phone-recovery app verifies by SMS, with email_verified becoming the special case where the factor is email. The proof flow is the same signed-one-time-token machinery, re-pointed at the factor and its channel; the security core is untouched; and the public surface is renamed so its names tell the truth. This is PR 2 of 2, closing the identity-and-recovery arc; the existing email suite passes unchanged.


Verification is proof of control of the recovery factor

Before, "verified" was hardcoded to email: repo.email_verified(user), the email_verified column, and a gate that raised at construction unless the model had an email column. That left a placeholder seam: a phone-recovery app declared recovery="phone" (PR 1) but had no way to verify the phone.

Now the repository resolves the factor: recovery_verified(user) reads email_verified when the recovery factor is email and {factor}_verified otherwise (recovery=None is never verified); mark_recovery_verified(db, user) writes the right column. Principal gains recovery_verified (populated by build_principal) alongside the kept email_verified. current_user(verified=True) now gates on recovery_verified and raises at construction only when recovery is None (an account shape with nothing to prove control of), not when email is absent. For an email-recovery app this is identical to before; for a phone app it finally works.

Why this shape: the contract already knows the recovery factor (identity.recovery), so "verified" should mean that factor is proven, not "an email is proven." email_verified stays the column for email apps (back-compat), and the concept generalizes above it.


The proof is the same machinery, re-pointed (security core untouched)

email_verified=True was always backed by a real proof: the user returned a signed, one-time-use, TTL'd token delivered to the address being verified. Generalizing had to preserve that exactly, or it would be a silent downgrade to "some channel said OK."

It does. The redemption core is byte-for-byte unchanged: verify_signed_token, _consume (one-time-use), and the TTL are not touched. The only diffs in confirm_* are the verified read/write switching to recovery_verified / mark_recovery_verified. On the request side, the token is now looked up by, and delivered to, the recovery factor: DeliveryIntent.recipient = repo.get(user, identity.recovery). This is where PR 1's deferred "recipient is still email" gap closes, for both the verify and the password-reset flows (reset also delivers to the factor). change_email stays email-shaped (it intrinsically proves a new email address).


The verified flag is as unsettable as email_verified always was

email_verified was protected on every write path because it is a LOGICAL_FIELDS member. A non-email {factor}_verified column is not, so it had to be gated explicitly or it would be a settable-without-proof downgrade. repo._recovery_verified_col() is unioned into both _gated_names (the register allowlist) and _contract_names (the provisioning filter), so {factor}_verified is dropped on all three write paths: register (even if opted into register_extra_fields), new_user_defaults (gated at construction), and new_user_fields (gated via filter_provisioning_data). The only way to set it remains returning the delivered token.


The mixin emits {factor}_verified; names made factor-neutral

make_auth_identity(recovery="phone") now emits a phone_verified bookkeeping column (NOT NULL, default False); recovery="email" does not double-emit (the always-present email_verified is reused); recovery=None emits no extra flag. The app still declares the factor column itself (phone, with its own constraints), the same as any app column.

With the behavior generalized, the names were renamed so the surface stops lying for non-email apps. The verify_email delivery kind is kept for email recovery (so existing EmailSender implementations that switch on it are unaffected) and a factor-neutral verify_recovery is emitted for any other factor, so a channel never gets an "email"-named verify for a phone. The service methods became request_recovery_verification / confirm_recovery_verification, and the public hook on_after_email_verified became on_after_recovery_verified. Names that are genuinely email-specific kept their names: on_after_email_changed and OAuth's email_verified (both prove an actual email, per the convention's test), and the /verify-email default path (user-facing config).


Documentation Updates

docs/guides/infra/hooks.md:

  • The hooks table row on_after_email_verified becomes on_after_recovery_verified ("recovery-factor verification confirm").

docs/guides/accounts/email.md:

  • The "Hooks" prose names on_after_recovery_verified instead of on_after_email_verified.

(The API reference page renders AuthHooks via mkdocstrings, so the renamed field updates automatically.)


Test Plan

Automated

  • uv run ruff check crudauth tests — clean
  • uv run mypy crudauth tests --config-file pyproject.toml — clean (100 files)
  • uv run pytest — 294 passed (the pre-existing email suite unchanged, the back-compat anchor)

Verification is proof of the recovery factor

  • Phone verify requires returning the delivered token: invalid/replayed token does not verify, valid token does (test_phone_verify_requires_the_delivered_token)
  • recovery_verified reads the right column per factor: email → email_verified, phone → phone_verified, recovery=None → always False (test_recovery_verified_reads_right_column_per_factor)
  • Email recovery: recovery_verified equals email_verified (test_email_recovery_equals_email_verified)
  • current_user(verified=True): raises at construction for recovery=None; 403 unverified then 200 after verifying (test_verified_gate_requires_recovery_factor, test_phone_verify_delivers_to_phone_and_gates_on_it)

Delivery re-pointed to the factor

  • Verify delivers to the phone, with the factor-neutral verify_recovery kind (test_phone_verify_delivers_to_phone_and_gates_on_it)
  • Password reset delivers to the phone too (test_phone_reset_delivers_to_the_phone)

The flag is unsettable on every write path

  • {factor}_verified dropped from register (even opted in) and provisioning (test_factor_verified_unsettable_via_register_and_provisioning)
  • {factor}_verified dropped from new_user_defaults at construction (test_factor_verified_unsettable_via_new_user_defaults)

Mixin factory

  • recovery="phone" emits phone_verified; recovery="email" does not double-emit; recovery=None emits no extra flag (test_factory_*)

Dependencies

None new.

Breaking Changes

  • AuthHooks.on_after_email_verified renamed to on_after_recovery_verified (and the internal run_after_email_verified to run_after_recovery_verified). Public surface: an app registering AuthHooks(on_after_email_verified=...) must rename the kwarg. Done as a real rename, not a deprecated alias, deliberately, while the breaking-change cost is zero (pre-prod, no overriders). on_after_email_changed is unchanged (it proves an actual new email).
  • EmailFlowService.request_email_verification / confirm_email_verification renamed to request_recovery_verification / confirm_recovery_verification, and request_email_verification / request_password_reset now take a value parameter (the recovery-factor value) instead of email. EmailFlowService is constructed by CRUDAuth internally, so most apps are unaffected; direct callers must update.
  • current_user(verified=True) semantics generalized. It now gates on recovery_verified and raises at construction only for recovery=None (previously: required an email column). Email-recovery apps behave identically; a non-email app that previously hit the placeholder raise now works.
  • Additive (not breaking): UserRepository(recovery=...) parameter and recovery_verified/mark_recovery_verified methods (defaults preserve email behavior); Principal.recovery_verified field (email_verified kept); the verify_recovery value added to EmailKind (email recovery still emits verify_email); make_auth_identity emits {factor}_verified only for non-email factors (the default shape is unchanged).

@igorbenav
igorbenav merged commit 5a85d92 into main Jun 20, 2026
16 checks passed
@igorbenav
igorbenav deleted the recovery-verification branch June 20, 2026 06:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant