Skip to content
This repository was archived by the owner on Jul 29, 2024. It is now read-only.

Commit 6e484d4

Browse files
refactor: app structure (#71)
* feat: add black & flake8 auto format config for vscode * chore: update README.md for auto formatting by vscode extention * refactor: app structure * chore: update README.md * chore: add github template * Update api/auth/routers.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: add AccessTokenResponseModel * chore: delete unused file * Update api/user/routers.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * refactor: to use http status from fastapi * chore: add default value for env variables * chore: update user update model * fix: user select query * refactor: to use fastapi's built-in exception handlers * Update api/auth/controllers.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update api/auth/controllers.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update api/auth/models.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update api/database/query.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * refactor: raise exception if env variables not found * fix: EmailStr import error * fix: typo * chore: update db init exception messages * refactor: user update api * refactor: users get query to consider pagination * chore: add constructor to auth provider * feat: refactor code again * refactor: update code by ai feedback * fix: update user * refactor: update by ai feedback * refactor: update auth code --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 6485fd6 commit 6e484d4

25 files changed

+603
-370
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
name: Bug report
3+
about: Create a report to help us improve
4+
title: ''
5+
labels: bug
6+
assignees: ''
7+
8+
---
9+
10+
**Describe the bug**
11+
A clear and concise description of what the bug is.
12+
13+
**To Reproduce**
14+
Steps to reproduce the behavior:
15+
1. Go to '...'
16+
2. Click on '....'
17+
3. Scroll down to '....'
18+
4. See error
19+
20+
**Expected behavior**
21+
A clear and concise description of what you expected to happen.
22+
23+
**Screenshots**
24+
If applicable, add screenshots to help explain your problem.
25+
26+
**System Info (if dev / build issue):**
27+
- OS: [e.g. iOS]
28+
- Node version (please ensure you are using 12+)
29+
- Npm version
30+
31+
**Browser Info (if display / formatting issue):**
32+
- Device [e.g. Desktop, iPhone6]
33+
- Browser [e.g. chrome, safari]
34+
- Version [e.g. 22]
35+
36+
**Additional context**
37+
Add any other context about the problem here.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
name: Feature request
3+
about: Suggest an idea for this project
4+
title: ''
5+
labels: ''
6+
assignees: ''
7+
8+
---
9+
10+
**Is your feature request related to a problem? Please describe.**
11+
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12+
13+
**Describe the solution you'd like**
14+
A clear and concise description of what you want to happen.
15+
16+
**Describe alternatives you've considered**
17+
A clear and concise description of any alternative solutions or features you've considered.
18+
19+
**Additional context**
20+
Add any other context or screenshots about the feature request here.

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## Description
2+
3+
-
4+
5+
## Related Issues
6+
7+
<!--
8+
Link to the issue that is fixed by this PR (if there is one)
9+
e.g. Fixes #1234, Addresses #1234, Related to #1234, etc.
10+
-->

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
# Git
2+
.git
3+
.github
4+
15
# API Server
26
__pycache__
37

48
# Front-end
5-
frontend
9+
frontend
10+
node_modules

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
# fastapi-mysql-docker
22

3+
## Elements
4+
35
- FastAPI
46
- MySQL
57
- Docker
68

7-
## Setup
9+
---
10+
11+
## Setup development environment
812

913
Please install `Docker` and `Docker compose` first.
1014

@@ -56,8 +60,8 @@ If you need a front-end app for this server-side & DB server.
5660
You can clone the front-end template from:
5761

5862
- https://github.com/qlawmarq/nuxt3-tailwind-auth-app
59-
- https://github.com/qlawmarq/expo-react-native-base
60-
- https://github.com/qlawmarq/next-web-app-template
63+
64+
---
6165

6266
## Note
6367

api/Dockerfile

Lines changed: 0 additions & 9 deletions
This file was deleted.

api/auth/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .provider import *

api/auth/controllers.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from fastapi import HTTPException, status
2+
from database.query import query_put
3+
from auth.provider import AuthProvider
4+
from auth.models import SignUpRequestModel
5+
from user.controllers import get_users_by_email
6+
7+
auth_handler = AuthProvider()
8+
9+
10+
def register_user(user_model: SignUpRequestModel):
11+
user = get_users_by_email(user_model.email)
12+
if len(user) != 0:
13+
raise HTTPException(
14+
status_code=status.HTTP_409_CONFLICT, detail="Email already exists."
15+
)
16+
hashed_password = auth_handler.get_password_hash(user_model.password)
17+
query_put(
18+
"""
19+
INSERT INTO user (
20+
first_name,
21+
last_name,
22+
email,
23+
password_hash
24+
) VALUES (%s,%s,%s,%s)
25+
""",
26+
(
27+
user_model.first_name,
28+
user_model.last_name,
29+
user_model.email,
30+
hashed_password,
31+
),
32+
)
33+
return {
34+
"first_name": user_model.first_name,
35+
"last_name": user_model.last_name,
36+
"email": user_model.email,
37+
# Do not return the password hash or any sensitive information
38+
}
39+
40+
41+
def signin_user(email, password):
42+
user = auth_handler.authenticate_user(email, password)
43+
return user

api/auth/models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from pydantic import BaseModel, EmailStr
2+
from user.models import UserResponseModel
3+
4+
5+
class SignInRequestModel(BaseModel):
6+
email: str
7+
password: str
8+
9+
10+
class SignUpRequestModel(BaseModel):
11+
email: EmailStr
12+
password: str
13+
first_name: str
14+
last_name: str
15+
16+
17+
class TokenModel(BaseModel):
18+
access_token: str
19+
refresh_token: str
20+
21+
22+
class UserAuthResponseModel(BaseModel):
23+
token: TokenModel
24+
user: UserResponseModel
25+
26+
27+
class AccessTokenResponseModel(BaseModel):
28+
access_token: str

api/auth/provider.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from datetime import datetime, timedelta
2+
from typing import Annotated
3+
from database import query_get
4+
from fastapi import Depends, HTTPException, status
5+
from fastapi.security import OAuth2PasswordBearer
6+
from jose import JWTError, jwt
7+
from passlib.context import CryptContext
8+
from pydantic import BaseModel
9+
import os
10+
11+
OAUTH2_SCHEME = OAuth2PasswordBearer(tokenUrl="token")
12+
13+
CREDENTIALS_EXCEPTION = HTTPException(
14+
status_code=status.HTTP_401_UNAUTHORIZED,
15+
detail="Could not validate credentials",
16+
headers={"WWW-Authenticate": "Bearer"},
17+
)
18+
USER_NOT_FOUND_EXCEPTION = HTTPException(
19+
status_code=status.HTTP_404_NOT_FOUND,
20+
detail="User not found",
21+
)
22+
23+
24+
class TokenData(BaseModel):
25+
user_email: str | None = None
26+
27+
28+
class AuthUser(BaseModel):
29+
id: int
30+
first_name: str
31+
last_name: str
32+
user_email: str
33+
34+
35+
class AuthProvider:
36+
ALGORITHM = "HS256"
37+
TOKEN_EXPIRE_MINS = 30
38+
REFRESH_TOKEN_EXPIRE_HOURS = 10
39+
PWD_CONTEXT = CryptContext(schemes=["bcrypt"], deprecated="auto")
40+
41+
def __init__(self) -> None:
42+
self.SECRET_KEY = os.getenv("APP_SECRET_STRING")
43+
if not self.SECRET_KEY:
44+
raise EnvironmentError("APP_SECRET_STRING environment variable not found")
45+
46+
def verify_password(self, plain_password, hashed_password) -> bool:
47+
return self.PWD_CONTEXT.verify(plain_password, hashed_password)
48+
49+
def get_password_hash(self, password) -> str:
50+
return self.PWD_CONTEXT.hash(password)
51+
52+
def authenticate_user(self, user_email: str, password: str) -> AuthUser:
53+
user = self.get_user_by_email(user_email)
54+
if not user:
55+
raise USER_NOT_FOUND_EXCEPTION
56+
if not self.verify_password(password, user["password_hash"]):
57+
raise CREDENTIALS_EXCEPTION
58+
return user
59+
60+
def create_access_token(
61+
self, data: dict, expires_delta: timedelta | None = None
62+
) -> str:
63+
to_encode = data.copy()
64+
if expires_delta:
65+
expire = datetime.utcnow() + expires_delta
66+
else:
67+
expire = datetime.utcnow() + timedelta(minutes=self.TOKEN_EXPIRE_MINS)
68+
to_encode.update({"exp": expire})
69+
encoded_jwt = jwt.encode(to_encode, self.SECRET_KEY, algorithm=self.ALGORITHM)
70+
return encoded_jwt
71+
72+
def encode_token(self, user_email) -> str:
73+
payload = {
74+
"exp": datetime.utcnow()
75+
+ timedelta(days=0, minutes=self.TOKEN_EXPIRE_MINS),
76+
"iat": datetime.utcnow(),
77+
"scope": "access_token",
78+
"sub": user_email,
79+
}
80+
return jwt.encode(payload, self.SECRET_KEY, algorithm=self.ALGORITHM)
81+
82+
def refresh_token(self, refresh_token) -> str:
83+
try:
84+
payload = jwt.decode(
85+
refresh_token, self.SECRET_KEY, algorithms=self.ALGORITHM
86+
)
87+
if payload["scope"] == "refresh_token":
88+
user_email = payload["sub"]
89+
new_token = self.encode_token(user_email)
90+
return new_token
91+
raise CREDENTIALS_EXCEPTION
92+
except jwt.ExpiredSignatureError:
93+
raise CREDENTIALS_EXCEPTION
94+
except jwt.InvalidTokenError:
95+
raise CREDENTIALS_EXCEPTION
96+
97+
def encode_refresh_token(self, user_email) -> str:
98+
payload = {
99+
"exp": datetime.utcnow()
100+
+ timedelta(days=0, hours=self.REFRESH_TOKEN_EXPIRE_HOURS),
101+
"iat": datetime.utcnow(),
102+
"scope": "refresh_token",
103+
"sub": user_email,
104+
}
105+
return jwt.encode(payload, self.SECRET_KEY, algorithm=self.ALGORITHM)
106+
107+
async def get_current_user(
108+
self, token: Annotated[str, Depends(OAUTH2_SCHEME)]
109+
) -> AuthUser:
110+
user = None
111+
try:
112+
payload = jwt.decode(token, self.SECRET_KEY, algorithms=[self.ALGORITHM])
113+
user_email: str = payload.get("sub")
114+
if user_email is None:
115+
raise CREDENTIALS_EXCEPTION
116+
token_data = TokenData(user_email=user_email)
117+
except JWTError:
118+
raise CREDENTIALS_EXCEPTION
119+
user = self.get_user_by_email(token_data.user_email)
120+
if user is None:
121+
raise CREDENTIALS_EXCEPTION
122+
return user
123+
124+
def get_user_by_email(self, user_email: str) -> AuthUser:
125+
user = query_get(
126+
"""
127+
SELECT
128+
user.id,
129+
user.first_name,
130+
user.last_name,
131+
user.email,
132+
user.password_hash
133+
FROM user
134+
WHERE email = %s
135+
""",
136+
[user_email],
137+
)
138+
if len(user) == 0:
139+
raise USER_NOT_FOUND_EXCEPTION
140+
return user[0]

0 commit comments

Comments
 (0)