Skip to content

Commit 2e3a522

Browse files
authored
Merge pull request #34 from Zerohertz/issue#32/refactor/oauth
[Refactor] OAuth 2.0
2 parents 13c58be + d52550d commit 2e3a522

File tree

24 files changed

+93032
-260
lines changed

24 files changed

+93032
-260
lines changed

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,9 @@ exec:
4949
.PHONY: expose
5050
expose:
5151
kubectl expose -n fastapi po/postgresql-0 --port 5432 --type=NodePort
52+
53+
.PHONY: swagger
54+
swagger:
55+
curl https://unpkg.com/[email protected]/swagger-ui-bundle.js > static/swagger-ui-bundle.js
56+
curl https://unpkg.com/[email protected]/swagger-ui.css > static/swagger-ui.css
57+
npx prettier --write "static/*.{js,css}"

app/api/v1/endpoints/admin/users.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
from fastapi import Depends, status
33
from fastapi.responses import JSONResponse
44

5-
from app.core.auth import AdminDeps
5+
from app.core.auth import AdminAuthDeps, GitHubOAuthDeps, PasswordOAuthDeps
66
from app.core.container import Container
77
from app.core.router import CoreAPIRouter
88
from app.schemas.users import UserPatchRequest, UserRequest, UserResponse
99
from app.services.users import UserService
1010

11-
router = CoreAPIRouter(prefix="/user", tags=["admin"], dependencies=[AdminDeps])
11+
router = CoreAPIRouter(
12+
prefix="/user",
13+
tags=["admin"],
14+
dependencies=[AdminAuthDeps, PasswordOAuthDeps, GitHubOAuthDeps],
15+
)
1216

1317

1418
@router.get(

app/api/v1/endpoints/auth.py

Lines changed: 43 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,103 @@
1+
from typing import Annotated
2+
13
from dependency_injector.wiring import Provide, inject
2-
from fastapi import Depends, status
3-
from fastapi.responses import JSONResponse, RedirectResponse
4+
from fastapi import Depends, Form, status
5+
from fastapi.responses import JSONResponse
46

5-
from app.core.auth import AuthDeps
6-
from app.core.configs import configs
7+
from app.core.auth import GitHubOAuthDeps, PasswordOAuthDeps, UserAuthDeps
78
from app.core.container import Container
89
from app.core.router import CoreAPIRouter
9-
from app.schemas.auth import JwtAccessToken, JwtRefreshToken, JwtToken
10-
from app.schemas.users import (
11-
UserOut,
12-
UserPasswordRequest,
13-
UserRegisterRequest,
14-
UserResponse,
10+
from app.schemas.auth import (
11+
GitHubOAuthRequest,
12+
JwtToken,
13+
PasswordOAuthReigsterRequest,
14+
PasswordOAuthRequest,
15+
RefreshOAuthRequest,
1516
)
17+
from app.schemas.users import UserOut, UserResponse
1618
from app.services.users import UserService
1719

1820
router = CoreAPIRouter(prefix="/auth", tags=["auth"])
1921

2022

2123
@router.post(
2224
"/refresh",
23-
response_model=JwtAccessToken,
25+
response_model=JwtToken,
2426
response_class=JSONResponse,
2527
status_code=status.HTTP_200_OK,
26-
summary="",
27-
description="",
28+
summary="Refresh access token",
29+
description="- Refresh the access token using a valid refresh token.</br>\n"
30+
"- If the refresh token is invalid or expired, authentication will fail.",
2831
)
2932
@inject
30-
async def post_refresh_token(
31-
token: JwtRefreshToken,
33+
async def refresh(
34+
request: Annotated[RefreshOAuthRequest, Form(...)],
3235
service: UserService = Depends(Provide[Container.user_service]),
3336
):
34-
return await service.refresh(token)
37+
return await service.refresh(request)
3538

3639

3740
@router.post(
38-
"/register",
41+
"/register/password",
3942
response_model=UserResponse,
4043
response_class=JSONResponse,
4144
status_code=status.HTTP_201_CREATED,
42-
summary="Register with password",
43-
description="",
45+
summary="Register a new account with a password",
46+
description="- Create a new user account using an email and password.</br>\n"
47+
"- The provided credentials will be used for authentication.",
4448
)
4549
@inject
4650
async def register_password(
47-
request: UserRegisterRequest,
51+
request: Annotated[PasswordOAuthReigsterRequest, Form(...)],
4852
service: UserService = Depends(Provide[Container.user_service]),
4953
):
5054
return await service.register(request)
5155

5256

5357
@router.post(
54-
"/login",
58+
"/token/password",
5559
response_model=JwtToken,
5660
response_class=JSONResponse,
5761
status_code=status.HTTP_200_OK,
58-
summary="Log in with password",
59-
description="",
62+
summary="Obtain an access token via password authentication",
63+
description="- Authenticate using an email and password to obtain an access token.</br>\n"
64+
"- This token can be used for subsequent API requests.",
6065
)
6166
@inject
6267
async def log_in_password(
63-
request: UserPasswordRequest,
68+
# NOTE: OAuth2PasswordRequestForm
69+
request: Annotated[PasswordOAuthRequest, Form(...)],
6470
service: UserService = Depends(Provide[Container.user_service]),
6571
):
6672
return await service.log_in_password(schema=request)
6773

6874

69-
@router.get(
70-
"/oauth/login/github",
71-
response_model=None,
72-
response_class=RedirectResponse,
73-
status_code=status.HTTP_302_FOUND,
74-
summary="Log in with GitHub OAuth",
75-
description="GitHub OAuth를 위해 redirection",
76-
)
77-
async def log_in_github():
78-
# NOTE: &scope=repo,user
79-
return RedirectResponse(
80-
f"https://github.com/login/oauth/authorize?client_id={configs.GITHUB_OAUTH_CLIENT_ID}"
81-
)
82-
83-
84-
@router.get(
85-
"/oauth/callback/github",
75+
@router.post(
76+
"/token/github",
8677
response_model=JwtToken,
8778
response_class=JSONResponse,
8879
status_code=status.HTTP_200_OK,
89-
summary="Callback for GitHub OAuth",
90-
description="GitHub OAuth에 의해 redirection될 endpoint",
91-
include_in_schema=False,
80+
summary="Obtain an access token via GitHub OAuth",
81+
description="- Authenticate using GitHub OAuth and receive an access token.<br/>\n"
82+
"- The client must provide an authorization code obtained from GitHub.",
9283
)
9384
@inject
94-
async def callback_github(
95-
code: str,
85+
async def log_in_github(
86+
request: Annotated[GitHubOAuthRequest, Form()],
9687
service: UserService = Depends(Provide[Container.user_service]),
9788
):
98-
"""
99-
# NOTE: Cookie 방식으로 JWT token 사용 시
100-
response.set_cookie(
101-
key="access_token", value=jwt_token.access_token, httponly=True, secure=True
102-
)
103-
response.set_cookie(
104-
key="refresh_token", value=jwt_token.refresh_token, httponly=True, secure=True
105-
)
106-
"""
107-
return await service.log_in_github(code=code)
89+
return await service.log_in_github(request)
10890

10991

11092
@router.get(
11193
"/me",
11294
response_model=UserOut,
11395
response_class=JSONResponse,
11496
status_code=status.HTTP_200_OK,
115-
summary="",
116-
description="",
97+
dependencies=[PasswordOAuthDeps, GitHubOAuthDeps],
98+
summary="Retrieve the current authenticated user's information",
99+
description="- Returns the authenticated user's details based on the provided access token.</br>\n"
100+
"- Requires a valid token obtained via password authentication or GitHub OAuth.",
117101
)
118-
async def get_me(user: AuthDeps):
102+
async def get_me(user: Annotated[UserOut, UserAuthDeps]):
119103
return user

app/api/v1/endpoints/users.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
from typing import Annotated
2+
13
from dependency_injector.wiring import Provide, inject
24
from fastapi import Depends, status
35
from fastapi.responses import JSONResponse
46

5-
from app.core.auth import AuthDeps
7+
from app.core.auth import GitHubOAuthDeps, PasswordOAuthDeps, UserAuthDeps
68
from app.core.container import Container
79
from app.core.router import CoreAPIRouter
8-
from app.schemas.users import UserPatchRequest, UserRequest, UserResponse
10+
from app.schemas.users import UserOut, UserPatchRequest, UserRequest, UserResponse
911
from app.services.users import UserService
1012

1113
router = CoreAPIRouter(prefix="/user", tags=["user"])
@@ -16,13 +18,14 @@
1618
response_model=UserResponse,
1719
response_class=JSONResponse,
1820
status_code=status.HTTP_200_OK,
21+
dependencies=[PasswordOAuthDeps, GitHubOAuthDeps],
1922
summary="",
2023
description="",
2124
)
2225
@inject
2326
async def put_user(
24-
user: AuthDeps,
2527
schema: UserRequest,
28+
user: Annotated[UserOut, UserAuthDeps],
2629
service: UserService = Depends(Provide[Container.user_service]),
2730
):
2831
return await service.put_by_id(id=user.id, schema=schema)
@@ -33,13 +36,14 @@ async def put_user(
3336
response_model=UserResponse,
3437
response_class=JSONResponse,
3538
status_code=status.HTTP_200_OK,
39+
dependencies=[PasswordOAuthDeps, GitHubOAuthDeps],
3640
summary="",
3741
description="",
3842
)
3943
@inject
4044
async def patch_user(
41-
user: AuthDeps,
4245
schema: UserPatchRequest,
46+
user: Annotated[UserOut, UserAuthDeps],
4347
service: UserService = Depends(Provide[Container.user_service]),
4448
):
4549
return await service.patch_by_id(id=user.id, schema=schema)
@@ -50,12 +54,13 @@ async def patch_user(
5054
response_model=UserResponse,
5155
response_class=JSONResponse,
5256
status_code=status.HTTP_200_OK,
57+
dependencies=[PasswordOAuthDeps, GitHubOAuthDeps],
5358
summary="",
5459
description="",
5560
)
5661
@inject
5762
async def delete_user(
58-
user: AuthDeps,
63+
user: Annotated[UserOut, UserAuthDeps],
5964
service: UserService = Depends(Provide[Container.user_service]),
6065
):
6166
return await service.delete_by_id(id=user.id)

app/core/auth.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
from dependency_injector.wiring import Provide, inject
44
from fastapi import Depends, HTTPException, Request
5-
from fastapi.security import HTTPBearer
5+
from fastapi.security import (
6+
HTTPBearer,
7+
OAuth2AuthorizationCodeBearer,
8+
OAuth2PasswordBearer,
9+
)
610

11+
from app.core.configs import configs, oauth_endpoints
712
from app.core.container import Container
813
from app.exceptions.auth import NotAuthenticated
914
from app.models.enums import Role
@@ -45,17 +50,34 @@ async def get_current_user(
4550
access_token: Annotated[str, Depends(jwt_bearer)],
4651
service: UserService = Depends(Provide[Container.user_service]),
4752
) -> UserOut:
48-
token = JwtAccessToken(access_token=access_token)
49-
return await service.verify(token=token)
53+
schema = JwtAccessToken(access_token=access_token)
54+
return await service.verify(schema=schema)
5055

5156

52-
AuthDeps = Annotated[UserOut, Depends(get_current_user)]
57+
UserAuthDeps = Depends(get_current_user)
5358

5459

55-
async def get_admin_user(user: AuthDeps) -> UserOut:
60+
async def get_admin_user(user: Annotated[UserOut, UserAuthDeps]) -> UserOut:
5661
if user.role != Role.ADMIN:
5762
raise NotAuthenticated
5863
return user
5964

6065

61-
AdminDeps = Depends(get_admin_user)
66+
AdminAuthDeps = Depends(get_admin_user)
67+
68+
PasswordOAuthDeps = Depends(
69+
OAuth2PasswordBearer(
70+
tokenUrl=oauth_endpoints.PASSWORD, scheme_name="Password OAuth"
71+
)
72+
)
73+
GitHubOAuthDeps = Depends(
74+
OAuth2AuthorizationCodeBearer(
75+
authorizationUrl=f"https://github.com/login/oauth/authorize?client_id={configs.GITHUB_OAUTH_CLIENT_ID}",
76+
tokenUrl=oauth_endpoints.GITHUB,
77+
# NOTE: https://github.com/Zerohertz/fastapi-cookbook/issues/33#issuecomment-2627994536
78+
refreshUrl=None,
79+
scheme_name="GitHub OAuth",
80+
scopes=None,
81+
# NOTE: &scope=repo,user
82+
)
83+
)

app/core/configs.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from enum import Enum
2-
from typing import Optional
32

43
from pydantic import computed_field
54
from pydantic_core import MultiHostUrl
@@ -24,14 +23,14 @@ class Configs(BaseSettings):
2423

2524
# --------- DATABASE SETTINGS --------- #
2625
DB_TYPE: str
27-
DB_DRIVER: Optional[str]
28-
DB_HOST: Optional[str]
29-
DB_PORT: Optional[int] = 0
30-
DB_USER: Optional[str]
31-
DB_PASSWORD: Optional[str]
32-
DB_NAME: Optional[str]
33-
DB_ECHO: Optional[bool] = True
34-
DB_TABLE_CREATE: Optional[bool] = True
26+
DB_DRIVER: str
27+
DB_HOST: str
28+
DB_PORT: int = 0
29+
DB_USER: str
30+
DB_PASSWORD: str
31+
DB_NAME: str
32+
DB_ECHO: bool = True
33+
DB_TABLE_CREATE: bool = True
3534

3635
# --------- AUTH SETTINGS --------- #
3736
GITHUB_OAUTH_CLIENT_ID: str
@@ -69,3 +68,11 @@ def DATABASE_URI(self) -> str:
6968

7069

7170
configs = Configs() # type: ignore[call-arg]
71+
72+
73+
class OAuthEndpoints(BaseSettings):
74+
PASSWORD: str = f"{configs.PREFIX}/v1/auth/token/password"
75+
GITHUB: str = f"{configs.PREFIX}/v1/auth/token/github"
76+
77+
78+
oauth_endpoints = OAuthEndpoints()

app/core/middlewares.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ def info(
5555
async def dispatch(
5656
self, request: Request, call_next: RequestResponseEndpoint
5757
) -> Response:
58+
body = await request.body()
59+
if body:
60+
logger.trace(f"{body=}")
5861
if request.headers.get("x-real-ip"):
5962
ip = request.headers.get("x-real-ip")
6063
elif request.headers.get("x-forwarded-for"):
@@ -67,22 +70,12 @@ async def dispatch(
6770
start_time = time.time()
6871
response = await call_next(request)
6972
end_time = time.time()
70-
status = ansi_format(
71-
str(response.status_code),
72-
bg_color=ANSI_BG_COLOR.LIGHT_BLACK,
73-
style=[ANSI_STYLE.UNDERLINE, ANSI_STYLE.BOLD],
74-
)
75-
elapsed_time = ansi_format(
76-
f"{end_time - start_time:.3f}s",
77-
bg_color=ANSI_BG_COLOR.LIGHT_BLACK,
78-
style=[ANSI_STYLE.UNDERLINE, ANSI_STYLE.BOLD],
79-
)
8073
self.info(
8174
ip=str(ip),
8275
url=str(request.url),
8376
method=str(request.method),
84-
status=status,
85-
elapsed_time=elapsed_time,
77+
status=str(response.status_code),
78+
elapsed_time=f"{end_time - start_time:.3f}s",
8679
)
8780
return response
8881

0 commit comments

Comments
 (0)