Skip to content

Commit 1352378

Browse files
committed
feat(#10): add authentication process
1 parent 061696e commit 1352378

12 files changed

Lines changed: 269 additions & 13 deletions

File tree

api/database/models/users.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from typing import Union
2+
13
from api.database.models.model_base import ModelBase
24
from sqlalchemy import String
35
from sqlalchemy.orm import Mapped, mapped_column
6+
from typing_extensions import Self
47

58

69
class User(ModelBase):
@@ -13,3 +16,7 @@ class User(ModelBase):
1316
String(255), nullable=False, unique=False, index=True
1417
)
1518
password: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
19+
20+
@classmethod
21+
async def get_by_email(cls, email: str) -> Union[Self, None]:
22+
return await cls.get(cls.email == email)

api/entrypoints/auth.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import Annotated, Union
2+
3+
from api.database.models.users import User
4+
from api.exceptions.http_exceptions import CredentialsException
5+
from api.schemas.auth import Token
6+
from api.services.auth import AuthService
7+
from fastapi import APIRouter, Depends
8+
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
9+
10+
router = APIRouter()
11+
12+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
13+
14+
15+
@router.post("/token", response_model=Token)
16+
async def login_for_access_token(
17+
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
18+
) -> Token:
19+
user: Union[User, None] = await AuthService.authenticate_user(
20+
form_data.username, form_data.password
21+
)
22+
if not user:
23+
raise CredentialsException()
24+
access_token, expires_at = AuthService.create_access_token(data={"sub": user.email})
25+
return Token(access_token=access_token, token_type="bearer", expires_at=expires_at)

api/entrypoints/router.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
from api.entrypoints import monitoring, random_response, root_response, user
1+
from api.entrypoints import auth, monitoring, random_response, root_response, user
22
from fastapi.routing import APIRouter
33

4+
user.router.include_router(user.authenticated_router)
5+
46
router = APIRouter()
57
router.include_router(monitoring.router, tags=["Monitoring"])
68
router.include_router(
79
random_response.router, prefix="/random_number", tags=["Random Number"]
810
)
911
router.include_router(root_response.router, prefix="", tags=["Root Response"])
1012
router.include_router(user.router, prefix="/user", tags=["User"])
13+
router.include_router(auth.router, prefix="/auth", tags=["Auth"])

api/entrypoints/user.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
from typing import List
1+
from typing import Annotated, List
22

3+
from api.database.models.users import User
34
from api.schemas.user import UserCreate, UserOut
5+
from api.services.auth import AuthService
46
from api.services.user_service import UserService
5-
from fastapi import APIRouter
7+
from fastapi import APIRouter, Depends
68

79
router = APIRouter()
10+
authenticated_router = APIRouter(
11+
dependencies=[Depends(AuthService.get_current_active_user)]
12+
)
813

914

10-
@router.get("/", response_model=List[UserOut])
15+
@authenticated_router.get("/", response_model=List[UserOut])
1116
async def get_users() -> List[UserOut]:
1217
"""
1318
Retrieve a list of all users.
@@ -18,7 +23,17 @@ async def get_users() -> List[UserOut]:
1823
return await UserService().get_all()
1924

2025

21-
@router.get("/{user_id}", response_model=UserOut)
26+
@authenticated_router.get("/me", response_model=UserOut)
27+
async def read_user_me(
28+
current_user: Annotated[User, Depends(AuthService.get_current_active_user)]
29+
):
30+
"""
31+
Retrieve the currently authenticated user by Bearer token.
32+
"""
33+
return current_user
34+
35+
36+
@authenticated_router.get("/{user_id}", response_model=UserOut)
2237
async def get_user(user_id: int) -> UserOut:
2338
"""
2439
Retrieve a user by ID.
@@ -46,7 +61,7 @@ async def create_user(user: UserCreate):
4661
return await UserService().create_user(user)
4762

4863

49-
@router.put("/{user_id}", response_model=None)
64+
@authenticated_router.put("/{user_id}", response_model=None)
5065
async def update_user(user_id: int, user: UserCreate) -> None:
5166
"""
5267
Update a user by ID.
@@ -61,7 +76,7 @@ async def update_user(user_id: int, user: UserCreate) -> None:
6176
return await UserService().update_user(user_id, user)
6277

6378

64-
@router.delete("/{user_id}", response_model=None)
79+
@authenticated_router.delete("/{user_id}", response_model=None)
6580
async def delete_user(user_id: int) -> None:
6681
"""
6782
Delete a user by ID.

api/exceptions/http_exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,12 @@ def __init__(self, model: ModelBase):
88
status_code=status.HTTP_404_NOT_FOUND,
99
detail=f"{model.__name__} not found",
1010
)
11+
12+
13+
class CredentialsException(HTTPException):
14+
def __init__(self):
15+
super().__init__(
16+
status_code=status.HTTP_401_UNAUTHORIZED,
17+
detail="Could not validate credentials",
18+
headers={"WWW-Authenticate": "Bearer"},
19+
)

api/schemas/auth.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from datetime import datetime
2+
from typing import Union
3+
4+
from pydantic import BaseModel, field_serializer
5+
6+
7+
class Token(BaseModel):
8+
access_token: str
9+
token_type: str
10+
expires_at: datetime
11+
12+
@field_serializer("expires_at")
13+
def serialize_expires_at(self, value: datetime):
14+
return value.strftime("%d-%m-%Y %H:%M:%S")
15+
16+
17+
class TokenData(BaseModel):
18+
email: Union[str, None] = None

api/schemas/base_db_schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class BaseDBSchema(BaseModel):
1111
deleted_at: Optional[datetime]
1212

1313
class Config:
14-
orm_mode = True
14+
from_attributes = True
1515

1616
@field_serializer("created_at", "updated_at", "deleted_at")
1717
def serialize_dt(self, dt: Optional[datetime]):

api/schemas/user.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
from api.schemas.base_db_schema import BaseDBSchema
2-
from pydantic import BaseModel
2+
from api.services.auth import AuthService
3+
from pydantic import BaseModel, field_serializer
34

45

56
class UserCreate(BaseModel):
67
name: str
78
email: str
89
password: str
910

11+
@field_serializer("password")
12+
def hash_password(self, password: str) -> str:
13+
return AuthService.get_password_hash(password)
14+
1015

1116
class UserOut(BaseDBSchema):
1217
name: str
1318
email: str
19+
20+
21+
class UserInDB(UserOut):
22+
hashed_password: str

api/services/auth.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from datetime import datetime, timedelta, timezone
2+
from typing import Annotated, Union
3+
4+
import jwt
5+
from api.database.models.users import User
6+
from api.exceptions.http_exceptions import CredentialsException
7+
from api.schemas.auth import TokenData
8+
from fastapi import Depends
9+
from fastapi.security import OAuth2PasswordBearer
10+
from passlib.context import CryptContext
11+
12+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
13+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
14+
15+
16+
SECRET_KEY = "ef555b4c8637c33623fe8e91ba7256725e7e2a1bcc75fe84acb189bcaa6c8693"
17+
ALGORITHM = "HS256"
18+
ACCESS_TOKEN_EXPIRE_MINUTES = 30
19+
20+
21+
class AuthService:
22+
@staticmethod
23+
def verify_password(plain_password, hashed_password):
24+
return pwd_context.verify(plain_password, hashed_password)
25+
26+
@staticmethod
27+
def get_password_hash(password):
28+
return pwd_context.hash(password)
29+
30+
@staticmethod
31+
async def authenticate_user(username: str, password: str):
32+
user: Union[User, None] = await User.get_by_email(username)
33+
if not user:
34+
return False
35+
if not AuthService.verify_password(password, user.password):
36+
return False
37+
return user
38+
39+
@staticmethod
40+
def create_access_token(
41+
data: dict, expires_delta_in_minutes: Union[int, None] = None
42+
):
43+
to_encode = data.copy()
44+
expires_at = datetime.now(timezone.utc) + timedelta(
45+
minutes=expires_delta_in_minutes or ACCESS_TOKEN_EXPIRE_MINUTES
46+
)
47+
to_encode.update({"exp": expires_at})
48+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
49+
return encoded_jwt, expires_at
50+
51+
# @staticmethod
52+
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
53+
try:
54+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
55+
username: Union[str, None] = payload.get("sub")
56+
if username is None:
57+
raise CredentialsException()
58+
token_data = TokenData(email=username)
59+
except jwt.InvalidTokenError:
60+
raise CredentialsException()
61+
user: Union[User, None] = await User.get_by_email(token_data.email)
62+
if user is None:
63+
raise CredentialsException()
64+
return user
65+
66+
@staticmethod
67+
async def get_current_active_user(
68+
current_user: Annotated[User, Depends(get_current_user)],
69+
):
70+
if current_user.deleted_at:
71+
raise CredentialsException()
72+
return current_user

poetry.lock

Lines changed: 95 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)