M21: add optional organisation identity
This commit is contained in:
@@ -3,7 +3,9 @@ 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
|
||||
|
||||
@@ -17,12 +19,21 @@ from app.core.security import (
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, PasswordLoginRequest
|
||||
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()
|
||||
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:
|
||||
@@ -36,14 +47,21 @@ def _current_user_out(user: User) -> CurrentUser:
|
||||
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()),
|
||||
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,
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.session_cookie_secure,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,6 +96,130 @@ def bootstrap_initial_admin(db: Session) -> None:
|
||||
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 False:
|
||||
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")
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user