working auth + users systems

This commit is contained in:
c-d-p
2025-04-16 21:32:57 +02:00
parent 516adc606d
commit 18ddb2f332
56 changed files with 943 additions and 0 deletions

View File

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,74 @@
# modules/auth/api.py
from fastapi import APIRouter, Cookie, Depends, HTTPException, status, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError
from modules.auth.models import User
from modules.auth.schemas import UserCreate, UserResponse, Token
from modules.auth.services import create_user
from modules.auth.security import TokenType, get_current_user, oauth2_scheme, create_access_token, create_refresh_token, verify_token, authenticate_user, blacklist_tokens
from sqlalchemy.orm import Session
from typing import Annotated, Optional
from core.database import get_db
from datetime import timedelta
from core.config import settings # Assuming settings is defined in core.config
from core.exceptions import unauthorized_exception
router = APIRouter()
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register(user: UserCreate, db: Annotated[Session, Depends(get_db)]):
return create_user(user.username, user.password, user.name, db)
@router.post("/login", response_model=Token)
def login(response: Response, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[Session, Depends(get_db)]):
"""
Authenticate user and return JWT token.
"""
user = authenticate_user(form_data.username, form_data.password, db)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
access_token = create_access_token(data={"sub": user.username}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
refresh_token = create_refresh_token(data={"sub": user.username})
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
response.set_cookie(
key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="Lax", max_age=max_age
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/refresh")
def refresh_token(request: Request, db: Annotated[Session, Depends(get_db)]):
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise unauthorized_exception("Refresh token missing")
user_data = verify_token(refresh_token, expected_token_type=TokenType.REFRESH, db=db)
if not user_data:
raise unauthorized_exception("Invalid refresh token")
new_access_token = create_access_token(data={"sub": user_data.username})
return {"access_token": new_access_token, "token_type": "bearer"}
@router.post("/logout")
def logout(response: Response, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], access_token: str = Depends(oauth2_scheme), refresh_token: Optional[str] = Cookie(None, alias="refresh_token")):
try:
if not refresh_token:
raise unauthorized_exception("Refresh token not found")
blacklist_tokens(
access_token=access_token,
refresh_token=refresh_token,
db=db
)
response.delete_cookie(key="refresh_token")
return {"message": "Logged out successfully"}
except JWTError:
raise unauthorized_exception("Invalid token")

View File

@@ -0,0 +1,18 @@
# modules/auth/dependencies.py
from fastapi import Depends, HTTPException, status
from modules.auth.security import get_current_user
from modules.auth.schemas import UserRole
from modules.auth.models import User
from core.exceptions import forbidden_exception
class RoleChecker:
def __init__(self, allowed_roles: list[UserRole]):
self.allowed_roles = allowed_roles
def __call__(self, user: User = Depends(get_current_user)):
if user.role not in self.allowed_roles:
forbidden_exception("You do not have permission to perform this action.")
return user
admin_only = RoleChecker([UserRole.ADMIN])
any_user = RoleChecker([UserRole.ADMIN, UserRole.USER])

View File

@@ -0,0 +1,25 @@
# modules/auth/models.py
from core.database import Base
from sqlalchemy import CheckConstraint, Column, Integer, String, Enum, DateTime
from enum import Enum as PyEnum
class UserRole(str, PyEnum):
ADMIN = "admin"
USER = "user"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
uuid = Column(String, unique=True)
username = Column(String, unique=True)
hashed_password = Column(String)
role = Column(Enum(UserRole), nullable=False, default=UserRole.USER)
name = Column(String)
class TokenBlacklist(Base):
__tablename__ = "token_blacklist"
id = Column(Integer, primary_key=True)
token = Column(String, unique=True)
expires_at = Column(DateTime)

View File

@@ -0,0 +1,33 @@
# modules/auth/schemas.py
from enum import Enum as PyEnum
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
refresh_token: str | None = None
class TokenData(BaseModel):
username: str | None = None
scopes: list[str] = []
class UserRole(str, PyEnum):
ADMIN = "admin"
USER = "user"
class UserCreate(BaseModel):
username: str
password: str
name: str
class UserPatch(BaseModel):
name: str | None = None
class UserResponse(BaseModel):
uuid: str
username: str
name: str
role: UserRole
class Config:
from_attributes = True

View File

@@ -0,0 +1,172 @@
# modules/auth/security.py
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from sqlalchemy.orm import Session
from core.database import get_db
from core.config import settings
from modules.auth.models import TokenBlacklist, User
from modules.auth.schemas import TokenData
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
class TokenType(str, Enum):
ACCESS = "access"
REFRESH = "refresh"
password_hasher = PasswordHasher()
def hash_password(password: str) -> str:
"""Hash a password with Argon2 (and optional pepper)."""
peppered_password = password + settings.PEPPER # Prepend/append pepper
return password_hasher.hash(peppered_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hashed version using Argon2."""
peppered_password = plain_password + settings.PEPPER
try:
return password_hasher.verify(hashed_password, peppered_password)
except VerifyMismatchError:
return False
def authenticate_user(username: str, password: str, db: Session) -> User | None:
"""
Authenticate a user by checking username/password against the database.
Returns User object if valid, None otherwise.
"""
# Get user from database
user = db.query(User).filter(User.username == username).first()
# If user not found or password doesn't match
if not user or not verify_password(password, user.hashed_password):
return None
return user
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "token_type": TokenType.ACCESS})
return jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM
)
def create_refresh_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "token_type": TokenType.REFRESH})
return jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM
)
def verify_token(token: str, expected_token_type: TokenType, db: Session) -> TokenData | None:
"""Verify a JWT token and return TokenData if valid.
Parameters
----------
token: str
The JWT token to be verified.
expected_token_type: TokenType
The expected type of token (access or refresh)
db: Session
Database session to fetch user data.
Returns
-------
TokenData | None
TokenData instance if the token is valid, None otherwise.
"""
is_blacklisted = db.query(TokenBlacklist).filter(TokenBlacklist.token == token).first() is not None
if is_blacklisted:
return None
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
username: str = payload.get("sub")
token_type: str = payload.get("token_type")
if username is None or token_type != expected_token_type:
return None
return TokenData(username=username)
except JWTError:
return None
def get_current_user(db: Annotated[Session, Depends(get_db)], token: str = Depends(oauth2_scheme)) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if the token is blacklisted
is_blacklisted = db.query(TokenBlacklist).filter(TokenBlacklist.token == token).first() is not None
if is_blacklisted:
raise credentials_exception
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM]
)
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user: User = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
return user
def blacklist_tokens(access_token: str, refresh_token: str, db: Session) -> None:
"""Blacklist both access and refresh tokens.
Parameters
----------
access_token: str
The access token to blacklist
refresh_token: str
The refresh token to blacklist
db: Session
Database session to perform the operation.
"""
for token in [access_token, refresh_token]:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
expires_at = datetime.fromtimestamp(payload.get("exp"))
# Add the token to the blacklist
blacklisted_token = TokenBlacklist(token=token, expires_at=expires_at)
db.add(blacklisted_token)
db.commit()
def blacklist_token(token: str, db: Session) -> None:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
expires_at = datetime.fromtimestamp(payload.get("exp"))
# Add the token to the blacklist
blacklisted_token = TokenBlacklist(token=token, expires_at=expires_at)
db.add(blacklisted_token)
db.commit()

View File

@@ -0,0 +1,30 @@
# modules/auth/services.py
from sqlalchemy.orm import Session
from modules.auth.models import User
from modules.auth.schemas import UserResponse
from modules.auth.security import hash_password
from core.exceptions import conflict_exception
import uuid
def create_user(username: str, password: str, name: str, db: Session) -> UserResponse:
"""
Create a new user in the database.
Hashes the password before storing it.
Returns the created user object.
"""
if db is None:
raise ValueError("Database session is required")
# Check if the user already exists
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
raise conflict_exception("Username already exists")
hashed_password = hash_password(password)
user_uuid = str(uuid.uuid4())
user = User(username=username, hashed_password=hashed_password, name=name, uuid=user_uuid)
db.add(user)
db.commit()
db.refresh(user) # Loads the generated ID
return UserResponse.model_validate(user) # Converts SQLAlchemy model -> Pydantic

View File