feat(auth): implement authentication endpoints and JWT handling

This commit is contained in:
gabdevele
2026-04-12 20:18:33 +02:00 Unverified
parent f3979c56ef
commit cc8ae34838
18 changed files with 640 additions and 33 deletions
+14 -2
View File
@@ -1,11 +1,23 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
MONGODB_URI: str
MONGODB_DB_NAME: str
REDIS_URL: str
JWT_SECRET_KEY: str
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = Field(default=30, gt=0)
AUTH_USERNAME: str
AUTH_PASSWORD: str
settings = Settings()
+2 -2
View File
@@ -1,6 +1,5 @@
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
from core.config import settings
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
class _MongoDB:
@@ -25,6 +24,7 @@ async def close_mongodb() -> None:
if client is not None:
client.close()
async def get_database() -> AsyncIOMotorDatabase:
return mongodb.get_database()
+1 -1
View File
@@ -1,9 +1,9 @@
import redis.asyncio as aioredis
from core.config import settings
_redis_pool: aioredis.Redis | None = None
async def init_redis() -> None:
global _redis_pool
_redis_pool = aioredis.from_url(
+99
View File
@@ -0,0 +1,99 @@
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from authlib.jose import jwt # type: ignore
from authlib.jose.errors import JoseError # type: ignore
from core.config import settings
from db.auth import get_user_by_username, is_token_revoked
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from models.auth import AccessTokenPayload, AuthUserDocument
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
bearer_scheme = HTTPBearer(auto_error=False)
@dataclass(slots=True)
class AuthContext:
token: str
user: AuthUserDocument
payload: AccessTokenPayload
class InvalidTokenError(Exception):
pass
def token_auth_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, password_hash: str) -> bool:
return pwd_context.verify(plain_password, password_hash)
def create_access_token(*, subject: str) -> str:
now = datetime.now(timezone.utc)
expires_delta = timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
expires_at = int((now + expires_delta).timestamp())
header = {
"alg": settings.JWT_ALGORITHM,
"typ": "JWT",
}
payload = {
"sub": subject,
"jti": str(uuid4()),
"exp": expires_at,
}
token = jwt.encode(header, payload, settings.JWT_SECRET_KEY)
if isinstance(token, bytes):
return token.decode("utf-8")
return token
def decode_access_token(token: str) -> AccessTokenPayload:
try:
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
claims.validate()
return AccessTokenPayload.model_validate(dict(claims))
except (JoseError, ValueError, TypeError):
raise InvalidTokenError from None
async def get_auth_context(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> AuthContext:
if credentials is None or credentials.scheme.lower() != "bearer":
raise token_auth_error()
token = credentials.credentials
try:
payload = decode_access_token(token)
except InvalidTokenError:
raise token_auth_error() from None
if await is_token_revoked(payload.jti):
raise token_auth_error()
user = await get_user_by_username(payload.sub)
if user is None:
raise token_auth_error()
return AuthContext(token=token, user=user, payload=payload)
async def require_authenticated_user(
auth_context: AuthContext = Depends(get_auth_context),
) -> AuthUserDocument:
return auth_context.user