11from datetime import datetime
22from datetime import timedelta
3+ from datetime import timezone
4+ import secrets
35from typing import Annotated
46
5- from authlib .jose import jwt
67from authlib .jose import JWTClaims
8+ from authlib .jose import jwt
79from authlib .jose .errors import JoseError
810from fastapi import Depends
911from pydantic import EmailStr
1012
13+ from wacruit .src .apps .auth .exceptions import ExpiredPasswordResetCodeException
14+ from wacruit .src .apps .auth .exceptions import InvalidPasswordResetCodeException
1115from wacruit .src .apps .auth .exceptions import InvalidTokenException
16+ from wacruit .src .apps .auth .exceptions import PasswordResetCodeNotVerifiedException
1217from wacruit .src .apps .auth .exceptions import UserNotFoundException
18+ from wacruit .src .apps .auth .models import PasswordResetVerification
1319from wacruit .src .apps .auth .repositories import AuthRepository
14- from wacruit .src .apps .common .security import get_token_secret
1520from wacruit .src .apps .common .security import PasswordService
21+ from wacruit .src .apps .common .security import get_token_secret
22+ from wacruit .src .apps .mail .services import EmailService
1623from wacruit .src .apps .user .models import User
1724
25+ PASSWORD_RESET_CODE_LENGTH = 6
26+ PASSWORD_RESET_CODE_TTL_MINUTES = 5
27+ PASSWORD_RESET_MAX_ATTEMPTS = 5
28+
29+
30+ def _utc_now () -> datetime :
31+ return datetime .now (timezone .utc )
32+
33+
34+ def _as_utc (value : datetime ) -> datetime :
35+ if value .tzinfo is None :
36+ return value .replace (tzinfo = timezone .utc )
37+ return value .astimezone (timezone .utc )
38+
1839
1940class AuthService :
2041 def __init__ (
2142 self ,
2243 auth_repository : Annotated [AuthRepository , Depends ()],
2344 token_secret : Annotated [str , Depends (get_token_secret )],
45+ email_service : Annotated [EmailService , Depends ()],
2446 ) -> None :
2547 self .auth_repository = auth_repository
2648 self .token_secret = token_secret
49+ self .email_service = email_service
2750
2851 def get_user_by_id (self , user_id : int ) -> User | None :
2952 return self .auth_repository .get_user_by_id (user_id )
@@ -78,7 +101,7 @@ def issue_token(self, user_id: int, expiration_hour: int, token_type: str) -> st
78101 header = {"alg" : "HS256" }
79102 payload = {
80103 "sub" : user_id ,
81- "exp" : int ((datetime . now () + timedelta (hours = expiration_hour )).timestamp ()),
104+ "exp" : int ((_utc_now () + timedelta (hours = expiration_hour )).timestamp ()),
82105 "token_type" : token_type ,
83106 }
84107
@@ -89,3 +112,77 @@ def check_available_email(self, email: EmailStr) -> bool:
89112 if user :
90113 return False
91114 return True
115+
116+ def send_password_reset_email (self , email : EmailStr ) -> None :
117+ user = self .auth_repository .get_user_by_email (email )
118+ if user is None :
119+ raise UserNotFoundException ()
120+
121+ code = self ._generate_password_reset_code ()
122+ now = _utc_now ()
123+ expires_at = now + timedelta (minutes = PASSWORD_RESET_CODE_TTL_MINUTES )
124+
125+ self .auth_repository .replace_active_password_reset_for_email (
126+ email ,
127+ PasswordResetVerification (
128+ email = email ,
129+ code_hash = PasswordService .hash_password (code ),
130+ expires_at = expires_at ,
131+ ),
132+ now ,
133+ )
134+ self .auth_repository .commit ()
135+ self .email_service .send_password_reset_code (str (email ), code )
136+
137+ def verify_password_reset_code (self , email : EmailStr , code : str ) -> None :
138+ verification = self ._get_valid_password_reset_verification (email , code )
139+ verification .verified_at = _utc_now ()
140+ self .auth_repository .update_password_reset_verification (verification )
141+
142+ def reset_password (self , email : EmailStr , code : str , new_password : str ) -> None :
143+ verification = self ._get_valid_password_reset_verification (email , code )
144+ if verification .verified_at is None :
145+ raise PasswordResetCodeNotVerifiedException ()
146+
147+ user = self .auth_repository .get_user_by_email (email )
148+ if user is None :
149+ raise UserNotFoundException ()
150+
151+ consumed = self .auth_repository .consume_password_reset_verification (
152+ verification .id ,
153+ email ,
154+ PasswordService .hash_password (new_password ),
155+ _utc_now (),
156+ )
157+ if not consumed :
158+ raise InvalidPasswordResetCodeException ()
159+
160+ def _generate_password_reset_code (self ) -> str :
161+ max_value = 10 ** PASSWORD_RESET_CODE_LENGTH
162+ return str (secrets .randbelow (max_value )).zfill (PASSWORD_RESET_CODE_LENGTH )
163+
164+ def _get_valid_password_reset_verification (
165+ self , email : EmailStr , code : str
166+ ) -> PasswordResetVerification :
167+ if not code .isdigit ():
168+ raise InvalidPasswordResetCodeException ()
169+
170+ verification = self .auth_repository .get_latest_password_reset_verification (
171+ email
172+ )
173+ if verification is None or verification .used_at is not None :
174+ raise InvalidPasswordResetCodeException ()
175+
176+ now = _utc_now ()
177+ if _as_utc (verification .expires_at ) < now :
178+ raise ExpiredPasswordResetCodeException ()
179+
180+ if verification .attempt_count >= PASSWORD_RESET_MAX_ATTEMPTS :
181+ raise InvalidPasswordResetCodeException ()
182+
183+ if not PasswordService .verify_password (code , verification .code_hash ):
184+ verification .attempt_count += 1
185+ self .auth_repository .update_password_reset_verification (verification )
186+ raise InvalidPasswordResetCodeException ()
187+
188+ return verification
0 commit comments