Skip to content

Commit 1c60a1e

Browse files
authored
Feat: 로그인 기능 추가 (#115)
* 가입할 때 username, password 필드를 추가했습니다. * 토큰 방식의 인증을 추가했습니다. * 현재 유저를 얻을 때 토큰을 사용하도록 했습니다.
1 parent 301175f commit 1c60a1e

29 files changed

+516
-50
lines changed

.env.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ DB_NAME=testdb
44
DB_HOST=127.0.0.1
55
DB_PORT=3307
66
S3_PORTFOLIO_BUCKET_NAME=wacruit-portfolio-test
7+
TOKEN_SECRET=test
78
SLACK_API_TOKEN=""

poetry.lock

Lines changed: 173 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ pytest-mock = "3.8.2"
2828
moto = {version = "4.2.7", extras = ["all", "ec2", "s3"]}
2929
tenacity = "^9.0.0"
3030
pyyaml = "6.0.2"
31+
argon2-cffi = "^25.1.0"
32+
authlib = "^1.6.5"
3133

3234

3335
[tool.poetry.group.dev.dependencies]

wacruit/src/apps/auth/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .views import v3_router as router
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from wacruit.src.apps.common.exceptions import WacruitException
2+
3+
4+
class UserNotFoundException(WacruitException):
5+
def __init__(self):
6+
super().__init__(status_code=404, detail="해당하는 계정이 존재하지 않습니다.")
7+
8+
9+
class InvalidTokenException(WacruitException):
10+
def __init__(self):
11+
super().__init__(status_code=401, detail="잘못된 토큰입니다.")

wacruit/src/apps/auth/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from sqlalchemy.orm import Mapped
2+
3+
from wacruit.src.database.base import DeclarativeBase
4+
from wacruit.src.database.base import intpk
5+
from wacruit.src.database.base import str255
6+
7+
8+
class BlockedToken(DeclarativeBase):
9+
__tablename__ = "blocked_token"
10+
11+
id: Mapped[intpk]
12+
token: Mapped[str255]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from typing import Annotated
2+
3+
from fastapi import Depends
4+
from sqlalchemy.orm import Session
5+
6+
from wacruit.src.apps.auth.models import BlockedToken
7+
from wacruit.src.apps.user.models import User
8+
from wacruit.src.database.connection import get_db_session
9+
from wacruit.src.database.connection import Transaction
10+
11+
12+
class AuthRepository:
13+
def __init__(
14+
self,
15+
session: Annotated[Session, Depends(get_db_session)],
16+
transaction: Annotated[Transaction, Depends()],
17+
) -> None:
18+
self.session = session
19+
self.transaction = transaction
20+
21+
def get_user_by_username(self, username: str) -> User | None:
22+
return self.session.query(User).where(User.username == username).first()
23+
24+
def get_user_by_id(self, user_id: int) -> User | None:
25+
return self.session.query(User).where(User.id == user_id).first()
26+
27+
def is_blocked_token(self, token: str) -> bool:
28+
result = (
29+
self.session.query(BlockedToken).where(BlockedToken.token == token).first()
30+
)
31+
if result:
32+
return True
33+
return False
34+
35+
def block_token(self, token: str) -> bool:
36+
to_block = BlockedToken(token=token)
37+
with self.transaction:
38+
self.session.add(to_block)
39+
return True

wacruit/src/apps/auth/schemas.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from pydantic import BaseModel
2+
3+
4+
class LoginRequest(BaseModel):
5+
username: str
6+
password: str
7+
8+
9+
class TokenResponse(BaseModel):
10+
access_token: str
11+
refresh_token: str

wacruit/src/apps/auth/services.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from datetime import datetime
2+
from datetime import timedelta
3+
from typing import Annotated
4+
5+
from authlib.jose import jwt
6+
from authlib.jose import JWTClaims
7+
from authlib.jose.errors import JoseError
8+
from fastapi import Depends
9+
10+
from wacruit.src.apps.auth.exceptions import InvalidTokenException
11+
from wacruit.src.apps.auth.exceptions import UserNotFoundException
12+
from wacruit.src.apps.auth.repositories import AuthRepository
13+
from wacruit.src.apps.common.security import get_token_secret
14+
from wacruit.src.apps.common.security import PasswordService
15+
from wacruit.src.apps.user.models import User
16+
17+
18+
class AuthService:
19+
def __init__(
20+
self,
21+
auth_repository: Annotated[AuthRepository, Depends()],
22+
token_secret: Annotated[str, Depends(get_token_secret)],
23+
) -> None:
24+
self.auth_repository = auth_repository
25+
self.token_secret = token_secret
26+
27+
def get_user_by_id(self, user_id: int) -> User | None:
28+
return self.auth_repository.get_user_by_id(user_id)
29+
30+
def login(self, username: str, password: str) -> tuple[str, str]:
31+
user = self.auth_repository.get_user_by_username(username)
32+
33+
if user is None:
34+
raise UserNotFoundException()
35+
if user.password is None:
36+
raise UserNotFoundException()
37+
38+
if PasswordService.verify_password(password, user.password):
39+
access_token = self.issue_token(user.id, 24, "access")
40+
refresh_token = self.issue_token(user.id, 24 * 7, "refresh")
41+
return (access_token, refresh_token)
42+
raise UserNotFoundException()
43+
44+
def refresh_token(self, refresh_token: str) -> tuple[str, str]:
45+
decoded_token = self.decode_token(refresh_token)
46+
if decoded_token["token_type"] != "refresh":
47+
raise UserNotFoundException()
48+
49+
user_id = decoded_token["sub"]
50+
user = self.auth_repository.get_user_by_id(user_id)
51+
52+
if self.auth_repository.is_blocked_token(refresh_token):
53+
raise UserNotFoundException()
54+
if user is None:
55+
raise UserNotFoundException()
56+
57+
self.block_token(refresh_token)
58+
access_token = self.issue_token(user.id, 24, "access")
59+
new_refresh_token = self.issue_token(user.id, 24 * 7, "refresh")
60+
return (access_token, new_refresh_token)
61+
62+
def block_token(self, token: str) -> None:
63+
if self.auth_repository.is_blocked_token(token):
64+
raise InvalidTokenException()
65+
66+
self.auth_repository.block_token(token)
67+
68+
def decode_token(self, token: str) -> JWTClaims:
69+
try:
70+
claims = jwt.decode(token, key=self.token_secret)
71+
claims.validate()
72+
return claims
73+
except JoseError as e:
74+
raise InvalidTokenException() from e
75+
76+
def issue_token(self, user_id: int, expiration_hour: int, token_type: str) -> str:
77+
header = {"alg": "HS256"}
78+
payload = {
79+
"sub": user_id,
80+
"exp": int((datetime.now() + timedelta(hours=expiration_hour)).timestamp()),
81+
"token_type": token_type,
82+
}
83+
84+
return jwt.encode(header, payload, key=self.token_secret).decode("utf-8")

wacruit/src/apps/auth/views.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter
4+
from fastapi import Depends
5+
from fastapi import Security
6+
from fastapi.security import HTTPAuthorizationCredentials
7+
from fastapi.security import HTTPBearer
8+
9+
from wacruit.src.apps.auth.schemas import LoginRequest
10+
from wacruit.src.apps.auth.schemas import TokenResponse
11+
from wacruit.src.apps.auth.services import AuthService
12+
13+
v3_router = APIRouter(prefix="/v3/auth", tags=["auth"])
14+
15+
security = HTTPBearer(scheme_name="refresh_token", description="토큰 갱신을 위한 Bearer 토큰")
16+
17+
18+
@v3_router.post("/login")
19+
def login(
20+
req: LoginRequest, auth_service: Annotated[AuthService, Depends()]
21+
) -> TokenResponse:
22+
res = auth_service.login(req.username, req.password)
23+
24+
return TokenResponse(access_token=res[0], refresh_token=res[1])
25+
26+
27+
@v3_router.post("/refresh")
28+
def refresh_token(
29+
refresh_credentials: Annotated[HTTPAuthorizationCredentials, Security(security)],
30+
auth_service: Annotated[AuthService, Depends()],
31+
) -> TokenResponse:
32+
res = auth_service.refresh_token(refresh_credentials.credentials)
33+
34+
return TokenResponse(access_token=res[0], refresh_token=res[1])

0 commit comments

Comments
 (0)