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
711from typing import Annotated , Any
812
913from fastapi import APIRouter , Depends
10- from pydantic import BaseModel , EmailStr , Field
14+ from pydantic import BaseModel , EmailStr , Field , create_model
1115
1216from ..constants import MIN_PASSWORD_LENGTH
1317from ..principal import Principal
1721__all__ = ["build_email_router" ]
1822
1923
20- class _EmailIn (BaseModel ):
21- email : EmailStr
22-
23-
2424class _TokenIn (BaseModel ):
2525 token : str
2626
@@ -36,67 +36,88 @@ class _ChangeIn(BaseModel):
3636
3737
3838def 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
0 commit comments