stupa-pdf-api/backend/src/services/auth_oidc.py
Frederik Beimgraben ad697e5f54 feat: Complete redesign with OIDC auth, PDF upload, and enhanced workflow
BREAKING CHANGE: Major architecture overhaul removing LaTeX compilation

- Removed embedded LaTeX compilation
- Added OIDC/OAuth2 authentication with Nextcloud integration
- Added email authentication with magic links
- Implemented role-based access control (RBAC)
- Added PDF template upload and field mapping
- Implemented visual form designer capability
- Created multi-stage approval workflow
- Added voting mechanism for AStA members
- Enhanced user dashboard with application tracking
- Added comprehensive audit trail and history
- Improved security with JWT tokens and encryption

New Features:
- OIDC single sign-on with automatic role mapping
- Dual authentication (OIDC + Email)
- Upload fillable PDFs as templates
- Graphical field mapping interface
- Configurable workflow with reviews and voting
- Admin panel for role and permission management
- Email notifications for status updates
- Docker compose setup with Redis and MailHog

Migration Required:
- Database schema updates via Alembic
- Configuration of OIDC provider
- Upload of PDF templates to replace LaTeX
- Role mapping configuration
2025-09-17 00:42:57 +02:00

455 lines
16 KiB
Python

"""
OIDC/OAuth2 Authentication Service
This module provides OIDC/OAuth2 authentication with Nextcloud integration.
"""
import secrets
import hashlib
import jwt
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List, Tuple
from urllib.parse import urlencode, quote
import httpx
import logging
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from jose import JWTError, jwt as jose_jwt
from ..models.user import User, Role, AuthProvider, VerificationStatus
from ..models.user import Session as UserSession
from ..config.settings import Settings
from ..repositories.user import UserRepository
from ..repositories.role import RoleRepository
from ..utils.crypto import encrypt_token, decrypt_token
logger = logging.getLogger(__name__)
class OIDCConfig:
"""OIDC configuration for Nextcloud"""
def __init__(self, settings: Settings):
self.settings = settings
self.issuer = settings.oidc.issuer # e.g., "https://nextcloud.example.com"
self.client_id = settings.oidc.client_id
self.client_secret = settings.oidc.client_secret
self.redirect_uri = settings.oidc.redirect_uri
self.scope = settings.oidc.scope or "openid profile email"
# OIDC endpoints (Nextcloud specific)
self.authorization_endpoint = f"{self.issuer}/index.php/apps/oauth2/authorize"
self.token_endpoint = f"{self.issuer}/index.php/apps/oauth2/api/v1/token"
self.userinfo_endpoint = f"{self.issuer}/ocs/v2.php/cloud/user"
self.jwks_uri = f"{self.issuer}/index.php/apps/oauth2/jwks"
# Alternative standard OIDC discovery
self.discovery_endpoint = f"{self.issuer}/.well-known/openid-configuration"
class OIDCAuthService:
"""Service for handling OIDC/OAuth2 authentication"""
def __init__(self, db_session: Session, settings: Settings):
self.db = db_session
self.settings = settings
self.config = OIDCConfig(settings)
self.user_repo = UserRepository(db_session)
self.role_repo = RoleRepository(db_session)
self.http_client = httpx.AsyncClient()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.http_client.aclose()
def generate_state_token(self) -> str:
"""Generate a secure state token for OIDC flow"""
return secrets.token_urlsafe(32)
def generate_nonce(self) -> str:
"""Generate a secure nonce for OIDC flow"""
return secrets.token_urlsafe(32)
def get_authorization_url(self, state: str, nonce: str) -> str:
"""Get the OIDC authorization URL"""
params = {
"client_id": self.config.client_id,
"response_type": "code",
"scope": self.config.scope,
"redirect_uri": self.config.redirect_uri,
"state": state,
"nonce": nonce,
}
return f"{self.config.authorization_endpoint}?{urlencode(params)}"
async def exchange_code_for_tokens(self, code: str) -> Dict[str, Any]:
"""Exchange authorization code for tokens"""
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.config.redirect_uri,
"client_id": self.config.client_id,
"client_secret": self.config.client_secret,
}
try:
response = await self.http_client.post(
self.config.token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"Failed to exchange code for tokens: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code"
)
async def get_userinfo(self, access_token: str) -> Dict[str, Any]:
"""Get user information from OIDC provider"""
headers = {"Authorization": f"Bearer {access_token}"}
try:
# Try standard OIDC userinfo endpoint
response = await self.http_client.get(
self.config.userinfo_endpoint,
headers=headers
)
if response.status_code == 200:
return response.json()
# Fallback to Nextcloud OCS API
ocs_headers = {
**headers,
"OCS-APIRequest": "true",
"Accept": "application/json"
}
response = await self.http_client.get(
f"{self.config.issuer}/ocs/v2.php/cloud/user?format=json",
headers=ocs_headers
)
response.raise_for_status()
ocs_data = response.json()
if "ocs" in ocs_data and "data" in ocs_data["ocs"]:
user_data = ocs_data["ocs"]["data"]
# Map Nextcloud data to OIDC claims
return {
"sub": user_data.get("id"),
"email": user_data.get("email"),
"email_verified": bool(user_data.get("email")),
"name": user_data.get("display-name", user_data.get("displayname")),
"preferred_username": user_data.get("id"),
"groups": user_data.get("groups", []),
"quota": user_data.get("quota"),
"language": user_data.get("language"),
"locale": user_data.get("locale"),
}
return response.json()
except httpx.HTTPError as e:
logger.error(f"Failed to get user info: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to get user information"
)
async def authenticate_user(
self,
code: str,
state: str,
stored_state: str,
nonce: Optional[str] = None
) -> Tuple[User, Dict[str, Any]]:
"""Authenticate user with OIDC"""
# Verify state
if state != stored_state:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid state parameter"
)
# Exchange code for tokens
token_response = await self.exchange_code_for_tokens(code)
access_token = token_response.get("access_token")
refresh_token = token_response.get("refresh_token")
id_token = token_response.get("id_token")
expires_in = token_response.get("expires_in", 3600)
# Get user info
userinfo = await self.get_userinfo(access_token)
# Validate nonce if ID token is present
if id_token and nonce:
try:
# Decode without verification first to get claims
# In production, should verify against JWKS
claims = jose_jwt.decode(id_token, options={"verify_signature": False})
if claims.get("nonce") != nonce:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid nonce"
)
except JWTError:
logger.warning("Failed to decode ID token")
# Create or update user
user = await self.create_or_update_user(userinfo, access_token, refresh_token, expires_in)
# Create session
session_token = self.create_session_token(user)
return user, {
"access_token": session_token,
"token_type": "Bearer",
"expires_in": self.settings.security.access_token_expire_minutes * 60,
"user": user.to_dict()
}
async def create_or_update_user(
self,
userinfo: Dict[str, Any],
access_token: str,
refresh_token: Optional[str],
expires_in: int
) -> User:
"""Create or update user from OIDC claims"""
sub = userinfo.get("sub")
email = userinfo.get("email")
if not sub or not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing required user information"
)
# Try to find existing user
user = self.user_repo.get_by_oidc_sub(sub, self.config.issuer)
if not user:
# Try to find by email
user = self.user_repo.get_by_email(email)
if user and user.auth_provider != AuthProvider.OIDC:
# User exists with different auth provider
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already exists with different authentication method"
)
# Parse name fields
name = userinfo.get("name", "")
given_name = userinfo.get("given_name", "")
family_name = userinfo.get("family_name", "")
if name and not (given_name or family_name):
# Try to split name
name_parts = name.split(" ", 1)
given_name = name_parts[0]
family_name = name_parts[1] if len(name_parts) > 1 else ""
# Calculate token expiration
token_expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
if user:
# Update existing user
user.oidc_sub = sub
user.oidc_issuer = self.config.issuer
user.given_name = given_name or user.given_name
user.family_name = family_name or user.family_name
user.preferred_username = userinfo.get("preferred_username", user.preferred_username)
user.display_name = name or user.display_name
user.picture_url = userinfo.get("picture", user.picture_url)
user.email_verified = userinfo.get("email_verified", False)
user.oidc_access_token = encrypt_token(access_token, self.settings.security.encryption_key)
user.oidc_refresh_token = encrypt_token(refresh_token, self.settings.security.encryption_key) if refresh_token else None
user.oidc_token_expires_at = token_expires_at
user.oidc_claims = userinfo
user.last_login_at = datetime.utcnow()
if user.email_verified:
user.verification_status = VerificationStatus.OIDC_VERIFIED
else:
# Create new user
user = User(
email=email,
auth_provider=AuthProvider.OIDC,
oidc_sub=sub,
oidc_issuer=self.config.issuer,
given_name=given_name,
family_name=family_name,
preferred_username=userinfo.get("preferred_username"),
display_name=name,
picture_url=userinfo.get("picture"),
email_verified=userinfo.get("email_verified", False),
verification_status=VerificationStatus.OIDC_VERIFIED if userinfo.get("email_verified") else VerificationStatus.UNVERIFIED,
oidc_access_token=encrypt_token(access_token, self.settings.security.encryption_key),
oidc_refresh_token=encrypt_token(refresh_token, self.settings.security.encryption_key) if refresh_token else None,
oidc_token_expires_at=token_expires_at,
oidc_claims=userinfo,
last_login_at=datetime.utcnow()
)
self.db.add(user)
# Map OIDC roles to application roles
await self.sync_user_roles(user, userinfo.get("groups", []))
self.db.commit()
self.db.refresh(user)
return user
async def sync_user_roles(self, user: User, oidc_groups: List[str]):
"""Sync user roles based on OIDC groups"""
# Get role mappings
role_mappings = self.role_repo.get_oidc_role_mappings()
# Clear existing non-system roles
user.roles = [role for role in user.roles if role.is_system]
# Add roles based on OIDC groups
for group in oidc_groups:
if group in role_mappings:
role = role_mappings[group]
if role not in user.roles:
user.roles.append(role)
# Special handling for admin groups
admin_groups = self.settings.oidc.admin_groups or []
if any(group in admin_groups for group in oidc_groups):
admin_role = self.role_repo.get_by_name("admin")
if admin_role and admin_role not in user.roles:
user.roles.append(admin_role)
# Special handling for specific roles
role_group_mapping = {
"haushaltsbeauftragte": self.settings.oidc.budget_reviewer_groups or [],
"finanzreferent": self.settings.oidc.finance_reviewer_groups or [],
"asta": self.settings.oidc.asta_groups or [],
}
for role_name, groups in role_group_mapping.items():
if any(group in groups for group in oidc_groups):
role = self.role_repo.get_by_name(role_name)
if role and role not in user.roles:
user.roles.append(role)
# Ensure every verified user has at least the user role
if user.verification_status != VerificationStatus.UNVERIFIED:
user_role = self.role_repo.get_by_name("user")
if user_role and user_role not in user.roles:
user.roles.append(user_role)
def create_session_token(self, user: User) -> str:
"""Create JWT session token"""
expire = datetime.utcnow() + timedelta(minutes=self.settings.security.access_token_expire_minutes)
payload = {
"sub": str(user.id),
"email": user.email,
"roles": [role.name for role in user.roles],
"exp": expire,
"iat": datetime.utcnow(),
"type": "access"
}
return jose_jwt.encode(
payload,
self.settings.security.jwt_secret_key,
algorithm=self.settings.security.jwt_algorithm
)
def create_refresh_token(self, user: User) -> str:
"""Create JWT refresh token"""
expire = datetime.utcnow() + timedelta(days=self.settings.security.refresh_token_expire_days)
payload = {
"sub": str(user.id),
"exp": expire,
"iat": datetime.utcnow(),
"type": "refresh"
}
return jose_jwt.encode(
payload,
self.settings.security.jwt_secret_key,
algorithm=self.settings.security.jwt_algorithm
)
async def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
"""Refresh access token using refresh token"""
try:
payload = jose_jwt.decode(
refresh_token,
self.settings.security.jwt_secret_key,
algorithms=[self.settings.security.jwt_algorithm]
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
user_id = payload.get("sub")
user = self.user_repo.get_by_id(int(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Create new access token
access_token = self.create_session_token(user)
return {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": self.settings.security.access_token_expire_minutes * 60
}
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
async def logout(self, user: User, session_token: Optional[str] = None):
"""Logout user and invalidate session"""
if session_token:
# Invalidate specific session
session = self.db.query(UserSession).filter(
UserSession.session_token == session_token,
UserSession.user_id == user.id
).first()
if session:
self.db.delete(session)
else:
# Invalidate all user sessions
self.db.query(UserSession).filter(
UserSession.user_id == user.id
).delete()
self.db.commit()