382 lines
14 KiB
Python
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
|