Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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,
)
51 changes: 50 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,53 @@ 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 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 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(..., max_length=50)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
84 changes: 82 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,71 @@ 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.email_service.send_password_reset_code(str(email), code)
self.auth_repository.expire_unused_password_reset_verifications(email, now)
self.auth_repository.create_password_reset_verification(
PasswordResetVerification(
email=email,
code_hash=PasswordService.hash_password(code),
expires_at=expires_at,
)
)

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()

user.password = PasswordService.hash_password(new_password)
verification.used_at = datetime.now()
self.auth_repository.update_user(user)
self.auth_repository.update_password_reset_verification(verification)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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
58 changes: 57 additions & 1 deletion wacruit/src/apps/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,26 @@
from fastapi.security import HTTPBearer

from wacruit.src.apps.auth.exceptions import EmailConflictException
from wacruit.src.apps.auth.exceptions import ExpiredPasswordResetCodeException
from wacruit.src.apps.auth.exceptions import InvalidPasswordResetCodeException
from wacruit.src.apps.auth.exceptions import PasswordResetCodeNotVerifiedException
from wacruit.src.apps.auth.exceptions import UserNotFoundException
from wacruit.src.apps.auth.schemas import LoginRequest
from wacruit.src.apps.auth.schemas import PasswordResetEmailRequest
from wacruit.src.apps.auth.schemas import PasswordResetRequest
from wacruit.src.apps.auth.schemas import PasswordResetVerifyRequest
from wacruit.src.apps.auth.schemas import TokenResponse
from wacruit.src.apps.auth.schemas import UserCheckRequest
from wacruit.src.apps.auth.services import AuthService
from wacruit.src.apps.common.exceptions import responses_from
from wacruit.src.apps.mail.exceptions import MailConfigException
from wacruit.src.apps.mail.exceptions import MailSendFailedException

v3_router = APIRouter(prefix="/v3/auth", tags=["auth"])

security = HTTPBearer(scheme_name="refresh_token", description="토큰 갱신을 위한 Bearer 토큰")
security = HTTPBearer(
scheme_name="refresh_token", description="토큰 갱신을 위한 Bearer 토큰"
)


@v3_router.post("/login")
Expand Down Expand Up @@ -43,3 +55,47 @@ def check_available_email(
res = auth_service.check_available_email(req.email)
if not res:
raise EmailConflictException()


@v3_router.post(
"/password-reset/email",
status_code=200,
responses=responses_from(MailConfigException, MailSendFailedException),
)
def send_password_reset_email(
req: PasswordResetEmailRequest,
auth_service: Annotated[AuthService, Depends()],
) -> None:
auth_service.send_password_reset_email(req.email)


@v3_router.post(
"/password-reset/verify",
status_code=200,
responses=responses_from(
InvalidPasswordResetCodeException,
ExpiredPasswordResetCodeException,
),
)
def verify_password_reset_code(
req: PasswordResetVerifyRequest,
auth_service: Annotated[AuthService, Depends()],
) -> None:
auth_service.verify_password_reset_code(req.email, req.code)


@v3_router.post(
"/password-reset",
status_code=200,
responses=responses_from(
InvalidPasswordResetCodeException,
ExpiredPasswordResetCodeException,
PasswordResetCodeNotVerifiedException,
UserNotFoundException,
),
)
def reset_password(
req: PasswordResetRequest,
auth_service: Annotated[AuthService, Depends()],
) -> None:
auth_service.reset_password(req.email, req.code, req.new_password)
Empty file.
42 changes: 42 additions & 0 deletions wacruit/src/apps/mail/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from pydantic import BaseSettings

from wacruit.src.secrets import OCISecretManager
from wacruit.src.settings import settings

_MAIL_SECRET_KEYS = {
"smtp_host": "host",
"smtp_port": "port",
"smtp_username": "username",
"smtp_password": "password",
"smtp_from_email": "from_email",
}


class MailConfig(BaseSettings):
host: str = ""
port: int = 587
username: str = ""
password: str = ""
from_email: str = ""
use_tls: bool = True

class Config(BaseSettings.Config):
case_sensitive = False
env_prefix = "SMTP_"
env_file = settings.env_files

def __init__(self):
super().__init__()
secret_manager = OCISecretManager()
if secret_manager.is_available():
self._load_from_vault(secret_manager)

def _load_from_vault(self, secret_manager: OCISecretManager) -> None:
for secret_key, attr_name in _MAIL_SECRET_KEYS.items():
value: str | int = secret_manager.get_secret(secret_key)
if attr_name == "port":
value = int(value)
setattr(self, attr_name, value)


mail_config = MailConfig()
11 changes: 11 additions & 0 deletions wacruit/src/apps/mail/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from wacruit.src.apps.common.exceptions import WacruitException


class MailConfigException(WacruitException):
def __init__(self):
super().__init__(status_code=500, detail="메일 설정이 올바르지 않습니다.")


class MailSendFailedException(WacruitException):
def __init__(self):
super().__init__(status_code=502, detail="메일 전송에 실패했습니다.")
Loading
Loading