Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions wacruit/src/apps/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from .views import v3_router as router

__all__ = ["router"]
15 changes: 15 additions & 0 deletions wacruit/src/apps/auth/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,18 @@ def __init__(self):
class EmailConflictException(WacruitException):
def __init__(self):
super().__init__(status_code=409, detail="이미 존재하는 이메일입니다.")


class InvalidPasswordResetCodeException(WacruitException):
def __init__(self):
super().__init__(status_code=400, detail="인증 번호가 올바르지 않습니다.")


class ExpiredPasswordResetCodeException(WacruitException):
def __init__(self):
super().__init__(status_code=400, detail="만료된 인증 번호입니다.")


class PasswordResetCodeNotVerifiedException(WacruitException):
def __init__(self):
super().__init__(status_code=400, detail="인증 번호 확인이 필요합니다.")
21 changes: 21 additions & 0 deletions wacruit/src/apps/auth/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from datetime import datetime

from sqlalchemy import DateTime
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

from wacruit.src.apps.common.sql import CURRENT_TIMESTAMP
from wacruit.src.database.base import DeclarativeBase
from wacruit.src.database.base import intpk
from wacruit.src.database.base import str255
Expand All @@ -10,3 +15,19 @@ class BlockedToken(DeclarativeBase):

id: Mapped[intpk]
token: Mapped[str255]


class PasswordResetVerification(DeclarativeBase):
__tablename__ = "password_reset_verification"

id: Mapped[intpk]
email: Mapped[str255] = mapped_column(index=True)
code_hash: Mapped[str255]
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
attempt_count: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=CURRENT_TIMESTAMP,
)
105 changes: 104 additions & 1 deletion wacruit/src/apps/auth/repositories.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from datetime import datetime
from typing import Annotated

from fastapi import Depends
from pydantic import EmailStr
from sqlalchemy.orm import Session

from wacruit.src.apps.auth.models import BlockedToken
from wacruit.src.apps.auth.models import PasswordResetVerification
from wacruit.src.apps.user.models import User
from wacruit.src.database.connection import get_db_session
from wacruit.src.database.connection import Transaction
from wacruit.src.database.connection import get_db_session


class AuthRepository:
Expand All @@ -25,6 +27,107 @@ def get_user_by_email(self, email: EmailStr) -> User | None:
def get_user_by_id(self, user_id: int) -> User | None:
return self.session.query(User).where(User.id == user_id).first()

def update_user(self, user: User) -> User:
with self.transaction:
self.session.merge(user)
return user

def create_password_reset_verification(
self, verification: PasswordResetVerification
) -> PasswordResetVerification:
with self.transaction:
self.session.add(verification)
return verification

def replace_active_password_reset_for_email(
self,
email: EmailStr,
verification: PasswordResetVerification,
expires_at: datetime,
) -> PasswordResetVerification:
with self.transaction:
(
self.session.query(PasswordResetVerification)
.where(
PasswordResetVerification.email == email,
PasswordResetVerification.used_at.is_(None),
)
.update(
{PasswordResetVerification.expires_at: expires_at},
synchronize_session="fetch",
)
)
self.session.add(verification)
return verification

def get_latest_password_reset_verification(
self, email: EmailStr
) -> PasswordResetVerification | None:
return (
self.session.query(PasswordResetVerification)
.where(PasswordResetVerification.email == email)
.order_by(
PasswordResetVerification.created_at.desc(),
PasswordResetVerification.id.desc(),
)
.first()
)

def update_password_reset_verification(
self, verification: PasswordResetVerification
) -> PasswordResetVerification:
with self.transaction:
self.session.merge(verification)
return verification

def consume_password_reset_verification(
self,
verification_id: int,
email: EmailStr,
password_hash: str,
used_at: datetime,
) -> bool:
with self.transaction:
verification = (
self.session.query(PasswordResetVerification)
.where(
PasswordResetVerification.id == verification_id,
PasswordResetVerification.email == email,
)
.with_for_update()
.first()
)
if verification is None or verification.used_at is not None:
return False

user = (
self.session.query(User)
.where(User.email == email)
.with_for_update()
.first()
)
if user is None:
return False

user.password = password_hash
verification.used_at = used_at
return True

def expire_unused_password_reset_verifications(
self, email: EmailStr, expires_at: datetime
) -> None:
verifications = (
self.session.query(PasswordResetVerification)
.where(
PasswordResetVerification.email == email,
PasswordResetVerification.used_at.is_(None),
)
.all()
)
with self.transaction:
for verification in verifications:
verification.expires_at = expires_at

def is_blocked_token(self, token: str) -> bool:
result = (
self.session.query(BlockedToken).where(BlockedToken.token == token).first()
Expand Down
16 changes: 16 additions & 0 deletions wacruit/src/apps/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pydantic import BaseModel
from pydantic import EmailStr
from pydantic import Field


class LoginRequest(BaseModel):
Expand All @@ -14,3 +15,18 @@ class TokenResponse(BaseModel):

class UserCheckRequest(BaseModel):
email: EmailStr


class PasswordResetEmailRequest(BaseModel):
email: EmailStr


class PasswordResetVerifyRequest(BaseModel):
email: EmailStr
code: str = Field(..., min_length=6, max_length=6)


class PasswordResetRequest(BaseModel):
email: EmailStr
code: str = Field(..., min_length=6, max_length=6)
new_password: str = Field(..., min_length=8, max_length=50)
89 changes: 87 additions & 2 deletions wacruit/src/apps/auth/services.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
from datetime import datetime
from datetime import timedelta
import secrets
from typing import Annotated

from authlib.jose import jwt
from authlib.jose import JWTClaims
from authlib.jose import jwt
from authlib.jose.errors import JoseError
from fastapi import Depends
from pydantic import EmailStr

from wacruit.src.apps.auth.exceptions import ExpiredPasswordResetCodeException
from wacruit.src.apps.auth.exceptions import InvalidPasswordResetCodeException
from wacruit.src.apps.auth.exceptions import InvalidTokenException
from wacruit.src.apps.auth.exceptions import PasswordResetCodeNotVerifiedException
from wacruit.src.apps.auth.exceptions import UserNotFoundException
from wacruit.src.apps.auth.models import PasswordResetVerification
from wacruit.src.apps.auth.repositories import AuthRepository
from wacruit.src.apps.common.security import get_token_secret
from wacruit.src.apps.common.security import PasswordService
from wacruit.src.apps.common.security import get_token_secret
from wacruit.src.apps.mail.services import EmailService
from wacruit.src.apps.user.models import User

PASSWORD_RESET_CODE_LENGTH = 6
PASSWORD_RESET_CODE_TTL_MINUTES = 10
PASSWORD_RESET_MAX_ATTEMPTS = 5


class AuthService:
def __init__(
self,
auth_repository: Annotated[AuthRepository, Depends()],
token_secret: Annotated[str, Depends(get_token_secret)],
email_service: Annotated[EmailService, Depends()],
) -> None:
self.auth_repository = auth_repository
self.token_secret = token_secret
self.email_service = email_service

def get_user_by_id(self, user_id: int) -> User | None:
return self.auth_repository.get_user_by_id(user_id)
Expand Down Expand Up @@ -89,3 +101,76 @@ def check_available_email(self, email: EmailStr) -> bool:
if user:
return False
return True

def send_password_reset_email(self, email: EmailStr) -> None:
user = self.auth_repository.get_user_by_email(email)
if user is None:
return

code = self._generate_password_reset_code()
now = datetime.now()
expires_at = now + timedelta(minutes=PASSWORD_RESET_CODE_TTL_MINUTES)

self.auth_repository.replace_active_password_reset_for_email(
email,
PasswordResetVerification(
email=email,
code_hash=PasswordService.hash_password(code),
expires_at=expires_at,
),
now,
)
self.email_service.send_password_reset_code(str(email), code)

def verify_password_reset_code(self, email: EmailStr, code: str) -> None:
verification = self._get_valid_password_reset_verification(email, code)
verification.verified_at = datetime.now()
self.auth_repository.update_password_reset_verification(verification)

def reset_password(self, email: EmailStr, code: str, new_password: str) -> None:
verification = self._get_valid_password_reset_verification(email, code)
if verification.verified_at is None:
raise PasswordResetCodeNotVerifiedException()

user = self.auth_repository.get_user_by_email(email)
if user is None:
raise UserNotFoundException()

consumed = self.auth_repository.consume_password_reset_verification(
verification.id,
email,
PasswordService.hash_password(new_password),
datetime.now(),
)
if not consumed:
raise InvalidPasswordResetCodeException()

def _generate_password_reset_code(self) -> str:
max_value = 10**PASSWORD_RESET_CODE_LENGTH
return str(secrets.randbelow(max_value)).zfill(PASSWORD_RESET_CODE_LENGTH)

def _get_valid_password_reset_verification(
self, email: EmailStr, code: str
) -> PasswordResetVerification:
if not code.isdigit():
raise InvalidPasswordResetCodeException()

verification = self.auth_repository.get_latest_password_reset_verification(
email
)
if verification is None or verification.used_at is not None:
raise InvalidPasswordResetCodeException()

now = datetime.now()
if verification.expires_at < now:
raise ExpiredPasswordResetCodeException()

if verification.attempt_count >= PASSWORD_RESET_MAX_ATTEMPTS:
raise InvalidPasswordResetCodeException()

if not PasswordService.verify_password(code, verification.code_hash):
verification.attempt_count += 1
self.auth_repository.update_password_reset_verification(verification)
raise InvalidPasswordResetCodeException()

return verification
Loading
Loading