504dfc52f5
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Login is now by email, not username. username still exists internally (JWT sub claim, audit logs, Jira actor attribution, SSO provisioning all still key off it) but is now always kept equal to email everywhere a user is created or their email changes — never a separately-chosen value. - User.email is now unique + NOT NULL (migration b067 backfills any missing/blank email from username first, so existing rows — notably the seeded admin, which historically had none — never violate it). - /auth/login and the (unused but updated for consistency) authenticate_user() now query by email. - create_user (legacy, unreferenced but kept) and create_user_without_password both derive username from email. - update_user keeps username in sync when email changes, and rejects duplicate emails. - seed.py reads ADMIN_EMAIL (new env var, wired through install.sh and docker-compose.prod.yml) for the initial admin; falls back to an email-shaped ADMIN_USERNAME or a placeholder that's flagged for the operator to fix. - admin_config.py's import bundle now matches/creates users by email, skipping (not crashing on) entries with no email. - sso_service.py always sets username = email for SSO-provisioned users. - LoginPage/auth.ts updated to email input/copy (wire field name stays 'username' — that's the OAuth2PasswordRequestForm spec, not the value).
426 lines
14 KiB
Python
426 lines
14 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 datetime, timezone from datetime
|
|
from datetime import datetime, timezone
|
|
|
|
# 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, is_token_blacklisted, 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, SetPasswordRequest, SetPasswordTokenValidateOut
|
|
|
|
# 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,
|
|
)
|
|
|
|
from app.domain.errors import EntityNotFoundError
|
|
from app.services.password_setup_service import (
|
|
consume_token_and_set_password,
|
|
validate_token as validate_password_setup_token,
|
|
)
|
|
|
|
# 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).
|
|
"""
|
|
# OAuth2PasswordRequestForm's field is spec-named "username" but the
|
|
# value a user actually types in is their email — email is the unique
|
|
# login identifier (username is an internal id, always kept == email).
|
|
user = db.query(User).filter(User.email == 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
|
|
"email": 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 email 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={"email": user.email, "ip": ip},
|
|
# Keyword argument: ip_address
|
|
ip_address=ip,
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
|
|
# Auto-discover the user's Jira account ID on every login (non-fatal)
|
|
try:
|
|
from app.services.jira_service import lookup_user_jira_account_id
|
|
if lookup_user_jira_account_id(db, user):
|
|
db.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
# 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"}
|
|
|
|
|
|
# A token that has *just* expired can still be refreshed within this window.
|
|
# Without this grace period, `/auth/refresh` decodes the same token with the
|
|
# same strict expiry check as every other endpoint — so by the time a 401
|
|
# triggers a refresh attempt, the refresh call itself has also expired and
|
|
# the session is unrecoverable. The grace window only widens the refresh
|
|
# check; a brand-new token is always issued with the full expiry.
|
|
_REFRESH_GRACE_SECONDS = 15 * 60
|
|
|
|
|
|
@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 or recently expired.
|
|
|
|
Called automatically by the frontend when it detects an expired
|
|
session while the user is actively using the app. Expiry is checked
|
|
manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on
|
|
PyJWT's built-in check, so a token that expired moments ago can still
|
|
be refreshed. Revoked (blacklisted) tokens are never refreshable.
|
|
"""
|
|
if not aegis_token:
|
|
raise PermissionViolation("No active session")
|
|
|
|
try:
|
|
payload = jwt.decode(
|
|
aegis_token,
|
|
settings.SECRET_KEY,
|
|
algorithms=[settings.ALGORITHM],
|
|
options={"verify_exp": False},
|
|
)
|
|
except JWTError:
|
|
raise PermissionViolation("Session expired — please log in again")
|
|
|
|
jti: str | None = payload.get("jti")
|
|
if jti and is_token_blacklisted(jti):
|
|
raise PermissionViolation("Session expired — please log in again")
|
|
|
|
exp = payload.get("exp")
|
|
now = datetime.now(timezone.utc).timestamp()
|
|
if exp is None or now - exp > _REFRESH_GRACE_SECONDS:
|
|
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"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Set password via a one-time emailed link — public (no auth), rate-limited
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/set-password/validate", response_model=SetPasswordTokenValidateOut)
|
|
@limiter.limit("20/minute")
|
|
def validate_set_password_token(request: Request, token: str, db: Session = Depends(get_db)) -> SetPasswordTokenValidateOut:
|
|
"""Check a set-password token before rendering the form. Public endpoint."""
|
|
try:
|
|
user = validate_password_setup_token(db, token)
|
|
except (EntityNotFoundError, BusinessRuleViolation):
|
|
return SetPasswordTokenValidateOut(valid=False)
|
|
return SetPasswordTokenValidateOut(valid=True, full_name=user.full_name)
|
|
|
|
|
|
@router.post("/set-password")
|
|
@limiter.limit("10/minute")
|
|
def set_password(request: Request, body: SetPasswordRequest, db: Session = Depends(get_db)) -> dict:
|
|
"""Complete a password setup/reset using a one-time token. Public endpoint."""
|
|
with UnitOfWork(db) as uow:
|
|
user = consume_token_and_set_password(db, body.token, body.new_password)
|
|
log_action(
|
|
db,
|
|
user.id,
|
|
"SET_PASSWORD_VIA_TOKEN",
|
|
"auth",
|
|
str(user.id),
|
|
details={},
|
|
)
|
|
uow.commit()
|
|
return {"detail": "Password set successfully — you can now log in"}
|