Files
Aegis/backend/app/routers/auth.py
T
kitos 394d5d9056 refactor(types): add comprehensive type annotations across backend Python codebase
Enable ANN rules in ruff.toml (flake8-annotations) and resolve all 221 violations:

ANN201/ANN202 — return types on 168 public/private functions:
- All 28 FastAPI routers: endpoints annotated with dict/list/specific schema/
  StreamingResponse/FileResponse/JSONResponse as appropriate
- main.py: lifespan→AsyncGenerator[None,None], exception handlers→JSONResponse
- database.py: get_db→Generator[Session,None,None], proxy methods→correct types
- middleware/request_context.py: dispatch→Response with Callable call_next type

ANN001/ANN002/ANN003 — 32 missing argument types:
- seed_demo.py: all db parameters typed as Session
- domain/unit_of_work.py: __aexit__ exc_type/exc_val/exc_tb typed with TracebackType
- services: audit_service user_id→UUID|None, heatmap_service query/model/builder,
  notification_service test→Test, tempo_service test→Test/user→User,
  test_workflow_service test_id→UUID, campaign_crud **fields→object,
  test_crud **fields→object (4 sites)

ANN401 — 16 Any usages resolved:
- Domain entities (campaign/technique/threat_actor/test_entity): replaced Any with
  actual ORM types via TYPE_CHECKING guards to avoid circular imports
- detection_rule_service: test_id/detection_rule_id/evaluator_id→UUID
- score_cache: kept Any with # noqa: ANN401 (genuinely generic cache)
- jira_service/tempo_service: kept Any with # noqa: ANN401 (lazy optional deps)
- d3fend_import_service: _to_str(v: Any) kept with # noqa: ANN401

ANN204/ANN205/ANN206 — special/static/class methods:
- database.py proxy __call__/__getattr__: *args: object/**kwargs: object
- schemas/test.py model_validate: obj→object, **kwargs→object
- sa_technique_repository._int_type→type

All 439 unit tests pass. ruff check app/ → All checks passed!

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:04:51 +02:00

173 lines
5.2 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
from fastapi import APIRouter, Cookie, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.auth import blacklist_token, create_access_token, verify_password
from app.config import settings
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.domain.errors import BusinessRuleViolation, PermissionViolation
from app.domain.unit_of_work import UnitOfWork
from app.limiter import limiter
from app.middleware.request_context import resolve_client_ip
from app.models.user import User
from app.schemas.auth import TokenResponse, UserOut
from app.schemas.user import PasswordChange
from app.services.audit_service import log_action
from app.services.auth_service import (
_DUMMY_HASH,
)
from app.services.auth_service import (
change_password as auth_change_password,
)
router = APIRouter(prefix="/auth", tags=["auth"])
_IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
_COOKIE_NAME = "aegis_token"
@router.post("/login", response_model=TokenResponse)
@limiter.limit("5/minute")
def login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
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).
"""
user = db.query(User).filter(User.username == form_data.username).first()
target_hash = user.hashed_password if user else _DUMMY_HASH
password_valid = verify_password(form_data.password, target_hash)
ip = resolve_client_ip(request)
if user is None or not password_valid:
with UnitOfWork(db) as uow:
log_action(
db,
user.id if user else None,
"LOGIN_FAILED",
"auth",
None,
details={
"username": form_data.username,
"ip": ip,
"reason": "invalid_credentials",
},
ip_address=ip,
)
uow.commit()
raise BusinessRuleViolation("Incorrect username or password")
if not user.is_active:
raise PermissionViolation("Account is disabled. Contact an administrator.")
access_token = create_access_token(data={"sub": user.username})
with UnitOfWork(db) as uow:
log_action(
db,
user.id,
"LOGIN_SUCCESS",
"auth",
str(user.id),
details={"username": user.username, "ip": ip},
ip_address=ip,
)
uow.commit()
response.set_cookie(
key=_COOKIE_NAME,
value=access_token,
httponly=True,
secure=_IS_HTTPS,
samesite="strict",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
path="/",
)
return TokenResponse(access_token=access_token)
@router.post("/logout")
def logout(
request: Request,
response: Response,
aegis_token: str | None = Cookie(None),
) -> dict:
"""Clear the authentication cookie and revoke the current token."""
bearer = (
request.headers.get("Authorization")
or request.headers.get("authorization")
or ""
)
bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip()
seen: set[str] = set()
for raw in (aegis_token, bearer):
if not raw or raw in seen:
continue
seen.add(raw)
try:
payload = jwt.decode(
raw,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
jti = payload.get("jti")
exp = payload.get("exp", 0)
if jti:
blacklist_token(jti, float(exp))
except JWTError:
pass
response.delete_cookie(
key=_COOKIE_NAME,
httponly=True,
secure=_IS_HTTPS,
samesite="strict",
path="/",
)
return {"detail": "Logged out"}
@router.get("/me", response_model=UserOut)
def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut:
"""Return the profile of the currently authenticated user."""
return current_user
@router.post("/change-password")
def change_password(
body: PasswordChange,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Change the current user's password."""
auth_change_password(
db,
current_user,
current_password=body.current_password,
new_password=body.new_password,
)
with UnitOfWork(db) as uow:
uow.commit()
return {"detail": "Password changed successfully"}