Skip to content

Commit 10cc378

Browse files
Yeonu-Kimclaude
andauthored
✨ auth 패키지 API 구현 (#4)
* ✨ auth 구현을 위한 기반 코드 설정 - User, EmailVerification 모델 정의 - 공통 스키마(SuccessResponse, Enum) 및 auth 스키마 추가 - JWT(access/refresh/verification token) 유틸 추가 - require_login 의존성 및 라우터 등록 - main.py: prefix /api, 전역 HTTPException 핸들러 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ POST /api/auth/email — 이메일 중복 확인 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ POST /api/auth/email/verify — 인증 이메일 발송 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ POST /api/auth/email/validate — 인증 코드 검증 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ POST /api/auth/user — 회원가입 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ POST /api/auth/user/session — 로그인 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ GET /api/auth/token — 액세스 토큰 재발급 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ DELETE /api/auth/user/session — 로그아웃 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ PATCH /api/auth/password — 비밀번호 변경 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ POST /api/auth/password — 비밀번호 초기화 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 테스트 기반 설정 (aiosqlite, conftest) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 POST /api/auth/email 테스트 + bcrypt 버전 호환성 수정 passlib 1.7.4가 bcrypt 5.x와 호환되지 않아 4.x로 고정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 POST /api/auth/email/verify 테스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 POST /api/auth/email/validate 테스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 POST /api/auth/user 테스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 POST /api/auth/user/session 테스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 GET /api/auth/token 테스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 DELETE /api/auth/user/session 테스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 PATCH /api/auth/password 테스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🧪 POST /api/auth/password 테스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🐛 순환 참조 오류 수정 - 모델 임포트를 alembic/env.py로 이동 base.py에서 모델을 임포트하면 모델이 초기화되는 도중에 다시 base.py를 임포트하려 해서 순환 참조가 발생함. Alembic autogenerate를 위한 모델 임포트를 alembic/env.py로 이동. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🗄️ DB를 PostgreSQL에서 SQLite로 전환 로컬 개발 환경에서 별도 DB 서버 없이 실행 가능하도록 aiosqlite 기반 SQLite로 전환. asyncpg 의존성 제거. session.py에 SQLite용 check_same_thread 옵션 추가. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🙈 sqlite DB를 gitignore에 추가 --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 62d2979 commit 10cc378

18 files changed

Lines changed: 1057 additions & 124 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,5 @@ Thumbs.db
3636

3737
# Logs
3838
*.log
39+
40+
*.db

alembic/env.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
from app.core.config import settings
2020
from app.db.base import Base
21+
import app.models.email_verification # noqa: F401
22+
import app.models.user # noqa: F401
2123

2224
target_metadata = Base.metadata
2325

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""initial
2+
3+
Revision ID: 42fa0f495c60
4+
Revises:
5+
Create Date: 2026-06-15 11:26:24.825684
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '42fa0f495c60'
16+
down_revision: Union[str, Sequence[str], None] = None
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table('email_verifications',
25+
sa.Column('id', sa.Uuid(), nullable=False),
26+
sa.Column('email', sa.String(length=255), nullable=False),
27+
sa.Column('code', sa.String(length=6), nullable=False),
28+
sa.Column('verification_token', sa.String(), nullable=True),
29+
sa.Column('is_verified', sa.Boolean(), nullable=False),
30+
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
31+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
32+
sa.PrimaryKeyConstraint('id')
33+
)
34+
op.create_index(op.f('ix_email_verifications_email'), 'email_verifications', ['email'], unique=False)
35+
op.create_table('users',
36+
sa.Column('id', sa.Uuid(), nullable=False),
37+
sa.Column('username', sa.String(length=50), nullable=False),
38+
sa.Column('email', sa.String(length=255), nullable=False),
39+
sa.Column('password_hash', sa.String(), nullable=False),
40+
sa.Column('role', sa.Enum('OWNER', 'REVIEWER', name='user_role'), nullable=False),
41+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
42+
sa.PrimaryKeyConstraint('id')
43+
)
44+
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
45+
# ### end Alembic commands ###
46+
47+
48+
def downgrade() -> None:
49+
"""Downgrade schema."""
50+
# ### commands auto generated by Alembic - please adjust! ###
51+
op.drop_index(op.f('ix_users_email'), table_name='users')
52+
op.drop_table('users')
53+
op.drop_index(op.f('ix_email_verifications_email'), table_name='email_verifications')
54+
op.drop_table('email_verifications')
55+
# ### end Alembic commands ###

app/api/v1/deps.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from fastapi import Depends, HTTPException, status
2+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3+
from jwt import DecodeError, ExpiredSignatureError
4+
5+
from app.core.security import decode_token
6+
7+
bearer = HTTPBearer()
8+
9+
10+
async def require_login(
11+
credentials: HTTPAuthorizationCredentials = Depends(bearer),
12+
) -> str:
13+
try:
14+
return decode_token(credentials.credentials)
15+
except (ExpiredSignatureError, DecodeError, ValueError) as e:
16+
raise HTTPException(
17+
status_code=status.HTTP_401_UNAUTHORIZED,
18+
detail="Invalid or expired token",
19+
) from e

app/api/v1/endpoints/auth.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
4+
from app.api.v1.deps import require_login
5+
from app.db.session import get_db
6+
from app.schemas.auth import (
7+
AccessTokenResp,
8+
AuthResp,
9+
ChangePasswordReq,
10+
EmailCheckReq,
11+
EmailValidateReq,
12+
EmailValidateResp,
13+
EmailVerifyReq,
14+
LoginReq,
15+
RegisterReq,
16+
ResetPasswordReq,
17+
UserInfo,
18+
)
19+
from app.schemas.common import SuccessResponse
20+
from app.services import auth as auth_service
21+
22+
router = APIRouter(prefix="/auth", tags=["Auth"])
23+
24+
25+
@router.post("/email", response_model=SuccessResponse[None])
26+
async def check_email(
27+
body: EmailCheckReq,
28+
db: AsyncSession = Depends(get_db),
29+
) -> SuccessResponse[None]:
30+
await auth_service.check_email_duplicate(db, body.email)
31+
return SuccessResponse(data=None)
32+
33+
34+
@router.post("/email/verify", response_model=SuccessResponse[None])
35+
async def verify_email(
36+
body: EmailVerifyReq,
37+
db: AsyncSession = Depends(get_db),
38+
) -> SuccessResponse[None]:
39+
await auth_service.send_verification_code(db, body.email)
40+
return SuccessResponse(data=None)
41+
42+
43+
@router.post("/email/validate", response_model=SuccessResponse[EmailValidateResp])
44+
async def validate_email(
45+
body: EmailValidateReq,
46+
db: AsyncSession = Depends(get_db),
47+
) -> SuccessResponse[EmailValidateResp]:
48+
token = await auth_service.validate_verification_code(db, body.email, body.code)
49+
return SuccessResponse(data=EmailValidateResp(verificationToken=token))
50+
51+
52+
@router.post("/user", response_model=SuccessResponse[AuthResp])
53+
async def register(
54+
body: RegisterReq,
55+
response: Response,
56+
db: AsyncSession = Depends(get_db),
57+
) -> SuccessResponse[AuthResp]:
58+
user, access_token, refresh_token = await auth_service.register(db, body)
59+
response.set_cookie(
60+
key="refresh_token",
61+
value=refresh_token,
62+
httponly=True,
63+
samesite="lax",
64+
)
65+
return SuccessResponse(
66+
data=AuthResp(
67+
user=UserInfo(id=str(user.id), userRole=user.role),
68+
token=access_token,
69+
)
70+
)
71+
72+
73+
@router.get("/token", response_model=SuccessResponse[AccessTokenResp])
74+
async def refresh_token(request: Request) -> SuccessResponse[AccessTokenResp]:
75+
token = request.cookies.get("refresh_token")
76+
if not token:
77+
raise HTTPException(
78+
status_code=status.HTTP_401_UNAUTHORIZED,
79+
detail="리프레시 토큰이 없습니다.",
80+
)
81+
access_token = auth_service.refresh_access_token(token)
82+
return SuccessResponse(data=AccessTokenResp(accessToken=access_token))
83+
84+
85+
@router.post("/user/session", response_model=SuccessResponse[AuthResp])
86+
async def login(
87+
body: LoginReq,
88+
response: Response,
89+
db: AsyncSession = Depends(get_db),
90+
) -> SuccessResponse[AuthResp]:
91+
user, access_token, refresh_token = await auth_service.login(db, body)
92+
response.set_cookie(
93+
key="refresh_token",
94+
value=refresh_token,
95+
httponly=True,
96+
samesite="lax",
97+
)
98+
return SuccessResponse(
99+
data=AuthResp(
100+
user=UserInfo(id=str(user.id), userRole=user.role),
101+
token=access_token,
102+
)
103+
)
104+
105+
106+
@router.delete("/user/session", response_model=SuccessResponse[None])
107+
async def logout(
108+
response: Response,
109+
_user_id: str = Depends(require_login),
110+
) -> SuccessResponse[None]:
111+
response.delete_cookie(key="refresh_token", httponly=True, samesite="lax")
112+
return SuccessResponse(data=None)
113+
114+
115+
@router.patch("/password", response_model=SuccessResponse[None])
116+
async def change_password(
117+
body: ChangePasswordReq,
118+
db: AsyncSession = Depends(get_db),
119+
user_id: str = Depends(require_login),
120+
) -> SuccessResponse[None]:
121+
await auth_service.change_password(db, user_id, body.oldPassword, body.newPassword)
122+
return SuccessResponse(data=None)
123+
124+
125+
@router.post("/password", response_model=SuccessResponse[None])
126+
async def reset_password(
127+
body: ResetPasswordReq,
128+
db: AsyncSession = Depends(get_db),
129+
) -> SuccessResponse[None]:
130+
await auth_service.reset_password(db, body)
131+
return SuccessResponse(data=None)

app/api/v1/router.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from fastapi import APIRouter
2+
3+
from app.api.v1.endpoints.auth import router as auth_router
4+
5+
router = APIRouter()
6+
router.include_router(auth_router)

app/core/security.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,51 @@
1+
from datetime import UTC, datetime, timedelta
2+
3+
import jwt
14
from passlib.context import CryptContext
25

6+
from app.core.config import settings
7+
38
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
49

510

611
def verify_password(plain: str, hashed: str) -> bool:
7-
return pwd_context.verify(plain, hashed)
12+
return bool(pwd_context.verify(plain, hashed))
813

914

1015
def get_password_hash(password: str) -> str:
11-
return pwd_context.hash(password)
16+
return str(pwd_context.hash(password))
17+
18+
19+
def create_access_token(user_id: str) -> str:
20+
payload = {
21+
"sub": user_id,
22+
"exp": datetime.now(UTC) + timedelta(minutes=30),
23+
}
24+
return str(jwt.encode(payload, settings.secret_key, algorithm="HS256"))
25+
26+
27+
def create_refresh_token(user_id: str) -> str:
28+
payload = {
29+
"sub": user_id,
30+
"exp": datetime.now(UTC) + timedelta(days=7),
31+
}
32+
return str(jwt.encode(payload, settings.secret_key, algorithm="HS256"))
33+
34+
35+
def create_verification_token(email: str) -> str:
36+
payload = {
37+
"sub": email,
38+
"type": "email_verification",
39+
"exp": datetime.now(UTC) + timedelta(hours=1),
40+
}
41+
return str(jwt.encode(payload, settings.secret_key, algorithm="HS256"))
42+
43+
44+
def decode_token(token: str) -> str:
45+
payload: dict[str, object] = jwt.decode(
46+
token, settings.secret_key, algorithms=["HS256"]
47+
)
48+
user_id = payload.get("sub")
49+
if not isinstance(user_id, str):
50+
raise ValueError("Invalid token payload")
51+
return user_id

app/db/session.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44

55
from app.core.config import settings
66

7-
engine = create_async_engine(settings.database_url, echo=settings.debug)
7+
_connect_args = (
8+
{"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
9+
)
10+
engine = create_async_engine(
11+
settings.database_url, echo=settings.debug, connect_args=_connect_args
12+
)
813
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
914

1015

app/main.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,39 @@
11
from collections.abc import AsyncGenerator
22
from contextlib import asynccontextmanager
3+
from datetime import UTC, datetime
34

4-
from fastapi import FastAPI
5+
from fastapi import FastAPI, Request
6+
from fastapi.exceptions import HTTPException
7+
from fastapi.responses import JSONResponse
58

9+
from app.api.v1.router import router as api_router
610
from app.core.config import settings
711

812

913
@asynccontextmanager
1014
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
11-
# startup
1215
yield
13-
# shutdown
1416

1517

1618
app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
1719

20+
app.include_router(api_router, prefix="/api")
21+
22+
23+
@app.exception_handler(HTTPException)
24+
async def http_exception_handler(_request: Request, exc: HTTPException) -> JSONResponse:
25+
return JSONResponse(
26+
status_code=exc.status_code,
27+
content={
28+
"status": exc.status_code,
29+
"data": {
30+
"timestamp": datetime.now(UTC).isoformat(),
31+
"message": str(exc.detail),
32+
"code": str(exc.status_code),
33+
},
34+
},
35+
)
36+
1837

1938
@app.get("/health")
2039
def health() -> dict[str, str]:

app/models/email_verification.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from datetime import datetime
2+
from uuid import UUID, uuid4
3+
4+
from sqlalchemy import Boolean, DateTime, String
5+
from sqlalchemy.orm import Mapped, mapped_column
6+
from sqlalchemy.sql import func
7+
8+
from app.db.base import Base
9+
10+
11+
class EmailVerification(Base):
12+
__tablename__ = "email_verifications"
13+
14+
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
15+
email: Mapped[str] = mapped_column(String(255), index=True)
16+
code: Mapped[str] = mapped_column(String(6))
17+
verification_token: Mapped[str | None] = mapped_column(String, nullable=True)
18+
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
19+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
20+
created_at: Mapped[datetime] = mapped_column(
21+
DateTime(timezone=True), server_default=func.now()
22+
)

0 commit comments

Comments
 (0)