Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 0 additions & 2 deletions app/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
from fastapi import APIRouter

from app.api.v1.endpoints.application import router as application_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.deposit import router as deposit_router
from app.api.v1.endpoints.event import router as event_router
from app.api.v1.endpoints.s3 import router as s3_router
from app.api.v1.endpoints.store import router as store_router

router = APIRouter()
router.include_router(auth_router)
router.include_router(store_router)
router.include_router(event_router)
router.include_router(application_router)
Expand Down
Empty file added app/auth/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions app/auth/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.repository import EmailVerificationRepository, UserRepository
from app.auth.repository_impl import EmailVerificationRepositoryImpl, UserRepositoryImpl
from app.auth.service import AuthService
from app.auth.service_impl import AuthServiceImpl
from app.db.session import get_db
from app.email.service import EmailSender
from app.email.service_impl import SmtpEmailSender


def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepository:
return UserRepositoryImpl(db)


def get_email_verification_repository(
db: AsyncSession = Depends(get_db),
) -> EmailVerificationRepository:
return EmailVerificationRepositoryImpl(db)


def get_email_sender() -> EmailSender:
return SmtpEmailSender()


def get_auth_service(
user_repo: UserRepository = Depends(get_user_repository),
ev_repo: EmailVerificationRepository = Depends(get_email_verification_repository),
email_sender: EmailSender = Depends(get_email_sender),
) -> AuthService:
return AuthServiceImpl(user_repo, ev_repo, email_sender)
41 changes: 41 additions & 0 deletions app/auth/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4

from sqlalchemy import Boolean, DateTime, Enum, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func

from app.db.base import Base

if TYPE_CHECKING:
from app.models.deposit import Deposit
Comment on lines +11 to +12

@Yeonu-Kim Yeonu-Kim Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deposit 안 쓰니까 제거해야 됨
-> 별도 issue로 뽑아두기



class User(Base):
__tablename__ = "users"

id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
username: Mapped[str] = mapped_column(String(50))
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String)
role: Mapped[str] = mapped_column(Enum("OWNER", "REVIEWER", name="user_role"))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

deposits: Mapped[list["Deposit"]] = relationship("Deposit", back_populates="user")


class EmailVerification(Base):
__tablename__ = "email_verifications"

id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
email: Mapped[str] = mapped_column(String(255), index=True)
code: Mapped[str] = mapped_column(String(6))
verification_token: Mapped[str | None] = mapped_column(String, nullable=True)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
27 changes: 27 additions & 0 deletions app/auth/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from abc import ABC, abstractmethod

from app.auth.models import EmailVerification, User


class UserRepository(ABC):
@abstractmethod
async def find_by_email(self, email: str) -> User | None: ...

@abstractmethod
async def find_by_id(self, user_id: str) -> User | None: ...

@abstractmethod
async def save(self, user: User) -> User: ...


class EmailVerificationRepository(ABC):
@abstractmethod
async def save(self, verification: EmailVerification) -> None: ...

@abstractmethod
async def find_latest_unverified(
self, email: str, code: str
) -> EmailVerification | None: ...

@abstractmethod
async def update(self, verification: EmailVerification) -> None: ...
61 changes: 61 additions & 0 deletions app/auth/repository_impl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from datetime import UTC, datetime
from typing import cast
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.models import EmailVerification, User
from app.auth.repository import EmailVerificationRepository, UserRepository


class UserRepositoryImpl(UserRepository):
def __init__(self, db: AsyncSession) -> None:
self.db = db

async def find_by_email(self, email: str) -> User | None:
return cast(
User | None,
await self.db.scalar(select(User).where(User.email == email)),
)

async def find_by_id(self, user_id: str) -> User | None:
return cast(
User | None,
await self.db.scalar(select(User).where(User.id == UUID(user_id))),
)

async def save(self, user: User) -> User:
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
return user


class EmailVerificationRepositoryImpl(EmailVerificationRepository):
def __init__(self, db: AsyncSession) -> None:
self.db = db

async def save(self, verification: EmailVerification) -> None:
self.db.add(verification)
await self.db.commit()

async def find_latest_unverified(
self, email: str, code: str
) -> EmailVerification | None:
return cast(
EmailVerification | None,
await self.db.scalar(
select(EmailVerification)
.where(
EmailVerification.email == email,
EmailVerification.code == code,
EmailVerification.is_verified.is_(False),
EmailVerification.expires_at > datetime.now(UTC),
)
.order_by(EmailVerification.created_at.desc())
),
)

async def update(self, verification: EmailVerification) -> None:
await self.db.commit()
Comment on lines +35 to +61

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인증코드 저장 로직은 별도 repo로 분리 (User 생성하는 거랑 로직상으로 차이가 있음. 한방에 묶어서 관리하기보다는 별도로 분리했을 때 추후 디버깅할 때 여기만 보면 되니까 유리할 듯

44 changes: 23 additions & 21 deletions app/api/v1/endpoints/auth.py → app/auth/router.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.v1.deps import require_login
from app.core.exceptions import AUTH_001, AppException
from app.db.session import get_db
from app.schemas.auth import (
from app.auth.dependencies import get_auth_service
from app.auth.schemas import (
AccessTokenResp,
AuthResp,
ChangePasswordReq,
Expand All @@ -17,43 +15,44 @@
ResetPasswordReq,
UserInfo,
)
from app.services import auth as auth_service
from app.auth.service import AuthService
from app.core.exceptions import AUTH_001, AppException

router = APIRouter(prefix="/auth", tags=["Auth"])


@router.post("/email", response_model=None)
async def check_email(
body: EmailCheckReq,
db: AsyncSession = Depends(get_db),
service: AuthService = Depends(get_auth_service),
) -> None:
await auth_service.check_email_duplicate(db, body.email)
await service.check_email_duplicate(body.email)


@router.post("/email/verify", response_model=None)
async def verify_email(
body: EmailVerifyReq,
db: AsyncSession = Depends(get_db),
service: AuthService = Depends(get_auth_service),
) -> None:
await auth_service.send_verification_code(db, body.email)
await service.send_verification_code(body.email)


@router.post("/email/validate", response_model=EmailValidateResp)
async def validate_email(
body: EmailValidateReq,
db: AsyncSession = Depends(get_db),
service: AuthService = Depends(get_auth_service),
) -> EmailValidateResp:
token = await auth_service.validate_verification_code(db, body.email, body.code)
token = await service.validate_verification_code(body.email, body.code)
return EmailValidateResp(verificationToken=token)


@router.post("/user", response_model=AuthResp)
async def register(
body: RegisterReq,
response: Response,
db: AsyncSession = Depends(get_db),
service: AuthService = Depends(get_auth_service),
) -> AuthResp:
user, access_token, refresh_token = await auth_service.register(db, body)
user, access_token, refresh_token = await service.register(body)
response.set_cookie(
key="refresh_token",
value=refresh_token,
Expand All @@ -67,21 +66,24 @@ async def register(


@router.get("/token", response_model=AccessTokenResp)
async def refresh_token(request: Request) -> AccessTokenResp:
async def refresh_token(
request: Request,
service: AuthService = Depends(get_auth_service),
) -> AccessTokenResp:
token = request.cookies.get("refresh_token")
if not token:
raise AppException(AUTH_001)
access_token = auth_service.refresh_access_token(token)
access_token = service.refresh_access_token(token)
return AccessTokenResp(accessToken=access_token)


@router.post("/user/session", response_model=AuthResp)
async def login(
body: LoginReq,
response: Response,
db: AsyncSession = Depends(get_db),
service: AuthService = Depends(get_auth_service),
) -> AuthResp:
user, access_token, refresh_token = await auth_service.login(db, body)
user, access_token, refresh_token = await service.login(body)
response.set_cookie(
key="refresh_token",
value=refresh_token,
Expand All @@ -105,15 +107,15 @@ async def logout(
@router.patch("/password", response_model=None)
async def change_password(
body: ChangePasswordReq,
db: AsyncSession = Depends(get_db),
service: AuthService = Depends(get_auth_service),
user_id: str = Depends(require_login),
) -> None:
await auth_service.change_password(db, user_id, body.oldPassword, body.newPassword)
await service.change_password(user_id, body.oldPassword, body.newPassword)


@router.post("/password", response_model=None)
async def reset_password(
body: ResetPasswordReq,
db: AsyncSession = Depends(get_db),
service: AuthService = Depends(get_auth_service),
) -> None:
await auth_service.reset_password(db, body)
await service.reset_password(body)
File renamed without changes.
32 changes: 32 additions & 0 deletions app/auth/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from abc import ABC, abstractmethod

from app.auth.models import User
from app.auth.schemas import LoginReq, RegisterReq, ResetPasswordReq


class AuthService(ABC):
@abstractmethod
async def check_email_duplicate(self, email: str) -> None: ...

@abstractmethod
async def send_verification_code(self, email: str) -> None: ...

@abstractmethod
async def validate_verification_code(self, email: str, code: str) -> str: ...

@abstractmethod
async def register(self, data: RegisterReq) -> tuple[User, str, str]: ...

@abstractmethod
def refresh_access_token(self, refresh_token: str) -> str: ...

@abstractmethod
async def login(self, data: LoginReq) -> tuple[User, str, str]: ...

@abstractmethod
async def change_password(
self, user_id: str, old_password: str, new_password: str
) -> None: ...

@abstractmethod
async def reset_password(self, data: ResetPasswordReq) -> None: ...
Loading
Loading