Skip to content

Commit ebe285b

Browse files
authored
Merge pull request #143 from wafflestudio/develop
prod 배포
2 parents ab6d4b9 + 7df14e5 commit ebe285b

22 files changed

Lines changed: 1327 additions & 17 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: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,24 @@ def __init__(self):
88

99
class InvalidTokenException(WacruitException):
1010
def __init__(self):
11-
super().__init__(status_code=401, detail="잘못된 토큰입니다.")
11+
super().__init__(status_code=401, detail="유효하지 않은 토큰입니다.")
1212

1313

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, server_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: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
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

8+
from wacruit.src.apps.auth.exceptions import UserNotFoundException
79
from wacruit.src.apps.auth.models import BlockedToken
10+
from wacruit.src.apps.auth.models import PasswordResetVerification
811
from wacruit.src.apps.user.models import User
9-
from wacruit.src.database.connection import get_db_session
1012
from wacruit.src.database.connection import Transaction
13+
from wacruit.src.database.connection import get_db_session
1114

1215

1316
class AuthRepository:
@@ -25,6 +28,119 @@ def get_user_by_email(self, email: EmailStr) -> User | None:
2528
def get_user_by_id(self, user_id: int) -> User | None:
2629
return self.session.query(User).where(User.id == user_id).first()
2730

31+
def update_user(self, user: User) -> User:
32+
with self.transaction:
33+
self.session.merge(user)
34+
return user
35+
36+
def create_password_reset_verification(
37+
self, verification: PasswordResetVerification
38+
) -> PasswordResetVerification:
39+
with self.transaction:
40+
self.session.add(verification)
41+
return verification
42+
43+
def replace_active_password_reset_for_email(
44+
self,
45+
email: EmailStr,
46+
verification: PasswordResetVerification,
47+
expires_at: datetime,
48+
) -> PasswordResetVerification:
49+
with self.transaction:
50+
# Lock the user row to serialize password reset requests per email.
51+
locked_user = (
52+
self.session.query(User)
53+
.where(User.email == email)
54+
.with_for_update()
55+
.one_or_none()
56+
)
57+
if locked_user is None:
58+
raise UserNotFoundException()
59+
(
60+
self.session.query(PasswordResetVerification)
61+
.where(
62+
PasswordResetVerification.email == email,
63+
PasswordResetVerification.used_at.is_(None),
64+
)
65+
.update(
66+
{PasswordResetVerification.expires_at: expires_at},
67+
synchronize_session="fetch",
68+
)
69+
)
70+
self.session.add(verification)
71+
return verification
72+
73+
def commit(self) -> None:
74+
self.session.commit()
75+
76+
def get_latest_password_reset_verification(
77+
self, email: EmailStr
78+
) -> PasswordResetVerification | None:
79+
return (
80+
self.session.query(PasswordResetVerification)
81+
.where(PasswordResetVerification.email == email)
82+
.order_by(
83+
PasswordResetVerification.created_at.desc(),
84+
PasswordResetVerification.id.desc(),
85+
)
86+
.first()
87+
)
88+
89+
def update_password_reset_verification(
90+
self, verification: PasswordResetVerification
91+
) -> PasswordResetVerification:
92+
with self.transaction:
93+
self.session.merge(verification)
94+
return verification
95+
96+
def consume_password_reset_verification(
97+
self,
98+
verification_id: int,
99+
email: EmailStr,
100+
password_hash: str,
101+
used_at: datetime,
102+
) -> bool:
103+
with self.transaction:
104+
verification = (
105+
self.session.query(PasswordResetVerification)
106+
.where(
107+
PasswordResetVerification.id == verification_id,
108+
PasswordResetVerification.email == email,
109+
)
110+
.with_for_update()
111+
.first()
112+
)
113+
if verification is None or verification.used_at is not None:
114+
return False
115+
116+
user = (
117+
self.session.query(User)
118+
.where(User.email == email)
119+
.with_for_update()
120+
.first()
121+
)
122+
if user is None:
123+
return False
124+
125+
user.password = password_hash
126+
verification.used_at = used_at
127+
return True
128+
129+
def expire_unused_password_reset_verifications(
130+
self, email: EmailStr, expires_at: datetime
131+
) -> None:
132+
verifications = (
133+
self.session.query(PasswordResetVerification)
134+
.where(
135+
PasswordResetVerification.email == email,
136+
PasswordResetVerification.used_at.is_(None),
137+
)
138+
.all()
139+
)
140+
with self.transaction:
141+
for verification in verifications:
142+
verification.expires_at = expires_at
143+
28144
def is_blocked_token(self, token: str) -> bool:
29145
result = (
30146
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: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,52 @@
11
from datetime import datetime
22
from datetime import timedelta
3+
from datetime import timezone
4+
import secrets
35
from typing import Annotated
46

5-
from authlib.jose import jwt
67
from authlib.jose import JWTClaims
8+
from authlib.jose import jwt
79
from authlib.jose.errors import JoseError
810
from fastapi import Depends
911
from pydantic import EmailStr
1012

13+
from wacruit.src.apps.auth.exceptions import ExpiredPasswordResetCodeException
14+
from wacruit.src.apps.auth.exceptions import InvalidPasswordResetCodeException
1115
from wacruit.src.apps.auth.exceptions import InvalidTokenException
16+
from wacruit.src.apps.auth.exceptions import PasswordResetCodeNotVerifiedException
1217
from wacruit.src.apps.auth.exceptions import UserNotFoundException
18+
from wacruit.src.apps.auth.models import PasswordResetVerification
1319
from wacruit.src.apps.auth.repositories import AuthRepository
14-
from wacruit.src.apps.common.security import get_token_secret
1520
from wacruit.src.apps.common.security import PasswordService
21+
from wacruit.src.apps.common.security import get_token_secret
22+
from wacruit.src.apps.mail.services import EmailService
1623
from wacruit.src.apps.user.models import User
1724

25+
PASSWORD_RESET_CODE_LENGTH = 6
26+
PASSWORD_RESET_CODE_TTL_MINUTES = 5
27+
PASSWORD_RESET_MAX_ATTEMPTS = 5
28+
29+
30+
def _utc_now() -> datetime:
31+
return datetime.now(timezone.utc)
32+
33+
34+
def _as_utc(value: datetime) -> datetime:
35+
if value.tzinfo is None:
36+
return value.replace(tzinfo=timezone.utc)
37+
return value.astimezone(timezone.utc)
38+
1839

1940
class AuthService:
2041
def __init__(
2142
self,
2243
auth_repository: Annotated[AuthRepository, Depends()],
2344
token_secret: Annotated[str, Depends(get_token_secret)],
45+
email_service: Annotated[EmailService, Depends()],
2446
) -> None:
2547
self.auth_repository = auth_repository
2648
self.token_secret = token_secret
49+
self.email_service = email_service
2750

2851
def get_user_by_id(self, user_id: int) -> User | None:
2952
return self.auth_repository.get_user_by_id(user_id)
@@ -78,7 +101,7 @@ def issue_token(self, user_id: int, expiration_hour: int, token_type: str) -> st
78101
header = {"alg": "HS256"}
79102
payload = {
80103
"sub": user_id,
81-
"exp": int((datetime.now() + timedelta(hours=expiration_hour)).timestamp()),
104+
"exp": int((_utc_now() + timedelta(hours=expiration_hour)).timestamp()),
82105
"token_type": token_type,
83106
}
84107

@@ -89,3 +112,77 @@ def check_available_email(self, email: EmailStr) -> bool:
89112
if user:
90113
return False
91114
return True
115+
116+
def send_password_reset_email(self, email: EmailStr) -> None:
117+
user = self.auth_repository.get_user_by_email(email)
118+
if user is None:
119+
raise UserNotFoundException()
120+
121+
code = self._generate_password_reset_code()
122+
now = _utc_now()
123+
expires_at = now + timedelta(minutes=PASSWORD_RESET_CODE_TTL_MINUTES)
124+
125+
self.auth_repository.replace_active_password_reset_for_email(
126+
email,
127+
PasswordResetVerification(
128+
email=email,
129+
code_hash=PasswordService.hash_password(code),
130+
expires_at=expires_at,
131+
),
132+
now,
133+
)
134+
self.auth_repository.commit()
135+
self.email_service.send_password_reset_code(str(email), code)
136+
137+
def verify_password_reset_code(self, email: EmailStr, code: str) -> None:
138+
verification = self._get_valid_password_reset_verification(email, code)
139+
verification.verified_at = _utc_now()
140+
self.auth_repository.update_password_reset_verification(verification)
141+
142+
def reset_password(self, email: EmailStr, code: str, new_password: str) -> None:
143+
verification = self._get_valid_password_reset_verification(email, code)
144+
if verification.verified_at is None:
145+
raise PasswordResetCodeNotVerifiedException()
146+
147+
user = self.auth_repository.get_user_by_email(email)
148+
if user is None:
149+
raise UserNotFoundException()
150+
151+
consumed = self.auth_repository.consume_password_reset_verification(
152+
verification.id,
153+
email,
154+
PasswordService.hash_password(new_password),
155+
_utc_now(),
156+
)
157+
if not consumed:
158+
raise InvalidPasswordResetCodeException()
159+
160+
def _generate_password_reset_code(self) -> str:
161+
max_value = 10**PASSWORD_RESET_CODE_LENGTH
162+
return str(secrets.randbelow(max_value)).zfill(PASSWORD_RESET_CODE_LENGTH)
163+
164+
def _get_valid_password_reset_verification(
165+
self, email: EmailStr, code: str
166+
) -> PasswordResetVerification:
167+
if not code.isdigit():
168+
raise InvalidPasswordResetCodeException()
169+
170+
verification = self.auth_repository.get_latest_password_reset_verification(
171+
email
172+
)
173+
if verification is None or verification.used_at is not None:
174+
raise InvalidPasswordResetCodeException()
175+
176+
now = _utc_now()
177+
if _as_utc(verification.expires_at) < now:
178+
raise ExpiredPasswordResetCodeException()
179+
180+
if verification.attempt_count >= PASSWORD_RESET_MAX_ATTEMPTS:
181+
raise InvalidPasswordResetCodeException()
182+
183+
if not PasswordService.verify_password(code, verification.code_hash):
184+
verification.attempt_count += 1
185+
self.auth_repository.update_password_reset_verification(verification)
186+
raise InvalidPasswordResetCodeException()
187+
188+
return verification

0 commit comments

Comments
 (0)