|
1 | 1 | from datetime import datetime |
2 | 2 | from datetime import timedelta |
| 3 | +import secrets |
3 | 4 | from typing import Annotated |
4 | 5 |
|
5 | | -from authlib.jose import jwt |
6 | 6 | from authlib.jose import JWTClaims |
| 7 | +from authlib.jose import jwt |
7 | 8 | from authlib.jose.errors import JoseError |
8 | 9 | from fastapi import Depends |
9 | 10 | from pydantic import EmailStr |
10 | 11 |
|
| 12 | +from wacruit.src.apps.auth.exceptions import ExpiredPasswordResetCodeException |
| 13 | +from wacruit.src.apps.auth.exceptions import InvalidPasswordResetCodeException |
11 | 14 | from wacruit.src.apps.auth.exceptions import InvalidTokenException |
| 15 | +from wacruit.src.apps.auth.exceptions import PasswordResetCodeNotVerifiedException |
12 | 16 | from wacruit.src.apps.auth.exceptions import UserNotFoundException |
| 17 | +from wacruit.src.apps.auth.models import PasswordResetVerification |
13 | 18 | from wacruit.src.apps.auth.repositories import AuthRepository |
14 | | -from wacruit.src.apps.common.security import get_token_secret |
15 | 19 | from wacruit.src.apps.common.security import PasswordService |
| 20 | +from wacruit.src.apps.common.security import get_token_secret |
| 21 | +from wacruit.src.apps.mail.services import EmailService |
16 | 22 | from wacruit.src.apps.user.models import User |
17 | 23 |
|
| 24 | +PASSWORD_RESET_CODE_LENGTH = 6 |
| 25 | +PASSWORD_RESET_CODE_TTL_MINUTES = 10 |
| 26 | +PASSWORD_RESET_MAX_ATTEMPTS = 5 |
| 27 | + |
18 | 28 |
|
19 | 29 | class AuthService: |
20 | 30 | def __init__( |
21 | 31 | self, |
22 | 32 | auth_repository: Annotated[AuthRepository, Depends()], |
23 | 33 | token_secret: Annotated[str, Depends(get_token_secret)], |
| 34 | + email_service: Annotated[EmailService, Depends()], |
24 | 35 | ) -> None: |
25 | 36 | self.auth_repository = auth_repository |
26 | 37 | self.token_secret = token_secret |
| 38 | + self.email_service = email_service |
27 | 39 |
|
28 | 40 | def get_user_by_id(self, user_id: int) -> User | None: |
29 | 41 | return self.auth_repository.get_user_by_id(user_id) |
@@ -89,3 +101,76 @@ def check_available_email(self, email: EmailStr) -> bool: |
89 | 101 | if user: |
90 | 102 | return False |
91 | 103 | return True |
| 104 | + |
| 105 | + def send_password_reset_email(self, email: EmailStr) -> None: |
| 106 | + user = self.auth_repository.get_user_by_email(email) |
| 107 | + if user is None: |
| 108 | + return |
| 109 | + |
| 110 | + code = self._generate_password_reset_code() |
| 111 | + now = datetime.now() |
| 112 | + expires_at = now + timedelta(minutes=PASSWORD_RESET_CODE_TTL_MINUTES) |
| 113 | + |
| 114 | + self.auth_repository.replace_active_password_reset_for_email( |
| 115 | + email, |
| 116 | + PasswordResetVerification( |
| 117 | + email=email, |
| 118 | + code_hash=PasswordService.hash_password(code), |
| 119 | + expires_at=expires_at, |
| 120 | + ), |
| 121 | + now, |
| 122 | + ) |
| 123 | + self.email_service.send_password_reset_code(str(email), code) |
| 124 | + |
| 125 | + def verify_password_reset_code(self, email: EmailStr, code: str) -> None: |
| 126 | + verification = self._get_valid_password_reset_verification(email, code) |
| 127 | + verification.verified_at = datetime.now() |
| 128 | + self.auth_repository.update_password_reset_verification(verification) |
| 129 | + |
| 130 | + def reset_password(self, email: EmailStr, code: str, new_password: str) -> None: |
| 131 | + verification = self._get_valid_password_reset_verification(email, code) |
| 132 | + if verification.verified_at is None: |
| 133 | + raise PasswordResetCodeNotVerifiedException() |
| 134 | + |
| 135 | + user = self.auth_repository.get_user_by_email(email) |
| 136 | + if user is None: |
| 137 | + raise UserNotFoundException() |
| 138 | + |
| 139 | + consumed = self.auth_repository.consume_password_reset_verification( |
| 140 | + verification.id, |
| 141 | + email, |
| 142 | + PasswordService.hash_password(new_password), |
| 143 | + datetime.now(), |
| 144 | + ) |
| 145 | + if not consumed: |
| 146 | + raise InvalidPasswordResetCodeException() |
| 147 | + |
| 148 | + def _generate_password_reset_code(self) -> str: |
| 149 | + max_value = 10**PASSWORD_RESET_CODE_LENGTH |
| 150 | + return str(secrets.randbelow(max_value)).zfill(PASSWORD_RESET_CODE_LENGTH) |
| 151 | + |
| 152 | + def _get_valid_password_reset_verification( |
| 153 | + self, email: EmailStr, code: str |
| 154 | + ) -> PasswordResetVerification: |
| 155 | + if not code.isdigit(): |
| 156 | + raise InvalidPasswordResetCodeException() |
| 157 | + |
| 158 | + verification = self.auth_repository.get_latest_password_reset_verification( |
| 159 | + email |
| 160 | + ) |
| 161 | + if verification is None or verification.used_at is not None: |
| 162 | + raise InvalidPasswordResetCodeException() |
| 163 | + |
| 164 | + now = datetime.now() |
| 165 | + if verification.expires_at < now: |
| 166 | + raise ExpiredPasswordResetCodeException() |
| 167 | + |
| 168 | + if verification.attempt_count >= PASSWORD_RESET_MAX_ATTEMPTS: |
| 169 | + raise InvalidPasswordResetCodeException() |
| 170 | + |
| 171 | + if not PasswordService.verify_password(code, verification.code_hash): |
| 172 | + verification.attempt_count += 1 |
| 173 | + self.auth_repository.update_password_reset_verification(verification) |
| 174 | + raise InvalidPasswordResetCodeException() |
| 175 | + |
| 176 | + return verification |
0 commit comments