Skip to content

Commit a6e7e5a

Browse files
Feature/auth password reset with OCI Email SDK (#139)
## 변경 사항 - 비밀번호 찾기/재설정 API 3종 추가 - `POST /api/v3/auth/password-reset/email` - `POST /api/v3/auth/password-reset/verify` - `POST /api/v3/auth/password-reset` - 비밀번호 재설정 인증번호 저장용 `password_reset_verification` 테이블 추가 - OCI Email Delivery SDK 기반 메일 전송 서비스 추가 - 인증번호 생성, 해시 저장, 만료 처리, 시도 횟수 제한, 사용 완료 처리 로직 추가 - 비밀번호 재설정 서비스 테스트 및 메일 SDK payload 테스트 추가 ## 구현 상세 - 인증번호 원문은 DB에 저장하지 않고 `PasswordService`로 해시하여 저장합니다. - 인증번호 유효 시간은 10분입니다. - 인증번호 검증 실패는 최대 5회까지 허용합니다. - 존재하지 않는 이메일로 인증번호 전송 요청이 들어와도 계정 존재 여부가 노출되지 않도록 정상 응답합니다. - 메일 발송은 SMTP credential 없이 `oci.email_data_plane.EmailDPClient.submit_email()`을 사용합니다. - Instance Principal signer 생성이 실패할 때만 로컬 OCI config(`oci.config.from_file()`)로 fallback합니다. - 비밀번호 재설정 인증번호는 DB에 먼저 저장된 뒤 메일로 발송되어, 저장 실패 시 검증 불가능한 코드가 발송되지 않습니다. ## 필요한 운영 설정 OCI Email Delivery를 설정한 뒤 아래 secret/env 값을 등록해야 합니다. ```text email_compartment_id email_from_email email_from_name # optional email_reply_to # optional email_service_endpoint # optional --------- Co-authored-by: 문재영 <152019282+mjy926@users.noreply.github.com>
1 parent a6c3b1e commit a6e7e5a

17 files changed

Lines changed: 937 additions & 5 deletions

wacruit/src/apps/auth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
from .views import v3_router as router
2+
3+
__all__ = ["router"]

wacruit/src/apps/auth/exceptions.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,18 @@ def __init__(self):
1414
class EmailConflictException(WacruitException):
1515
def __init__(self):
1616
super().__init__(status_code=409, detail="이미 존재하는 이메일입니다.")
17+
18+
19+
class InvalidPasswordResetCodeException(WacruitException):
20+
def __init__(self):
21+
super().__init__(status_code=400, detail="인증 번호가 올바르지 않습니다.")
22+
23+
24+
class ExpiredPasswordResetCodeException(WacruitException):
25+
def __init__(self):
26+
super().__init__(status_code=400, detail="만료된 인증 번호입니다.")
27+
28+
29+
class PasswordResetCodeNotVerifiedException(WacruitException):
30+
def __init__(self):
31+
super().__init__(status_code=400, detail="인증 번호 확인이 필요합니다.")

wacruit/src/apps/auth/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
from datetime import datetime
2+
3+
from sqlalchemy import DateTime
14
from sqlalchemy.orm import Mapped
5+
from sqlalchemy.orm import mapped_column
26

7+
from wacruit.src.apps.common.sql import CURRENT_TIMESTAMP
38
from wacruit.src.database.base import DeclarativeBase
49
from wacruit.src.database.base import intpk
510
from wacruit.src.database.base import str255
@@ -10,3 +15,19 @@ class BlockedToken(DeclarativeBase):
1015

1116
id: Mapped[intpk]
1217
token: Mapped[str255]
18+
19+
20+
class PasswordResetVerification(DeclarativeBase):
21+
__tablename__ = "password_reset_verification"
22+
23+
id: Mapped[intpk]
24+
email: Mapped[str255] = mapped_column(index=True)
25+
code_hash: Mapped[str255]
26+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
27+
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
28+
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
29+
attempt_count: Mapped[int] = mapped_column(default=0)
30+
created_at: Mapped[datetime] = mapped_column(
31+
DateTime(timezone=True),
32+
server_default=CURRENT_TIMESTAMP,
33+
)

wacruit/src/apps/auth/repositories.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1+
from datetime import datetime
12
from typing import Annotated
23

34
from fastapi import Depends
45
from pydantic import EmailStr
56
from sqlalchemy.orm import Session
67

78
from wacruit.src.apps.auth.models import BlockedToken
9+
from wacruit.src.apps.auth.models import PasswordResetVerification
810
from wacruit.src.apps.user.models import User
9-
from wacruit.src.database.connection import get_db_session
1011
from wacruit.src.database.connection import Transaction
12+
from wacruit.src.database.connection import get_db_session
1113

1214

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

30+
def update_user(self, user: User) -> User:
31+
with self.transaction:
32+
self.session.merge(user)
33+
return user
34+
35+
def create_password_reset_verification(
36+
self, verification: PasswordResetVerification
37+
) -> PasswordResetVerification:
38+
with self.transaction:
39+
self.session.add(verification)
40+
return verification
41+
42+
def replace_active_password_reset_for_email(
43+
self,
44+
email: EmailStr,
45+
verification: PasswordResetVerification,
46+
expires_at: datetime,
47+
) -> PasswordResetVerification:
48+
with self.transaction:
49+
(
50+
self.session.query(PasswordResetVerification)
51+
.where(
52+
PasswordResetVerification.email == email,
53+
PasswordResetVerification.used_at.is_(None),
54+
)
55+
.update(
56+
{PasswordResetVerification.expires_at: expires_at},
57+
synchronize_session="fetch",
58+
)
59+
)
60+
self.session.add(verification)
61+
return verification
62+
63+
def get_latest_password_reset_verification(
64+
self, email: EmailStr
65+
) -> PasswordResetVerification | None:
66+
return (
67+
self.session.query(PasswordResetVerification)
68+
.where(PasswordResetVerification.email == email)
69+
.order_by(
70+
PasswordResetVerification.created_at.desc(),
71+
PasswordResetVerification.id.desc(),
72+
)
73+
.first()
74+
)
75+
76+
def update_password_reset_verification(
77+
self, verification: PasswordResetVerification
78+
) -> PasswordResetVerification:
79+
with self.transaction:
80+
self.session.merge(verification)
81+
return verification
82+
83+
def consume_password_reset_verification(
84+
self,
85+
verification_id: int,
86+
email: EmailStr,
87+
password_hash: str,
88+
used_at: datetime,
89+
) -> bool:
90+
with self.transaction:
91+
verification = (
92+
self.session.query(PasswordResetVerification)
93+
.where(
94+
PasswordResetVerification.id == verification_id,
95+
PasswordResetVerification.email == email,
96+
)
97+
.with_for_update()
98+
.first()
99+
)
100+
if verification is None or verification.used_at is not None:
101+
return False
102+
103+
user = (
104+
self.session.query(User)
105+
.where(User.email == email)
106+
.with_for_update()
107+
.first()
108+
)
109+
if user is None:
110+
return False
111+
112+
user.password = password_hash
113+
verification.used_at = used_at
114+
return True
115+
116+
def expire_unused_password_reset_verifications(
117+
self, email: EmailStr, expires_at: datetime
118+
) -> None:
119+
verifications = (
120+
self.session.query(PasswordResetVerification)
121+
.where(
122+
PasswordResetVerification.email == email,
123+
PasswordResetVerification.used_at.is_(None),
124+
)
125+
.all()
126+
)
127+
with self.transaction:
128+
for verification in verifications:
129+
verification.expires_at = expires_at
130+
28131
def is_blocked_token(self, token: str) -> bool:
29132
result = (
30133
self.session.query(BlockedToken).where(BlockedToken.token == token).first()

wacruit/src/apps/auth/schemas.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pydantic import BaseModel
22
from pydantic import EmailStr
3+
from pydantic import Field
34

45

56
class LoginRequest(BaseModel):
@@ -14,3 +15,18 @@ class TokenResponse(BaseModel):
1415

1516
class UserCheckRequest(BaseModel):
1617
email: EmailStr
18+
19+
20+
class PasswordResetEmailRequest(BaseModel):
21+
email: EmailStr
22+
23+
24+
class PasswordResetVerifyRequest(BaseModel):
25+
email: EmailStr
26+
code: str = Field(..., min_length=6, max_length=6)
27+
28+
29+
class PasswordResetRequest(BaseModel):
30+
email: EmailStr
31+
code: str = Field(..., min_length=6, max_length=6)
32+
new_password: str = Field(..., min_length=8, max_length=50)

wacruit/src/apps/auth/services.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,41 @@
11
from datetime import datetime
22
from datetime import timedelta
3+
import secrets
34
from typing import Annotated
45

5-
from authlib.jose import jwt
66
from authlib.jose import JWTClaims
7+
from authlib.jose import jwt
78
from authlib.jose.errors import JoseError
89
from fastapi import Depends
910
from pydantic import EmailStr
1011

12+
from wacruit.src.apps.auth.exceptions import ExpiredPasswordResetCodeException
13+
from wacruit.src.apps.auth.exceptions import InvalidPasswordResetCodeException
1114
from wacruit.src.apps.auth.exceptions import InvalidTokenException
15+
from wacruit.src.apps.auth.exceptions import PasswordResetCodeNotVerifiedException
1216
from wacruit.src.apps.auth.exceptions import UserNotFoundException
17+
from wacruit.src.apps.auth.models import PasswordResetVerification
1318
from wacruit.src.apps.auth.repositories import AuthRepository
14-
from wacruit.src.apps.common.security import get_token_secret
1519
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
1622
from wacruit.src.apps.user.models import User
1723

24+
PASSWORD_RESET_CODE_LENGTH = 6
25+
PASSWORD_RESET_CODE_TTL_MINUTES = 10
26+
PASSWORD_RESET_MAX_ATTEMPTS = 5
27+
1828

1929
class AuthService:
2030
def __init__(
2131
self,
2232
auth_repository: Annotated[AuthRepository, Depends()],
2333
token_secret: Annotated[str, Depends(get_token_secret)],
34+
email_service: Annotated[EmailService, Depends()],
2435
) -> None:
2536
self.auth_repository = auth_repository
2637
self.token_secret = token_secret
38+
self.email_service = email_service
2739

2840
def get_user_by_id(self, user_id: int) -> User | None:
2941
return self.auth_repository.get_user_by_id(user_id)
@@ -89,3 +101,76 @@ def check_available_email(self, email: EmailStr) -> bool:
89101
if user:
90102
return False
91103
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

Comments
 (0)