Initial commit: core-api service extraction from portainer-core
Build and Push / build (release) Successful in 43s
Build and Push / build (release) Successful in 43s
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Authentication module for core-api
|
||||
|
||||
Provides OIDC/OAuth2 authentication via Authentik
|
||||
"""
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
OIDC Authentication Module
|
||||
|
||||
Provides OAuth2/OIDC token validation for FastAPI using Authentik as IdP.
|
||||
Implements bearer token authentication with JWT verification.
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, Security, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
import httpx
|
||||
from functools import lru_cache
|
||||
from typing import Dict, Optional
|
||||
from src.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
class OIDCConfig:
|
||||
"""OIDC configuration from environment"""
|
||||
|
||||
def __init__(self):
|
||||
# These will be set from environment variables in config.py
|
||||
self.enabled = False
|
||||
self.issuer = ""
|
||||
self.audience = ""
|
||||
self.jwks_uri = ""
|
||||
|
||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
||||
"""Configure OIDC settings"""
|
||||
self.enabled = enabled
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
||||
|
||||
|
||||
# Global OIDC config instance
|
||||
oidc_config = OIDCConfig()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_jwks() -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) from Authentik
|
||||
|
||||
Cached to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
Returns:
|
||||
JWKS dictionary containing public keys for token verification
|
||||
|
||||
Raises:
|
||||
HTTPException: If JWKS fetch fails
|
||||
"""
|
||||
if not oidc_config.enabled:
|
||||
return {}
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
||||
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
jwks = response.json()
|
||||
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
||||
return jwks
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch JWKS: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authentication service unavailable"
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Validate OIDC token from Authorization: Bearer header
|
||||
|
||||
Extracts and validates JWT token from request header. Verifies:
|
||||
- Token signature using JWKS
|
||||
- Token expiration
|
||||
- Issuer matches Authentik
|
||||
- Audience matches core-api
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer token from Authorization header
|
||||
|
||||
Returns:
|
||||
User claims dictionary containing email, name, groups, etc.
|
||||
Returns None if OIDC is disabled (allows unauthenticated access)
|
||||
|
||||
Raises:
|
||||
HTTPException 401: If token is invalid, expired, or missing when OIDC enabled
|
||||
"""
|
||||
# If OIDC is disabled, allow all requests (no authentication)
|
||||
if not oidc_config.enabled:
|
||||
logger.debug("OIDC disabled - allowing unauthenticated access")
|
||||
return None
|
||||
|
||||
# OIDC enabled - token required
|
||||
if not credentials:
|
||||
logger.warning("Authentication required but no token provided")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
# Decode token header to get key ID
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
|
||||
if not kid:
|
||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||
|
||||
# Find matching key in JWKS
|
||||
jwks = get_jwks()
|
||||
rsa_key = None
|
||||
|
||||
for key in jwks.get("keys", []):
|
||||
if key.get("kid") == kid:
|
||||
rsa_key = key
|
||||
break
|
||||
|
||||
if not rsa_key:
|
||||
logger.warning(f"No matching key found for kid: {kid}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||
|
||||
# Verify and decode token
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
rsa_key,
|
||||
algorithms=["RS256"],
|
||||
audience=oidc_config.audience,
|
||||
issuer=oidc_config.issuer,
|
||||
)
|
||||
|
||||
user_email = payload.get("email", "unknown")
|
||||
logger.info(f"Authenticated user: {user_email}")
|
||||
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Token expired")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Token expired",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except jwt.JWTClaimsError as e:
|
||||
logger.warning(f"Invalid token claims: {e}")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid token claims",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except JWTError as e:
|
||||
logger.error(f"JWT validation error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authentication token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected authentication error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Authentication error",
|
||||
)
|
||||
|
||||
|
||||
async def get_admin_user(
|
||||
user: Optional[Dict] = Depends(get_current_user)
|
||||
) -> Dict:
|
||||
"""
|
||||
Require admin group membership
|
||||
|
||||
Use this dependency for endpoints that require admin access.
|
||||
Checks if user is member of 'admin' group in Authentik.
|
||||
|
||||
Args:
|
||||
user: User claims from get_current_user
|
||||
|
||||
Returns:
|
||||
User claims dictionary if user is admin
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not in admin group
|
||||
HTTPException 401: If OIDC enabled but user not authenticated
|
||||
"""
|
||||
# If OIDC disabled, allow all (backward compatibility)
|
||||
if not oidc_config.enabled or user is None:
|
||||
logger.debug("OIDC disabled - allowing admin access")
|
||||
return {"email": "unauthenticated", "groups": ["admin"]}
|
||||
|
||||
# Check admin group membership
|
||||
groups = user.get("groups", [])
|
||||
|
||||
if "admin" not in groups and "authentik Admins" not in groups:
|
||||
user_email = user.get("email", "unknown")
|
||||
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin access required"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Optional authentication - allows both authenticated and unauthenticated access
|
||||
|
||||
Use for endpoints that should be accessible to everyone but can provide
|
||||
enhanced functionality for authenticated users.
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer token from Authorization header
|
||||
|
||||
Returns:
|
||||
User claims if valid token provided, None otherwise
|
||||
"""
|
||||
if not credentials or not oidc_config.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user(credentials)
|
||||
except HTTPException:
|
||||
# Invalid token - return None instead of raising
|
||||
return None
|
||||
|
||||
|
||||
async def get_forward_auth_user(
|
||||
request: Request
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Authentik Forward Auth authentication for external access via NPM
|
||||
|
||||
This dependency allows:
|
||||
- External access through api.schweitz.net (with Authentik forward auth headers) - REQUIRES authentication
|
||||
- Internal direct access (no forward auth headers) - ALLOWED without authentication
|
||||
|
||||
When accessing through NPM with Authentik forward auth enabled, NPM adds headers like:
|
||||
- X-authentik-username
|
||||
- X-authentik-email
|
||||
- X-authentik-groups
|
||||
- X-authentik-name
|
||||
- X-authentik-uid
|
||||
|
||||
Args:
|
||||
request: FastAPI request object containing headers
|
||||
|
||||
Returns:
|
||||
User info dict if authenticated via forward auth headers
|
||||
None if accessed internally (no forward auth headers)
|
||||
|
||||
Raises:
|
||||
HTTPException 401: If forward auth headers present but invalid/incomplete
|
||||
"""
|
||||
# Check for Authentik forward auth headers
|
||||
username = request.headers.get("x-authentik-username")
|
||||
email = request.headers.get("x-authentik-email")
|
||||
groups = request.headers.get("x-authentik-groups")
|
||||
name = request.headers.get("x-authentik-name")
|
||||
uid = request.headers.get("x-authentik-uid")
|
||||
|
||||
# If NO forward auth headers present, this is internal access - allow it
|
||||
if not username and not email:
|
||||
logger.debug("No forward auth headers - allowing internal access")
|
||||
return None
|
||||
|
||||
# Forward auth headers present (external access via api.schweitz.net)
|
||||
# Validate authentication
|
||||
if not username or not email:
|
||||
logger.warning("Incomplete forward auth headers detected")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required - incomplete forward auth headers"
|
||||
)
|
||||
|
||||
# Parse groups (comma-separated string to list)
|
||||
groups_list = [g.strip() for g in groups.split(",")] if groups else []
|
||||
|
||||
user_info = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"name": name or username,
|
||||
"groups": groups_list,
|
||||
"uid": uid,
|
||||
"auth_method": "forward_auth"
|
||||
}
|
||||
|
||||
logger.info(f"Authenticated via forward auth: {email} (groups: {groups_list})")
|
||||
return user_info
|
||||
|
||||
|
||||
async def get_forward_auth_admin(
|
||||
user: Optional[Dict] = Depends(get_forward_auth_user)
|
||||
) -> Dict:
|
||||
"""
|
||||
Require admin access for external requests, allow all internal requests
|
||||
|
||||
Use this dependency for endpoints that require admin access when accessed
|
||||
externally through api.schweitz.net, but allow unrestricted internal access.
|
||||
|
||||
Args:
|
||||
user: User info from get_forward_auth_user
|
||||
|
||||
Returns:
|
||||
User info dict if user is admin or if accessed internally
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If external user is not in admin/authentik Admins group
|
||||
"""
|
||||
# Internal access (no forward auth headers) - allow all
|
||||
if user is None:
|
||||
logger.debug("Internal access - allowing without admin check")
|
||||
return {"email": "internal", "groups": ["admin"], "auth_method": "internal"}
|
||||
|
||||
# External access - check admin group membership
|
||||
groups = user.get("groups", [])
|
||||
|
||||
if "admin" not in groups and "authentik Admins" not in groups:
|
||||
user_email = user.get("email", "unknown")
|
||||
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin access required"
|
||||
)
|
||||
|
||||
return user
|
||||
Reference in New Issue
Block a user