Skip to content

Commit e69d590

Browse files
authored
Merge pull request #13 from ArielMAJ/feat/#10/add-authentication
feat(#10): add authentication process
2 parents 061696e + aaffb8a commit e69d590

14 files changed

Lines changed: 280 additions & 13 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ POSTGRES_PORT=5432
1111

1212
POSTGRES_ECHO=true
1313
DATABASE_ENABLE_CONNECTION_POOLING=true
14+
15+
SECRET_KEY=
16+
ALGORITHM=
17+
ACCESS_TOKEN_EXPIRE_MINUTES=

api/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ class DatabaseConfig:
5858
}
5959

6060

61+
class AuthConfig:
62+
SECRET_KEY: str = os.getenv("SECRET_KEY")
63+
ALGORITHM: str = os.getenv("ALGORITHM")
64+
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES"))
65+
66+
6167
class Config:
6268
"""Base configuration."""
6369

@@ -74,6 +80,7 @@ class Config:
7480
APPLICATION_ROOT = os.getenv("APPLICATION_ROOT", "")
7581

7682
DATABASE: DatabaseConfig = DatabaseConfig()
83+
AUTH: AuthConfig = AuthConfig()
7784

7885

7986
class TestConfig(Config):

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+
@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

0 commit comments

Comments
 (0)