Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- Add must_change_password field to User model with migration b023 - Add POST /auth/change-password endpoint with password policy validation - Add require_password_changed dependency to block requests until password is changed - Add ChangePasswordModal with live password policy checklist (forced on first login) - Show password policy in CreateUserModal and EditUserModal - Fix backend permissions: tests, campaigns, templates, reports, evidence, worklogs - red_tech/blue_tech: execute only, cannot create tests/campaigns/templates - red_lead/blue_lead: create/edit tests/campaigns/templates, generate reports, no system access - viewer: read-only everywhere, can generate reports - Fix frontend role checks across TestDetailPage, TestDetailHeader, TeamTabs, TestsPage, CampaignsPage, CampaignDetailPage, Sidebar
171 lines
5.5 KiB
Python
171 lines
5.5 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, HTTPException, Request, Response, status
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
from sqlalchemy.orm import Session
|
|
|
|
from jose import jwt, JWTError
|
|
|
|
from app.auth import verify_password, hash_password, create_access_token, blacklist_token
|
|
from app.config import settings
|
|
from app.database import get_db
|
|
from app.dependencies.auth import get_current_user
|
|
from app.models.user import User
|
|
from app.schemas.auth import TokenResponse, UserOut
|
|
from app.schemas.user import PasswordChange
|
|
|
|
# Rate limiter instance (shares backend state via app.state.limiter)
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
# Detect whether we're behind HTTPS (production) so the cookie can be Secure
|
|
_IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
|
|
|
# Cookie name used to transport the JWT
|
|
_COOKIE_NAME = "aegis_token"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /auth/login
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@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),
|
|
):
|
|
"""Authenticate a user and return a JWT access token.
|
|
|
|
Rate-limited to **5 attempts per minute per IP** to prevent brute-force
|
|
attacks. The token is set as an HttpOnly cookie **and** returned in the
|
|
JSON body for API/Swagger compatibility.
|
|
"""
|
|
user = db.query(User).filter(User.username == form_data.username).first()
|
|
|
|
if user is None or not verify_password(form_data.password, user.hashed_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Incorrect username or password",
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Account is disabled. Contact an administrator.",
|
|
)
|
|
|
|
access_token = create_access_token(data={"sub": user.username})
|
|
|
|
# Set HttpOnly cookie — inaccessible from JS
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /auth/logout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/logout")
|
|
def logout(
|
|
request: Request,
|
|
response: Response,
|
|
aegis_token: str | None = Cookie(None),
|
|
):
|
|
"""Clear the authentication cookie and revoke the current token.
|
|
|
|
The token's ``jti`` is added to the Redis blacklist so it cannot
|
|
be reused even if the cookie has already been copied elsewhere.
|
|
The blacklist entry auto-expires when the token's ``exp`` is reached.
|
|
"""
|
|
# Attempt to blacklist the token's jti
|
|
token = aegis_token or request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
|
if token:
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
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 # token already invalid — nothing to revoke
|
|
|
|
response.delete_cookie(
|
|
key=_COOKIE_NAME,
|
|
httponly=True,
|
|
secure=_IS_HTTPS,
|
|
samesite="strict",
|
|
path="/",
|
|
)
|
|
return {"detail": "Logged out"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /auth/me
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/me", response_model=UserOut)
|
|
def read_current_user(current_user: User = Depends(get_current_user)):
|
|
"""Return the profile of the currently authenticated user."""
|
|
return current_user
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /auth/change-password
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/change-password")
|
|
def change_password(
|
|
body: PasswordChange,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Change the current user's password.
|
|
|
|
Requires the current password for verification. On success the
|
|
``must_change_password`` flag is cleared so the user can proceed
|
|
normally.
|
|
"""
|
|
if not verify_password(body.current_password, current_user.hashed_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Current password is incorrect",
|
|
)
|
|
|
|
current_user.hashed_password = hash_password(body.new_password)
|
|
current_user.must_change_password = False
|
|
db.commit()
|
|
|
|
return {"detail": "Password changed successfully"}
|