313 lines
11 KiB
Python
313 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
import uuid
|
|
|
|
from authlib.integrations.starlette_client import OAuth, OAuthError # type: ignore[import-untyped]
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_current_user, get_db
|
|
from app.core.config import get_settings
|
|
from app.core.ratelimit import FailedAttemptLimiter
|
|
from app.core.security import (
|
|
SessionPayload,
|
|
create_session_token,
|
|
hash_password,
|
|
read_session_token,
|
|
verify_password,
|
|
)
|
|
from app.models.user import User
|
|
from app.schemas import CurrentUser, OidcStatusOut, PasswordLoginRequest
|
|
from app.services.audit import record_audit_event
|
|
from app.services.sessions import revoke_session
|
|
|
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
|
settings = get_settings()
|
|
_login_limiter = (
|
|
FailedAttemptLimiter(
|
|
max_failures=settings.login_max_failures,
|
|
window_seconds=settings.login_failure_window_seconds,
|
|
)
|
|
if settings.login_max_failures > 0
|
|
else None
|
|
)
|
|
|
|
|
|
def _client_key(request: Request) -> str:
|
|
# The API sits behind the web container's reverse proxy in every documented
|
|
# deployment. The proxy appends/overwrites the socket peer as the final hop, so an
|
|
# attacker-controlled leading value must never select a fresh limiter bucket.
|
|
forwarded = request.headers.get("x-forwarded-for", "")
|
|
if forwarded:
|
|
return forwarded.split(",")[-1].strip()
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
oauth = OAuth()
|
|
if settings.oidc_enabled and settings.oidc_issuer_url:
|
|
oauth.register(
|
|
name="oidc",
|
|
client_id=settings.oidc_client_id,
|
|
client_secret=settings.oidc_client_secret,
|
|
server_metadata_url=f"{settings.oidc_issuer_url.rstrip('/')}/.well-known/openid-configuration",
|
|
client_kwargs={"scope": "openid email profile"},
|
|
)
|
|
|
|
|
|
def _current_user_out(user: User) -> CurrentUser:
|
|
return CurrentUser(
|
|
public_ref=user.public_ref,
|
|
display_name=user.display_name,
|
|
role=user.role, # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
def _set_session(response: Response, user: User) -> None:
|
|
token = create_session_token(
|
|
SessionPayload(
|
|
user_id=str(user.id),
|
|
public_ref=user.public_ref,
|
|
role=user.role,
|
|
display_name=user.display_name,
|
|
issued_at=int(time.time()),
|
|
session_id=str(uuid.uuid4()),
|
|
)
|
|
)
|
|
response.set_cookie(
|
|
settings.session_cookie_name,
|
|
token,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=settings.session_cookie_secure,
|
|
max_age=settings.session_ttl_seconds,
|
|
)
|
|
|
|
|
|
def bootstrap_initial_admin(db: Session) -> None:
|
|
"""Create or rotate the explicitly configured first manager in operational mode."""
|
|
if (
|
|
settings.mobilityops_demo_mode
|
|
or not settings.initial_admin_email
|
|
or not settings.initial_admin_password
|
|
):
|
|
return
|
|
email = settings.initial_admin_email.strip().lower()
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if user is None:
|
|
user = User(
|
|
public_ref="USR-ADMIN",
|
|
email=email,
|
|
password_hash=hash_password(settings.initial_admin_password),
|
|
display_name=settings.initial_admin_display_name,
|
|
role="operations_manager",
|
|
active=True,
|
|
)
|
|
db.add(user)
|
|
record_audit_event(
|
|
db,
|
|
actor_type="system",
|
|
actor_label="bootstrap",
|
|
action="operational_admin_created",
|
|
entity_type="user",
|
|
entity_id=user.id,
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _oidc_configured() -> bool:
|
|
return bool(
|
|
settings.oidc_enabled
|
|
and settings.oidc_issuer_url
|
|
and settings.oidc_client_id
|
|
and settings.oidc_client_secret
|
|
)
|
|
|
|
|
|
def _oidc_redirect_uri() -> str:
|
|
return settings.oidc_redirect_uri or (
|
|
f"{settings.mobilityops_public_url.rstrip('/')}/api/v1/auth/oidc/callback"
|
|
)
|
|
|
|
|
|
def _allowed_oidc_email(email: str) -> bool:
|
|
domains = {
|
|
value.strip().casefold()
|
|
for value in settings.oidc_allowed_email_domains.split(",")
|
|
if value.strip()
|
|
}
|
|
return not domains or email.rsplit("@", 1)[-1].casefold() in domains
|
|
|
|
|
|
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
|
|
subject = str(claims.get("sub") or "").strip()
|
|
email = str(claims.get("email") or "").strip().lower()
|
|
if not subject or not email or claims.get("email_verified") is not True:
|
|
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
|
|
if not _allowed_oidc_email(email):
|
|
raise HTTPException(status_code=403, detail="Email domain is not allowed")
|
|
|
|
provider = settings.oidc_issuer_url.rstrip("/")
|
|
user = db.scalar(
|
|
select(User).where(
|
|
User.identity_provider == provider,
|
|
User.external_subject == subject,
|
|
)
|
|
)
|
|
if user is None:
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if user is not None and user.external_subject not in (None, subject):
|
|
raise HTTPException(status_code=409, detail="Email is linked to another identity")
|
|
if user is not None and claims.get("email_verified") is not True:
|
|
# Linking an existing local account (possibly the bootstrap admin) purely on an
|
|
# email match requires the IdP to explicitly assert the address is verified;
|
|
# an absent claim is treated as unverified.
|
|
raise HTTPException(status_code=401, detail="Verified OIDC email is required")
|
|
created = user is None
|
|
if created:
|
|
if not settings.oidc_auto_provision:
|
|
raise HTTPException(status_code=403, detail="OIDC user is not provisioned")
|
|
role = settings.oidc_default_role
|
|
if role not in {"operations_manager", "rental_employee"}:
|
|
role = "rental_employee"
|
|
user = User(
|
|
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
|
|
email=email,
|
|
password_hash=None,
|
|
display_name=str(claims.get("name") or email),
|
|
role=role,
|
|
active=True,
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
assert user is not None
|
|
if not user.active:
|
|
raise HTTPException(status_code=403, detail="User is inactive")
|
|
user.identity_provider = provider
|
|
user.external_subject = subject
|
|
record_audit_event(
|
|
db,
|
|
actor_type="system" if created else "user",
|
|
actor_id=None if created else user.id,
|
|
actor_label=settings.oidc_provider_name,
|
|
action="oidc_user_provisioned" if created else "oidc_identity_linked",
|
|
entity_type="user",
|
|
entity_id=user.id,
|
|
metadata={"provider": provider},
|
|
)
|
|
return user
|
|
|
|
|
|
@router.get("/oidc/status", response_model=OidcStatusOut)
|
|
def oidc_status() -> OidcStatusOut:
|
|
return OidcStatusOut(
|
|
enabled=_oidc_configured(),
|
|
provider_name=settings.oidc_provider_name if _oidc_configured() else None,
|
|
)
|
|
|
|
|
|
@router.get("/oidc/login")
|
|
async def oidc_login(request: Request) -> Response:
|
|
if not _oidc_configured():
|
|
raise HTTPException(status_code=404, detail="OIDC login is not configured")
|
|
client = oauth.create_client("oidc")
|
|
if client is None:
|
|
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
|
|
return await client.authorize_redirect(request, _oidc_redirect_uri())
|
|
|
|
|
|
@router.get("/oidc/callback")
|
|
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Response:
|
|
if not _oidc_configured():
|
|
raise HTTPException(status_code=404, detail="OIDC login is not configured")
|
|
client = oauth.create_client("oidc")
|
|
if client is None:
|
|
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
|
|
try:
|
|
token = await client.authorize_access_token(request)
|
|
except OAuthError as exc:
|
|
raise HTTPException(status_code=401, detail="OIDC authentication failed") from exc
|
|
user = _resolve_oidc_user(db, dict(token.get("userinfo") or {}))
|
|
response = RedirectResponse(f"{settings.mobilityops_public_url.rstrip('/')}/dashboard")
|
|
_set_session(response, user)
|
|
record_audit_event(
|
|
db,
|
|
actor_type="user",
|
|
actor_id=user.id,
|
|
actor_label=user.display_name,
|
|
action="oidc_login",
|
|
entity_type="user",
|
|
entity_id=user.id,
|
|
metadata={"provider": settings.oidc_issuer_url.rstrip("/")},
|
|
)
|
|
db.commit()
|
|
return response
|
|
|
|
|
|
@router.post("/login", response_model=CurrentUser)
|
|
def password_login(
|
|
body: PasswordLoginRequest,
|
|
request: Request,
|
|
response: Response,
|
|
db: Session = Depends(get_db),
|
|
) -> CurrentUser:
|
|
if settings.mobilityops_demo_mode:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Password login is unavailable in demo mode",
|
|
)
|
|
limiter_key = _client_key(request)
|
|
retry_after = _login_limiter.retry_after_seconds(limiter_key) if _login_limiter else 0
|
|
if retry_after:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="Too many failed login attempts. Try again later.",
|
|
headers={"Retry-After": str(retry_after)},
|
|
)
|
|
user = db.scalar(select(User).where(User.email == body.email.strip().lower()))
|
|
if user is None or not user.active or not verify_password(body.password, user.password_hash):
|
|
if _login_limiter:
|
|
_login_limiter.record_failure(limiter_key)
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
if _login_limiter:
|
|
_login_limiter.reset(limiter_key)
|
|
_set_session(response, user)
|
|
record_audit_event(
|
|
db,
|
|
actor_type="user",
|
|
actor_id=user.id,
|
|
actor_label=user.display_name,
|
|
action="password_login",
|
|
entity_type="user",
|
|
entity_id=user.id,
|
|
)
|
|
db.commit()
|
|
return _current_user_out(user)
|
|
|
|
|
|
@router.get("/session", response_model=CurrentUser)
|
|
def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return user
|
|
|
|
|
|
@router.post("/logout")
|
|
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
|
token = request.cookies.get(settings.session_cookie_name)
|
|
payload = read_session_token(token) if token else None
|
|
if payload is not None and token is not None:
|
|
revoke_session(db, token, payload)
|
|
record_audit_event(
|
|
db,
|
|
actor_type="user",
|
|
actor_id=uuid.UUID(payload.user_id),
|
|
actor_label=payload.display_name,
|
|
action="logout",
|
|
entity_type="user",
|
|
)
|
|
db.commit()
|
|
response.delete_cookie(settings.session_cookie_name)
|
|
return {"status": "logged_out"}
|