Skip to content

Commit 78ea9c0

Browse files
committed
Make recovery verify/reset endpoints factor-aware (phone recovery over HTTP)
1 parent 39a64e7 commit 78ea9c0

2 files changed

Lines changed: 95 additions & 39 deletions

File tree

crudauth/email/router.py

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
"""Builds the email-flow endpoints (verify / reset / change).
2-
3-
The three *trigger* endpoints carry a per-IP rate-limit dependency (caller
4-
spray); the service additionally enforces a silent per-target-email cap.
1+
"""Builds the recovery-flow endpoints (verify / reset / change).
2+
3+
The trigger endpoints carry a per-IP rate-limit dependency (caller spray); the
4+
service additionally enforces a silent per-target cap. The verify and reset
5+
request bodies are shaped to the contract's recovery factor (``email`` validated
6+
as an address, any other factor as a plain string), so a phone-recovery app can
7+
drive them over HTTP with a phone number. Change-email is email-specific and only
8+
mounts when the model actually has an email column.
59
"""
610

711
from typing import Annotated, Any
812

913
from fastapi import APIRouter, Depends
10-
from pydantic import BaseModel, EmailStr, Field
14+
from pydantic import BaseModel, EmailStr, Field, create_model
1115

1216
from ..constants import MIN_PASSWORD_LENGTH
1317
from ..principal import Principal
@@ -17,10 +21,6 @@
1721
__all__ = ["build_email_router"]
1822

1923

20-
class _EmailIn(BaseModel):
21-
email: EmailStr
22-
23-
2424
class _TokenIn(BaseModel):
2525
token: str
2626

@@ -36,67 +36,88 @@ class _ChangeIn(BaseModel):
3636

3737

3838
def build_email_router(*, auth: Any, service: EmailFlowService) -> APIRouter:
39-
"""Build the email-flow router (verify / reset / change endpoints).
39+
"""Build the recovery-flow router (verify / reset, plus change-email when applicable).
40+
41+
The verify and reset request bodies are generated for the recovery factor: an
42+
email-recovery app keeps ``{"email": ...}`` (validated as an address), a
43+
phone-recovery app gets ``{"phone": ...}``. Change-email endpoints are added
44+
only when the model has an ``email`` column, since they prove a real address.
4045
4146
Args:
4247
auth: The owning [CRUDAuth][crudauth.crud_auth.CRUDAuth] (for ``session``,
4348
``current_user``, and ``rate_limit`` dependencies).
4449
service: The [EmailFlowService][crudauth.email.service.EmailFlowService] that mints/verifies tokens.
4550
4651
Returns:
47-
An `APIRouter` with the six email endpoints.
52+
An `APIRouter` with the recovery endpoints.
4853
"""
4954
router = APIRouter(tags=["auth:email"])
5055
db_dep = auth.session
5156
user_dep = auth.current_user()
5257

58+
factor = service.repo.recovery
59+
if factor is None:
60+
raise RuntimeError("the recovery router requires a recovery factor (identity.recovery)")
61+
field_type: Any = EmailStr if factor == "email" else str
62+
fields: dict[str, Any] = {factor: (field_type, ...)}
63+
RecoveryRequestModel = create_model("RecoveryRequestIn", **fields)
64+
channel_noun = "email" if factor == "email" else "message"
65+
5366
@router.post(
5467
"/email/verify-request",
5568
dependencies=[Depends(auth.rate_limit("email_verify_request", key=KeyBy.IP))],
5669
)
57-
async def request_verification(body: _EmailIn, db: Annotated[Any, Depends(db_dep)]):
58-
"""Send an email-verification link. Always returns success (no enumeration)."""
59-
await service.request_recovery_verification(db, body.email)
60-
return {"detail": "If an account exists, a verification email has been sent."}
70+
async def request_verification(
71+
body: RecoveryRequestModel, # type: ignore[valid-type]
72+
db: Annotated[Any, Depends(db_dep)],
73+
):
74+
"""Send a verification link to the recovery factor. Always succeeds (no enumeration)."""
75+
await service.request_recovery_verification(db, getattr(body, factor))
76+
return {"detail": f"If an account exists, a verification {channel_noun} has been sent."}
6177

6278
@router.post("/email/verify-confirm")
6379
async def confirm_verification(body: _TokenIn, db: Annotated[Any, Depends(db_dep)]):
64-
"""Confirm a verification token and mark the email verified."""
80+
"""Confirm a verification token and mark the recovery factor verified."""
6581
await service.confirm_recovery_verification(db, body.token)
66-
return {"detail": "Email verified successfully."}
82+
return {"detail": "Verified successfully."}
6783

6884
@router.post(
6985
"/password/reset-request",
7086
dependencies=[Depends(auth.rate_limit("password_reset_request", key=KeyBy.IP))],
7187
)
72-
async def request_reset(body: _EmailIn, db: Annotated[Any, Depends(db_dep)]):
73-
"""Send a password-reset link. Always returns success (no enumeration)."""
74-
await service.request_password_reset(db, body.email)
75-
return {"detail": "If an account exists, a password reset email has been sent."}
88+
async def request_reset(
89+
body: RecoveryRequestModel, # type: ignore[valid-type]
90+
db: Annotated[Any, Depends(db_dep)],
91+
):
92+
"""Send a password-reset link to the recovery factor. Always succeeds (no enumeration)."""
93+
await service.request_password_reset(db, getattr(body, factor))
94+
return {"detail": f"If an account exists, a password reset {channel_noun} has been sent."}
7695

7796
@router.post("/password/reset-confirm")
7897
async def reset(body: _ResetIn, db: Annotated[Any, Depends(db_dep)]):
7998
"""Reset the password from a valid token and evict the user's other sessions."""
8099
await service.reset_password(db, body.token, body.new_password)
81100
return {"detail": "Password reset successfully."}
82101

83-
@router.post(
84-
"/email/change-request",
85-
dependencies=[Depends(auth.rate_limit("email_change_request", key=KeyBy.IP))],
86-
)
87-
async def change_request(
88-
body: _ChangeIn,
89-
db: Annotated[Any, Depends(db_dep)],
90-
principal: Annotated[Principal, Depends(user_dep)],
91-
):
92-
"""Request an email change (authenticated; re-auth via current password)."""
93-
await service.request_email_change(db, principal.user, body.new_email, body.password)
94-
return {"detail": "If the address is available, a confirmation email has been sent."}
95-
96-
@router.post("/email/change-confirm")
97-
async def change_confirm(body: _TokenIn, db: Annotated[Any, Depends(db_dep)]):
98-
"""Confirm an email-change token and apply the new address."""
99-
await service.confirm_email_change(db, body.token)
100-
return {"detail": "Email changed successfully."}
102+
if service.repo.has("email"):
103+
104+
@router.post(
105+
"/email/change-request",
106+
dependencies=[Depends(auth.rate_limit("email_change_request", key=KeyBy.IP))],
107+
)
108+
async def change_request(
109+
body: _ChangeIn,
110+
db: Annotated[Any, Depends(db_dep)],
111+
principal: Annotated[Principal, Depends(user_dep)],
112+
):
113+
"""Request an email change (authenticated; re-auth via current password)."""
114+
await service.request_email_change(db, principal.user, body.new_email, body.password)
115+
return {"detail": "If the address is available, a confirmation email has been sent."}
116+
117+
@router.post("/email/change-confirm")
118+
async def change_confirm(body: _TokenIn, db: Annotated[Any, Depends(db_dep)]):
119+
"""Confirm an email-change token and apply the new address."""
120+
await service.confirm_email_change(db, body.token)
121+
return {"detail": "Email changed successfully."}
101122

102123
return router

tests/test_recovery_verification.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,38 @@ async def test_phone_verify_requires_the_delivered_token() -> None:
272272
db, token
273273
) # replay rejected
274274
assert repo.recovery_verified(await repo.get_by_field(db, "phone", "777")) is True
275+
276+
277+
async def test_phone_verify_and_reset_work_over_http() -> None:
278+
# the closed seam: the built-in request endpoints accept the recovery factor
279+
# (phone) over HTTP, not an email, so phone recovery is end-to-end over HTTP.
280+
async with _phone_app() as (auth, client, maker, channel):
281+
async with maker() as db:
282+
await auth.repo.create(
283+
db, {"username": "neo", "phone": "555", "hashed_password": get_password_hash("pw")}
284+
)
285+
286+
r = await client.post("/email/verify-request", json={"phone": "555"})
287+
assert r.status_code == 200
288+
assert channel.intents[-1].recipient == "555"
289+
assert channel.intents[-1].kind == "verify_recovery"
290+
291+
r = await client.post("/password/reset-request", json={"phone": "555"})
292+
assert r.status_code == 200
293+
assert channel.intents[-1].recipient == "555"
294+
assert channel.intents[-1].kind == "reset_password"
295+
296+
297+
async def test_phone_request_body_is_phone_shaped_not_email() -> None:
298+
# the request body is shaped to the factor: a phone app's verify-request wants
299+
# "phone", so posting "email" is a 422 (missing required field).
300+
async with _phone_app() as (auth, client, _maker, _channel):
301+
r = await client.post("/email/verify-request", json={"email": "a@x.com"})
302+
assert r.status_code == 422
303+
304+
305+
async def test_email_less_phone_app_mounts_no_change_email() -> None:
306+
# change-email proves a real address; an email-less phone app doesn't mount it.
307+
async with _phone_app() as (auth, client, _maker, _channel):
308+
r = await client.post("/email/change-request", json={"new_email": "a@x.com", "password": "pw"})
309+
assert r.status_code == 404

0 commit comments

Comments
 (0)