working auth + users systems
This commit is contained in:
0
backend/modules/auth/__init__.py
Normal file
0
backend/modules/auth/__init__.py
Normal file
BIN
backend/modules/auth/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/modules/auth/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/api.cpython-312.pyc
Normal file
BIN
backend/modules/auth/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/dependencies.cpython-312.pyc
Normal file
BIN
backend/modules/auth/__pycache__/dependencies.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/models.cpython-312.pyc
Normal file
BIN
backend/modules/auth/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/schemas.cpython-312.pyc
Normal file
BIN
backend/modules/auth/__pycache__/schemas.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/security.cpython-312.pyc
Normal file
BIN
backend/modules/auth/__pycache__/security.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/service.cpython-312.pyc
Normal file
BIN
backend/modules/auth/__pycache__/service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/modules/auth/__pycache__/services.cpython-312.pyc
Normal file
BIN
backend/modules/auth/__pycache__/services.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/modules/auth/__pycache__/utils.cpython-312.pyc
Normal file
BIN
backend/modules/auth/__pycache__/utils.cpython-312.pyc
Normal file
Binary file not shown.
74
backend/modules/auth/api.py
Normal file
74
backend/modules/auth/api.py
Normal 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")
|
||||
18
backend/modules/auth/dependencies.py
Normal file
18
backend/modules/auth/dependencies.py
Normal 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])
|
||||
25
backend/modules/auth/models.py
Normal file
25
backend/modules/auth/models.py
Normal 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)
|
||||
33
backend/modules/auth/schemas.py
Normal file
33
backend/modules/auth/schemas.py
Normal 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
|
||||
172
backend/modules/auth/security.py
Normal file
172
backend/modules/auth/security.py
Normal 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()
|
||||
30
backend/modules/auth/services.py
Normal file
30
backend/modules/auth/services.py
Normal 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
|
||||
0
backend/modules/auth/tasks.py
Normal file
0
backend/modules/auth/tasks.py
Normal file
Reference in New Issue
Block a user