Files
Aegis/backend/app/routers/auth.py
T
kitos 4e378540af fix(security): replace python-jose with PyJWT to eliminate ecdsa CVEs
Snyk scan found 3 High severity vulns: two in ecdsa (pulled by python-jose)
and one in diskcache (pulled by pySigma, never imported). Remove both
vulnerable dependencies and migrate JWT handling to PyJWT. Fix
test_logout_revokes_token which broke because test stubs sys.modules[jose]
with a MagicMock at collection time; test now uses PyJWT directly.
2026-06-11 09:06:16 +02:00

296 lines
9.4 KiB
Python

"""Authentication router: login, logout and current-user endpoints.
The JWT access token is delivered as an **HttpOnly** cookie
(``aegis_token``) so it is inaccessible to client-side JavaScript,
mitigating XSS token-theft attacks. The JSON response also includes
the token in the body for backwards compatibility and for clients that
cannot use cookies (e.g. Swagger UI).
"""
# Import os
import os
# Import APIRouter, Cookie, Depends, Request, Response from fastapi
from fastapi import APIRouter, Cookie, Depends, Request, Response
# Import OAuth2PasswordRequestForm from fastapi.security
from fastapi.security import OAuth2PasswordRequestForm
# Import jwt (PyJWT)
import jwt
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import blacklist_token, create_access_token, verify_pa... from app.auth
from app.auth import blacklist_token, create_access_token, verify_password
# Import settings from app.config
from app.config import settings
# Import get_db from app.database
from app.database import get_db
# Import get_current_user from app.dependencies.auth
from app.dependencies.auth import get_current_user
# Import BusinessRuleViolation, PermissionViolation from app.domain.errors
from app.domain.errors import BusinessRuleViolation, PermissionViolation
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import limiter from app.limiter
from app.limiter import limiter
# Import resolve_client_ip from app.middleware.request_context
from app.middleware.request_context import resolve_client_ip
# Import User from app.models.user
from app.models.user import User
# Import TokenResponse, UserOut from app.schemas.auth
from app.schemas.auth import TokenResponse, UserOut
# Import PasswordChange from app.schemas.user
from app.schemas.user import PasswordChange
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
# Import from app.services.auth_service
from app.services.auth_service import (
_DUMMY_HASH,
)
# Import from app.services.auth_service
from app.services.auth_service import (
change_password as auth_change_password,
)
# Assign router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix="/auth", tags=["auth"])
# Assign _IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
_IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
# Assign _COOKIE_NAME = "aegis_token"
_COOKIE_NAME = "aegis_token"
# Apply the @router.post decorator
@router.post("/login", response_model=TokenResponse)
# Apply the @limiter.limit decorator
@limiter.limit("5/minute")
# Define function login
def login(
# Entry: request
request: Request,
# Entry: response
response: Response,
# Entry: form_data
form_data: OAuth2PasswordRequestForm = Depends(),
# Entry: db
db: Session = Depends(get_db),
) -> TokenResponse:
"""Authenticate a user and return a JWT access token.
Rate-limited to **5 attempts per minute per IP**. Failed and successful
logins are recorded in the audit log (SEC-009).
"""
# Assign user = db.query(User).filter(User.username == form_data.username).first()
user = db.query(User).filter(User.username == form_data.username).first()
# Assign target_hash = user.hashed_password if user else _DUMMY_HASH
target_hash = user.hashed_password if user else _DUMMY_HASH
# Assign password_valid = verify_password(form_data.password, target_hash)
password_valid = verify_password(form_data.password, target_hash)
# Assign ip = resolve_client_ip(request)
ip = resolve_client_ip(request)
# Check: user is None or not password_valid
if user is None or not password_valid:
# Open context manager
with UnitOfWork(db) as uow:
# Call log_action()
log_action(
db,
user.id if user else None,
# Literal argument value
"LOGIN_FAILED",
# Literal argument value
"auth",
# Literal argument value
None,
# Keyword argument: details
details={
# Literal argument value
"username": form_data.username,
# Literal argument value
"ip": ip,
# Literal argument value
"reason": "invalid_credentials",
},
# Keyword argument: ip_address
ip_address=ip,
)
# Call uow.commit()
uow.commit()
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Incorrect username or password")
# Check: not user.is_active
if not user.is_active:
# Raise PermissionViolation
raise PermissionViolation("Account is disabled. Contact an administrator.")
# Assign access_token = create_access_token(data={"sub": user.username})
access_token = create_access_token(data={"sub": user.username})
# Open context manager
with UnitOfWork(db) as uow:
# Call log_action()
log_action(
db,
user.id,
# Literal argument value
"LOGIN_SUCCESS",
# Literal argument value
"auth",
str(user.id),
# Keyword argument: details
details={"username": user.username, "ip": ip},
# Keyword argument: ip_address
ip_address=ip,
)
# Call uow.commit()
uow.commit()
# Call response.set_cookie()
response.set_cookie(
# Keyword argument: key
key=_COOKIE_NAME,
# Keyword argument: value
value=access_token,
# Keyword argument: httponly
httponly=True,
# Keyword argument: secure
secure=_IS_HTTPS,
# Keyword argument: samesite
samesite="strict",
# Keyword argument: max_age
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
# Keyword argument: path
path="/",
)
# Return TokenResponse(access_token=access_token)
return TokenResponse(access_token=access_token)
# Apply the @router.post decorator
@router.post("/logout")
# Define function logout
def logout(
# Entry: request
request: Request,
# Entry: response
response: Response,
# Entry: aegis_token
aegis_token: str | None = Cookie(None),
) -> dict:
"""Clear the authentication cookie and revoke the current token."""
# Assign bearer = (
bearer = (
request.headers.get("Authorization")
or request.headers.get("authorization")
or ""
)
# Assign bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip()
bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip()
# Assign seen = set()
seen: set[str] = set()
# Iterate over (aegis_token, bearer)
for raw in (aegis_token, bearer):
# Check: not raw or raw in seen
if not raw or raw in seen:
# Skip to the next loop iteration
continue
# Call seen.add()
seen.add(raw)
# Attempt the following; catch errors below
try:
# Assign payload = jwt.decode(
payload = jwt.decode(
raw,
settings.SECRET_KEY,
# Keyword argument: algorithms
algorithms=[settings.ALGORITHM],
)
# Assign jti = payload.get("jti")
jti = payload.get("jti")
# Assign exp = payload.get("exp", 0)
exp = payload.get("exp", 0)
# Check: jti
if jti:
# Call blacklist_token()
blacklist_token(jti, float(exp))
# Handle any JWT validation error during logout (token may be expired or malformed)
except jwt.exceptions.InvalidTokenError:
# Intentional no-op placeholder
pass
# Call response.delete_cookie()
response.delete_cookie(
# Keyword argument: key
key=_COOKIE_NAME,
# Keyword argument: httponly
httponly=True,
# Keyword argument: secure
secure=_IS_HTTPS,
# Keyword argument: samesite
samesite="strict",
# Keyword argument: path
path="/",
)
# Return {"detail": "Logged out"}
return {"detail": "Logged out"}
# Apply the @router.get decorator
@router.get("/me", response_model=UserOut)
# Define function read_current_user
def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut:
"""Return the profile of the currently authenticated user."""
# Return current_user
return current_user
# Apply the @router.post decorator
@router.post("/change-password")
# Define function change_password
def change_password(
# Entry: body
body: PasswordChange,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""Change the current user's password."""
# Call auth_change_password()
auth_change_password(
db,
current_user,
# Keyword argument: current_password
current_password=body.current_password,
# Keyword argument: new_password
new_password=body.new_password,
)
# Open context manager
with UnitOfWork(db) as uow:
# Call uow.commit()
uow.commit()
# Return {"detail": "Password changed successfully"}
return {"detail": "Password changed successfully"}