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
455 lines
16 KiB
Python
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()
|