Files

382 lines
14 KiB
Python

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