from collections.abc import Mapping from typing import Any from urllib.parse import urlparse from django.conf import settings from django.contrib.auth import login from django.core.exceptions import ValidationError from django.db import IntegrityError, transaction from django.db.models import Count from django.http import HttpResponseRedirect from ninja import Router from ninja.errors import HttpError from apps.accounts.api import require_permission from apps.accounts.identity_schemas import ( DirectorySyncIn, DirectorySyncRunOut, EnterpriseGroupMappingIn, EnterpriseGroupMappingOut, IdentityProviderCreateIn, IdentityProviderOut, IdentityProviderUpdateIn, PublicIdentityProviderOut, ) from apps.accounts.identity_services import ( IdentitySyncError, run_directory_sync, sync_identity_claims, ) from apps.accounts.models import ( EnterpriseGroupMapping, EnterpriseIdentityProvider, UserGroup, ) from apps.accounts.oidc import ( OIDCConfigurationError, build_oidc_client, callback_uri, frontend_redirect, normalize_return_to, ) from apps.audit.models import OperationAuditLog from apps.modelhub.crypto import ( CredentialEncryptionError, encrypt_secret, mask_secret, ) router = Router(tags=["enterprise-identity"]) SENSITIVE_CONFIG_FRAGMENTS = ("password", "secret", "token", "credential") def _audit(user, action: str, target_type: str, target_id: str, snapshot: dict, result="success"): OperationAuditLog.objects.create( user=user if getattr(user, "is_authenticated", False) else None, module="identity", action=action, target_type=target_type, target_id=target_id, request_snapshot=snapshot, result=result, ) def _validate_configuration(configuration: Mapping[str, Any]) -> None: def visit(value: Any) -> None: if isinstance(value, Mapping): for key, nested in value.items(): lowered = str(key).lower() if any(fragment in lowered for fragment in SENSITIVE_CONFIG_FRAGMENTS): raise HttpError(400, f"敏感配置 {key} 必须通过 credential 字段加密保存") visit(nested) elif isinstance(value, list): for nested in value: visit(nested) visit(configuration) def _validate_provider(provider: EnterpriseIdentityProvider) -> None: if provider.provider_type == EnterpriseIdentityProvider.ProviderType.OIDC: if not provider.metadata_url and not provider.issuer: raise HttpError(400, "OIDC 提供方必须配置 metadata_url 或 issuer") if not provider.client_id: raise HttpError(400, "OIDC 提供方必须配置 client_id") if "openid" not in (provider.scopes or []): raise HttpError(400, "OIDC scopes 必须包含 openid") if provider.enabled and not provider.credential_encrypted: raise HttpError(400, "启用 OIDC 前必须配置 client secret") for url in (provider.issuer, provider.metadata_url): if not url: continue parsed = urlparse(url) local_development = settings.DEBUG and parsed.hostname in {"localhost", "127.0.0.1"} if parsed.scheme != "https" and not local_development: raise HttpError(400, "OIDC 地址必须使用 HTTPS") elif provider.provider_type == EnterpriseIdentityProvider.ProviderType.LDAP: if provider.enabled and not provider.configuration.get("server_uri"): raise HttpError(400, "启用 LDAP 前必须配置 server_uri") else: raise HttpError(400, "不支持的身份提供方类型") def _serialize_provider(provider: EnterpriseIdentityProvider) -> dict: return { "id": provider.id, "slug": provider.slug, "name": provider.name, "provider_type": provider.provider_type, "enabled": provider.enabled, "button_label": provider.button_label or f"使用 {provider.name} 登录", "issuer": provider.issuer, "metadata_url": provider.metadata_url, "client_id": provider.client_id, "credential_set": bool(provider.credential_encrypted), "credential_masked": mask_secret(provider.credential_encrypted), "scopes": provider.scopes, "claim_mappings": provider.claim_mappings, "configuration": provider.configuration, "auto_create_users": provider.auto_create_users, "sync_profile": provider.sync_profile, "sync_groups": provider.sync_groups, "identity_count": getattr(provider, "identity_count", 0), "group_mapping_count": getattr(provider, "group_mapping_count", 0), "created_at": provider.created_at, "updated_at": provider.updated_at, } def _serialize_mapping(mapping: EnterpriseGroupMapping) -> dict: return { "id": mapping.id, "provider_id": mapping.provider_id, "external_group": mapping.external_group, "user_group_id": mapping.user_group_id, "user_group_name": mapping.user_group.name, "user_group_code": mapping.user_group.code, "created_at": mapping.created_at, } @router.get("/auth/providers", response=list[PublicIdentityProviderOut], auth=None) def public_providers(request): return [ { "slug": provider.slug, "name": provider.name, "button_label": provider.button_label or f"使用 {provider.name} 登录", "provider_type": provider.provider_type, } for provider in EnterpriseIdentityProvider.objects.filter( enabled=True, provider_type=EnterpriseIdentityProvider.ProviderType.OIDC, ) ] @router.get("/auth/oidc/{slug}/start", auth=None) def oidc_start(request, slug: str, return_to: str = "/"): provider = EnterpriseIdentityProvider.objects.filter( slug=slug, enabled=True, provider_type=EnterpriseIdentityProvider.ProviderType.OIDC, ).first() if provider is None: raise HttpError(404, "身份提供方不存在或未启用") try: client = build_oidc_client(provider) request.session[f"identity_return_to:{provider.slug}"] = normalize_return_to(return_to) return client.authorize_redirect(request, callback_uri(request, provider)) except (OIDCConfigurationError, CredentialEncryptionError) as error: _audit(None, "oidc_start_failed", "identity_provider", str(provider.id), {}, "failed") raise HttpError(503, str(error)) from error @router.get("/auth/oidc/{slug}/callback", auth=None) def oidc_callback(request, slug: str): provider = EnterpriseIdentityProvider.objects.filter( slug=slug, enabled=True, provider_type=EnterpriseIdentityProvider.ProviderType.OIDC, ).first() if provider is None: return HttpResponseRedirect(frontend_redirect(error="身份提供方不存在或未启用")) try: client = build_oidc_client(provider) token = client.authorize_access_token(request) claims = token.get("userinfo") if not isinstance(claims, Mapping): raise IdentitySyncError("OIDC 响应缺少已校验的用户身份声明") identity, created = sync_identity_claims(provider, claims, login_event=True) login(request, identity.user) return_to = request.session.pop(f"identity_return_to:{provider.slug}", "/") _audit( identity.user, "oidc_login", "external_identity", str(identity.id), {"provider": provider.slug, "identity_created": created}, ) return HttpResponseRedirect(frontend_redirect(return_to)) except Exception as error: _audit( None, "oidc_callback_failed", "identity_provider", str(provider.id), {"error_type": type(error).__name__}, "failed", ) return HttpResponseRedirect(frontend_redirect(error="企业登录失败,请联系管理员")) @router.get("/providers", response=list[IdentityProviderOut]) def list_providers(request): require_permission(request, "system.setting.manage") queryset = EnterpriseIdentityProvider.objects.annotate( identity_count=Count("external_identities", distinct=True), group_mapping_count=Count("group_mappings", distinct=True), ) return [_serialize_provider(provider) for provider in queryset] @router.post("/providers", response=IdentityProviderOut) def create_provider(request, payload: IdentityProviderCreateIn): require_permission(request, "system.setting.manage") values = payload.dict() credential = values.pop("credential", "") _validate_configuration(values["configuration"]) try: with transaction.atomic(): provider = EnterpriseIdentityProvider( **values, credential_encrypted=encrypt_secret(credential) if credential else "", created_by=request.user, updated_by=request.user, ) _validate_provider(provider) provider.full_clean() provider.save() except CredentialEncryptionError as error: raise HttpError(400, str(error)) from error except ValidationError as error: raise HttpError(400, "; ".join(error.messages)) from error except IntegrityError as error: raise HttpError(400, "身份提供方标识已存在") from error _audit( request.user, "create_provider", "identity_provider", str(provider.id), {"slug": provider.slug, "provider_type": provider.provider_type}, ) provider.identity_count = 0 provider.group_mapping_count = 0 return _serialize_provider(provider) @router.patch("/providers/{provider_id}", response=IdentityProviderOut) def update_provider(request, provider_id: int, payload: IdentityProviderUpdateIn): require_permission(request, "system.setting.manage") provider = EnterpriseIdentityProvider.objects.get(id=provider_id) values = payload.dict(exclude_unset=True) credential = values.pop("credential", None) if "configuration" in values: _validate_configuration(values["configuration"]) try: if credential: provider.credential_encrypted = encrypt_secret(credential) for field, value in values.items(): setattr(provider, field, value) provider.updated_by = request.user _validate_provider(provider) provider.full_clean() provider.save() except CredentialEncryptionError as error: raise HttpError(400, str(error)) from error except ValidationError as error: raise HttpError(400, "; ".join(error.messages)) from error _audit( request.user, "update_provider", "identity_provider", str(provider.id), {"fields": sorted(values), "credential_replaced": bool(credential)}, ) provider.identity_count = provider.external_identities.count() provider.group_mapping_count = provider.group_mappings.count() return _serialize_provider(provider) @router.get( "/providers/{provider_id}/group-mappings", response=list[EnterpriseGroupMappingOut], ) def list_group_mappings(request, provider_id: int): require_permission(request, "system.setting.manage") mappings = EnterpriseGroupMapping.objects.select_related("user_group").filter( provider_id=provider_id ) return [_serialize_mapping(mapping) for mapping in mappings] @router.post( "/providers/{provider_id}/group-mappings", response=EnterpriseGroupMappingOut, ) def create_group_mapping( request, provider_id: int, payload: EnterpriseGroupMappingIn, ): require_permission(request, "system.setting.manage") provider = EnterpriseIdentityProvider.objects.get(id=provider_id) user_group = UserGroup.objects.get(id=payload.user_group_id) external_group = payload.external_group.strip() if not external_group: raise HttpError(400, "企业组织标识不能为空") try: mapping = EnterpriseGroupMapping.objects.create( provider=provider, external_group=external_group, user_group=user_group, ) except IntegrityError as error: raise HttpError(400, "该外部组织映射已存在") from error _audit( request.user, "create_group_mapping", "identity_provider", str(provider.id), {"external_group": mapping.external_group, "user_group_id": user_group.id}, ) return _serialize_mapping(mapping) @router.delete("/group-mappings/{mapping_id}") def delete_group_mapping(request, mapping_id: int): require_permission(request, "system.setting.manage") mapping = EnterpriseGroupMapping.objects.select_related("provider").get(id=mapping_id) snapshot = { "external_group": mapping.external_group, "user_group_id": mapping.user_group_id, } provider_id = mapping.provider_id mapping.delete() _audit( request.user, "delete_group_mapping", "identity_provider", str(provider_id), snapshot, ) return {"status": "deleted"} @router.post( "/providers/{provider_id}/directory-sync", response=DirectorySyncRunOut, ) def directory_sync(request, provider_id: int, payload: DirectorySyncIn): require_permission(request, "system.setting.manage") if len(payload.records) > 1000: raise HttpError(400, "单次目录同步最多接收 1000 条记录") provider = EnterpriseIdentityProvider.objects.get(id=provider_id) records = [record.dict() for record in payload.records] run = run_directory_sync(provider, records, request.user) _audit( request.user, "directory_sync", "identity_provider", str(provider.id), { "run_id": run.id, "total_count": run.total_count, "failed_count": run.failed_count, }, "success" if run.failed_count == 0 else "failed", ) return run