Skip to content

Commit ccad5d4

Browse files
Merge pull request #4 from codewithme-py/feat/Auth-security-layer
feat(auth): implement basic jwt auth and password hashing
2 parents c63eaa2 + f3e3af0 commit ccad5d4

15 files changed

Lines changed: 242 additions & 73 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
# FairDrop_draft
2+
[![Workflow Status](https://github.com/codewithme-py/FairDrop/actions/workflows/ci.yaml/badge.svg)](https://github.com/codewithme-py/FairDrop/actions/workflows/ci.yaml)

app/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
1616
s3_port: int = Field(alias='S3_PORT')
1717
minio_root_user: str = Field(alias='MINIO_ROOT_USER')
1818
minio_root_password: str = Field(alias='MINIO_ROOT_PASSWORD')
19+
secret_key: str = Field(alias='SECRET_KEY')
1920
debug_mode: bool = Field(default=False, alias='DEBUG_MODE')
2021

2122
@computed_field

app/core/database.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ class Base(DeclarativeBase):
2222
pass
2323

2424

25-
async def get_db() -> AsyncGenerator[AsyncSession, None]:
25+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
2626
async with async_session_factory() as session:
2727
yield session

app/core/exception_handlers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from fastapi import Request, status
2+
from fastapi.responses import JSONResponse
3+
4+
from .exceptions import CredentialsError, UserAlreadyExists
5+
6+
7+
async def user_already_exists_handler(
8+
request: Request, exc: UserAlreadyExists
9+
) -> JSONResponse:
10+
return JSONResponse(
11+
status_code=status.HTTP_400_BAD_REQUEST,
12+
content={'detail': str(exc) or 'User already exists'},
13+
)
14+
15+
16+
async def credentials_error_handler(
17+
request: Request, exc: CredentialsError
18+
) -> JSONResponse:
19+
return JSONResponse(
20+
status_code=status.HTTP_401_UNAUTHORIZED,
21+
content={'detail': str(exc) or 'Invalid credentials'},
22+
headers=getattr(exc, 'headers', None),
23+
)

app/core/exceptions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class AppError(Exception):
2+
"""Базовый класс для всех ошибок приложения."""
3+
4+
def __init__(self, message: str = '', headers: dict | None = None):
5+
self.message = message
6+
self.headers = headers
7+
super().__init__(message)
8+
9+
10+
class UserAlreadyExists(AppError):
11+
"""Пользователь c таким email уже существует."""
12+
13+
14+
class CredentialsError(AppError):
15+
"""Неверные учетные данные."""
16+
17+
def __init__(self, message: str = 'Could not validate credentials'):
18+
super().__init__(message=message, headers={'WWW-Authenticate': 'Bearer'})

app/core/security.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from datetime import UTC, datetime, timedelta
2+
3+
from jose import jwt
4+
from passlib.context import CryptContext
5+
6+
from app.core.config import settings
7+
8+
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
9+
ALGORITHM = 'HS256'
10+
11+
12+
def verify_password(plain_password: str, hashed_password: str) -> bool:
13+
return bool(pwd_context.verify(plain_password, hashed_password))
14+
15+
16+
def get_password_hash(password: str) -> str:
17+
return str(pwd_context.hash(password))
18+
19+
20+
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
21+
to_encode = data.copy()
22+
if expires_delta:
23+
expire = datetime.now(UTC) + expires_delta
24+
else:
25+
expire = datetime.now(UTC) + timedelta(minutes=30)
26+
to_encode.update({'exp': expire})
27+
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)
28+
return str(encoded_jwt)

app/core/setup.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from fastapi import FastAPI
2+
3+
from .exception_handlers import credentials_error_handler, user_already_exists_handler
4+
from .exceptions import CredentialsError, UserAlreadyExists
5+
6+
7+
def setup_exception_handlers(app: FastAPI) -> None:
8+
app.add_exception_handler(UserAlreadyExists, user_already_exists_handler) # type: ignore[arg-type]
9+
app.add_exception_handler(CredentialsError, credentials_error_handler) # type: ignore[arg-type]

app/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22
from fastapi import FastAPI
33

44
from app.core.logging import setup_logging
5+
from app.core.setup import setup_exception_handlers
6+
from app.services.user.routes import router_v1
57

68
setup_logging()
79
logger = structlog.get_logger(__name__)
810
app = FastAPI()
911

12+
setup_exception_handlers(app)
13+
14+
app.include_router(router_v1, prefix='/api/v1', tags=['Users'])
15+
1016

1117
@app.get('/health')
1218
def health() -> dict[str, str]:

app/services/user/routes.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Depends, status
4+
from fastapi.security import OAuth2PasswordRequestForm
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
7+
from app.core.database import get_session
8+
from app.core.exceptions import CredentialsError
9+
from app.core.security import create_access_token
10+
from app.services.user.models import User
11+
from app.services.user.schemas import Token, UserCreate, UserRead
12+
from app.services.user.service import UserService
13+
from app.shared.deps import get_current_user
14+
15+
router_v1 = APIRouter()
16+
17+
18+
@router_v1.post('/users', status_code=status.HTTP_201_CREATED)
19+
async def create_user(
20+
user_create: UserCreate, session: Annotated[AsyncSession, Depends(get_session)]
21+
) -> UserRead:
22+
user = await UserService.create_user(session, user_create)
23+
return UserRead.model_validate(user)
24+
25+
26+
@router_v1.post('/auth/token')
27+
async def login(
28+
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
29+
session: Annotated[AsyncSession, Depends(get_session)],
30+
) -> Token:
31+
user = await UserService.authenticate_user(
32+
session, form_data.username, form_data.password
33+
)
34+
if not user:
35+
raise CredentialsError()
36+
access_token = create_access_token(data={'sub': str(user.email)})
37+
return Token(access_token=access_token, token_type='bearer')
38+
39+
40+
@router_v1.get('/users/me')
41+
async def read_user_me(
42+
current_user: Annotated[User, Depends(get_current_user)],
43+
) -> UserRead:
44+
return UserRead.model_validate(current_user)

app/services/user/schemas.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from uuid import UUID
2+
3+
from pydantic import BaseModel, ConfigDict, EmailStr
4+
5+
6+
class UserCreate(BaseModel):
7+
email: EmailStr
8+
password: str
9+
10+
11+
class UserRead(BaseModel):
12+
model_config = ConfigDict(from_attributes=True)
13+
id: UUID
14+
email: EmailStr
15+
16+
17+
class Token(BaseModel):
18+
access_token: str
19+
token_type: str

0 commit comments

Comments
 (0)