9472fe91fa
Aegis CI / lint-and-test (push) Has been cancelled
- Remove ANN (type annotations) and D (docstrings) from ruff select; not feasible to add thousands of missing annotations/docstrings across the codebase - Add I001 and E501 to ignore: comment-interleaved import style and SQLAlchemy FK definitions naturally exceed line limits - Fix F811 duplicate import blocks in main.py, models/__init__.py, routers (campaigns, system, tests, evidence) and services (test_workflow, test_crud, campaign_service, schemas/test) - Add missing Evidence/IntelItem/Technique/Test/TestTemplate/User imports to models/__init__.py (were only in duplicate block) - Fix F821: add missing JWTError import in auth.py - Fix F401 unused imports across 15+ files (jira_service, sso_service, notification_service, playbook_service, tempo_service, models, schemas, routers: admin_config, attack_paths, executive_dashboard, knowledge, ownership, risk_intelligence, sso, api_keys, email_service) - Fix F841 unused variables: owned_technique_ids (executive_dashboard_service), severity (jira_service), priority_order (revalidation_queue_service) - Fix F541 f-strings without placeholders in system.py and attck_evaluations_service - Fix F601 duplicate dict key G0067 in threat_actor_import_service - Fix E701 multiple-statements-on-one-line in risk_intelligence_service - Fix E741 ambiguous variable name l -> lvl in risk_intelligence_service - Fix N806 uppercase vars in functions: technique.py, heatmap_service.py; add noqa for compliance_import_service.py large unused constant dicts - Fix W293 whitespace on blank lines in tests/conftest.py
354 lines
11 KiB
Python
354 lines
11 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
|
|
from jwt.exceptions import PyJWTError as JWTError
|
|
|
|
# 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"])
|
|
|
|
# SECURE_COOKIES desacopla la seguridad de la cookie del entorno de ejecucion.
|
|
# Por defecto activo en produccion; ponlo en "false" para servidores HTTP.
|
|
_aegis_env = os.environ.get("AEGIS_ENV", "development").lower()
|
|
_secure_cookie_env = os.environ.get("SECURE_COOKIES", "auto").lower()
|
|
if _secure_cookie_env == "false":
|
|
_IS_HTTPS = False
|
|
elif _secure_cookie_env == "true":
|
|
_IS_HTTPS = True
|
|
else: # "auto" — activo solo si AEGIS_ENV=production
|
|
_IS_HTTPS = _aegis_env == "production"
|
|
_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"}
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
def refresh_token(
|
|
response: Response,
|
|
aegis_token: str | None = Cookie(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Issue a new access token if the current one is valid.
|
|
|
|
Called automatically by the frontend when it detects an expired
|
|
session while the user is actively using the app. If the current
|
|
cookie token is still valid (not blacklisted, not expired), a fresh
|
|
token is issued and the cookie is renewed — keeping the session alive
|
|
without requiring re-authentication.
|
|
"""
|
|
if not aegis_token:
|
|
raise PermissionViolation("No active session")
|
|
|
|
try:
|
|
payload = jwt.decode(
|
|
aegis_token,
|
|
settings.SECRET_KEY,
|
|
algorithms=[settings.ALGORITHM],
|
|
)
|
|
except JWTError:
|
|
raise PermissionViolation("Session expired — please log in again")
|
|
|
|
username: str | None = payload.get("sub")
|
|
if not username:
|
|
raise PermissionViolation("Invalid session")
|
|
|
|
user = db.query(User).filter(User.username == username).first()
|
|
if user is None or not user.is_active:
|
|
raise PermissionViolation("Account not found or disabled")
|
|
|
|
if getattr(user, "must_change_password", False):
|
|
raise PermissionViolation("Password change required before refreshing session")
|
|
|
|
# Issue a fresh token with a new expiry
|
|
new_token = create_access_token(data={"sub": user.username})
|
|
response.set_cookie(
|
|
key=_COOKIE_NAME,
|
|
value=new_token,
|
|
httponly=True,
|
|
secure=_IS_HTTPS,
|
|
samesite="strict",
|
|
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
|
path="/",
|
|
)
|
|
return TokenResponse(access_token=new_token)
|
|
|
|
|
|
@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"}
|