feat: add enterprise identity and channel integrations
This commit is contained in:
381
backend/apps/accounts/identity_api.py
Normal file
381
backend/apps/accounts/identity_api.py
Normal file
@@ -0,0 +1,381 @@
|
||||
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
|
||||
110
backend/apps/accounts/identity_schemas.py
Normal file
110
backend/apps/accounts/identity_schemas.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class PublicIdentityProviderOut(Schema):
|
||||
slug: str
|
||||
name: str
|
||||
button_label: str
|
||||
provider_type: str
|
||||
|
||||
|
||||
class IdentityProviderOut(Schema):
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
provider_type: str
|
||||
enabled: bool
|
||||
button_label: str
|
||||
issuer: str
|
||||
metadata_url: str
|
||||
client_id: str
|
||||
credential_set: bool
|
||||
credential_masked: str
|
||||
scopes: list[str]
|
||||
claim_mappings: dict[str, str]
|
||||
configuration: dict[str, Any]
|
||||
auto_create_users: bool
|
||||
sync_profile: bool
|
||||
sync_groups: bool
|
||||
identity_count: int
|
||||
group_mapping_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityProviderCreateIn(Schema):
|
||||
slug: str
|
||||
name: str
|
||||
provider_type: str = "oidc"
|
||||
enabled: bool = False
|
||||
button_label: str = ""
|
||||
issuer: str = ""
|
||||
metadata_url: str = ""
|
||||
client_id: str = ""
|
||||
credential: str = ""
|
||||
scopes: list[str] = ["openid", "profile", "email"]
|
||||
claim_mappings: dict[str, str] = {}
|
||||
configuration: dict[str, Any] = {}
|
||||
auto_create_users: bool = True
|
||||
sync_profile: bool = True
|
||||
sync_groups: bool = True
|
||||
|
||||
|
||||
class IdentityProviderUpdateIn(Schema):
|
||||
name: str | None = None
|
||||
enabled: bool | None = None
|
||||
button_label: str | None = None
|
||||
issuer: str | None = None
|
||||
metadata_url: str | None = None
|
||||
client_id: str | None = None
|
||||
credential: str | None = None
|
||||
scopes: list[str] | None = None
|
||||
claim_mappings: dict[str, str] | None = None
|
||||
configuration: dict[str, Any] | None = None
|
||||
auto_create_users: bool | None = None
|
||||
sync_profile: bool | None = None
|
||||
sync_groups: bool | None = None
|
||||
|
||||
|
||||
class EnterpriseGroupMappingOut(Schema):
|
||||
id: int
|
||||
provider_id: int
|
||||
external_group: str
|
||||
user_group_id: int
|
||||
user_group_name: str
|
||||
user_group_code: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EnterpriseGroupMappingIn(Schema):
|
||||
external_group: str
|
||||
user_group_id: int
|
||||
|
||||
|
||||
class DirectoryRecordIn(Schema):
|
||||
subject: str
|
||||
username: str = ""
|
||||
email: str = ""
|
||||
display_name: str = ""
|
||||
active: bool = True
|
||||
groups: list[str] = []
|
||||
|
||||
|
||||
class DirectorySyncIn(Schema):
|
||||
records: list[DirectoryRecordIn]
|
||||
|
||||
|
||||
class DirectorySyncRunOut(Schema):
|
||||
id: int
|
||||
provider_id: int
|
||||
status: str
|
||||
total_count: int
|
||||
created_count: int
|
||||
updated_count: int
|
||||
failed_count: int
|
||||
errors: list[dict[str, Any]]
|
||||
started_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
230
backend/apps/accounts/identity_services.py
Normal file
230
backend/apps/accounts/identity_services.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""企业身份声明、目录账号和组织映射同步服务。"""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import (
|
||||
EnterpriseDirectorySyncRun,
|
||||
EnterpriseIdentityProvider,
|
||||
ExternalIdentity,
|
||||
)
|
||||
|
||||
DEFAULT_CLAIM_MAPPINGS = {
|
||||
"subject": "sub",
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
"display_name": "name",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
|
||||
class IdentitySyncError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def resolved_claim_mappings(provider: EnterpriseIdentityProvider) -> dict[str, str]:
|
||||
return {**DEFAULT_CLAIM_MAPPINGS, **(provider.claim_mappings or {})}
|
||||
|
||||
|
||||
def read_claim(claims: Mapping[str, Any], path: str, default: Any = "") -> Any:
|
||||
value: Any = claims
|
||||
for part in path.split("."):
|
||||
if not isinstance(value, Mapping) or part not in value:
|
||||
return default
|
||||
value = value[part]
|
||||
return value
|
||||
|
||||
|
||||
def _clean_username(value: str) -> str:
|
||||
cleaned = re.sub(r"[^@\w.+-]", "_", value.strip(), flags=re.UNICODE).strip("._")
|
||||
return cleaned[:150]
|
||||
|
||||
|
||||
def _unique_username(provider: EnterpriseIdentityProvider, preferred: str, subject: str) -> str:
|
||||
user_model = get_user_model()
|
||||
base = (
|
||||
_clean_username(preferred)
|
||||
or _clean_username(f"{provider.slug}_{subject}")
|
||||
or provider.slug
|
||||
)
|
||||
if not user_model.objects.filter(username=base).exists():
|
||||
return base
|
||||
for suffix in range(2, 10000):
|
||||
candidate = f"{base[: 150 - len(str(suffix)) - 1]}-{suffix}"
|
||||
if not user_model.objects.filter(username=candidate).exists():
|
||||
return candidate
|
||||
raise IdentitySyncError("无法生成唯一的本地账号")
|
||||
|
||||
|
||||
def _claim_list(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _sync_business_groups(
|
||||
provider: EnterpriseIdentityProvider,
|
||||
identity: ExternalIdentity,
|
||||
external_groups: list[str],
|
||||
) -> None:
|
||||
mappings = list(provider.group_mappings.select_related("user_group"))
|
||||
managed_group_ids = {mapping.user_group_id for mapping in mappings}
|
||||
desired_group_ids = {
|
||||
mapping.user_group_id
|
||||
for mapping in mappings
|
||||
if mapping.external_group in set(external_groups) and mapping.user_group.is_active
|
||||
}
|
||||
if managed_group_ids:
|
||||
identity.user.business_groups.remove(*(managed_group_ids - desired_group_ids))
|
||||
if desired_group_ids:
|
||||
identity.user.business_groups.add(*desired_group_ids)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_identity_claims(
|
||||
provider: EnterpriseIdentityProvider,
|
||||
claims: Mapping[str, Any],
|
||||
*,
|
||||
login_event: bool = False,
|
||||
active: bool | None = None,
|
||||
claim_mappings: Mapping[str, str] | None = None,
|
||||
) -> tuple[ExternalIdentity, bool]:
|
||||
"""按稳定 subject 同步本地账号、档案和受管业务组,不依据显示名关联身份。"""
|
||||
|
||||
mappings = dict(claim_mappings or resolved_claim_mappings(provider))
|
||||
subject = str(read_claim(claims, mappings["subject"], "")).strip()
|
||||
if not subject:
|
||||
raise IdentitySyncError("身份声明缺少稳定 subject")
|
||||
|
||||
identity = (
|
||||
ExternalIdentity.objects.select_for_update()
|
||||
.select_related("user")
|
||||
.filter(provider=provider, subject=subject)
|
||||
.first()
|
||||
)
|
||||
created = identity is None
|
||||
email = str(read_claim(claims, mappings["email"], "") or "").strip().lower()
|
||||
preferred_username = str(read_claim(claims, mappings["username"], "") or "").strip()
|
||||
display_name = str(read_claim(claims, mappings["display_name"], "") or "").strip()
|
||||
|
||||
if identity is None:
|
||||
if not provider.auto_create_users:
|
||||
raise IdentitySyncError("该身份提供方不允许自动创建账号")
|
||||
user_model = get_user_model()
|
||||
user = None
|
||||
if email and provider.configuration.get("allow_email_linking") is True:
|
||||
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
|
||||
if user is None:
|
||||
username = _unique_username(provider, preferred_username or email, subject)
|
||||
user = user_model(username=username, email=email, first_name=display_name)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
identity = ExternalIdentity.objects.create(
|
||||
provider=provider,
|
||||
subject=subject,
|
||||
user=user,
|
||||
)
|
||||
|
||||
user = identity.user
|
||||
changed_fields: list[str] = []
|
||||
if provider.sync_profile:
|
||||
if email and user.email != email:
|
||||
user.email = email
|
||||
changed_fields.append("email")
|
||||
if display_name and user.first_name != display_name:
|
||||
user.first_name = display_name
|
||||
changed_fields.append("first_name")
|
||||
if active is not None and user.is_active != active:
|
||||
user.is_active = active
|
||||
changed_fields.append("is_active")
|
||||
if changed_fields:
|
||||
user.save(update_fields=changed_fields)
|
||||
if login_event and not user.is_active:
|
||||
raise IdentitySyncError("账号已停用")
|
||||
|
||||
now = timezone.now()
|
||||
identity.claims_snapshot = dict(claims)
|
||||
identity.last_synced_at = now
|
||||
update_fields = ["claims_snapshot", "last_synced_at", "updated_at"]
|
||||
if login_event:
|
||||
identity.last_login_at = now
|
||||
update_fields.append("last_login_at")
|
||||
identity.save(update_fields=update_fields)
|
||||
|
||||
if provider.sync_groups:
|
||||
_sync_business_groups(
|
||||
provider,
|
||||
identity,
|
||||
_claim_list(read_claim(claims, mappings["groups"], [])),
|
||||
)
|
||||
return identity, created
|
||||
|
||||
|
||||
def run_directory_sync(
|
||||
provider: EnterpriseIdentityProvider,
|
||||
records: list[dict[str, Any]],
|
||||
initiated_by,
|
||||
) -> EnterpriseDirectorySyncRun:
|
||||
"""逐条同步目录快照,隔离单条失败并保留最多一百条脱敏错误摘要。"""
|
||||
|
||||
run = EnterpriseDirectorySyncRun.objects.create(
|
||||
provider=provider,
|
||||
initiated_by=initiated_by,
|
||||
total_count=len(records),
|
||||
)
|
||||
errors: list[dict[str, str]] = []
|
||||
for index, record in enumerate(records):
|
||||
claims = {
|
||||
"sub": record.get("subject", ""),
|
||||
"preferred_username": record.get("username", ""),
|
||||
"email": record.get("email", ""),
|
||||
"name": record.get("display_name", ""),
|
||||
"groups": record.get("groups", []),
|
||||
}
|
||||
try:
|
||||
_, created = sync_identity_claims(
|
||||
provider,
|
||||
claims,
|
||||
active=bool(record.get("active", True)),
|
||||
claim_mappings=DEFAULT_CLAIM_MAPPINGS,
|
||||
)
|
||||
if created:
|
||||
run.created_count += 1
|
||||
else:
|
||||
run.updated_count += 1
|
||||
except (IdentitySyncError, IntegrityError) as error:
|
||||
run.failed_count += 1
|
||||
errors.append(
|
||||
{
|
||||
"index": str(index),
|
||||
"subject": str(record.get("subject", ""))[:255],
|
||||
"error": str(error),
|
||||
}
|
||||
)
|
||||
|
||||
run.errors = errors[:100]
|
||||
if run.failed_count == 0:
|
||||
run.status = EnterpriseDirectorySyncRun.Status.COMPLETED
|
||||
elif run.failed_count == run.total_count:
|
||||
run.status = EnterpriseDirectorySyncRun.Status.FAILED
|
||||
else:
|
||||
run.status = EnterpriseDirectorySyncRun.Status.PARTIAL
|
||||
run.finished_at = timezone.now()
|
||||
run.save(
|
||||
update_fields=[
|
||||
"created_count",
|
||||
"updated_count",
|
||||
"failed_count",
|
||||
"errors",
|
||||
"status",
|
||||
"finished_at",
|
||||
]
|
||||
)
|
||||
return run
|
||||
@@ -102,6 +102,24 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
|
||||
40,
|
||||
"agent.run.view",
|
||||
),
|
||||
MenuSpec(
|
||||
"platform.tools",
|
||||
"Tool / MCP",
|
||||
"#/tools",
|
||||
"settings",
|
||||
"platform",
|
||||
50,
|
||||
"tool.view",
|
||||
),
|
||||
MenuSpec(
|
||||
"platform.channels",
|
||||
"渠道接入",
|
||||
"#/channels",
|
||||
"message",
|
||||
"platform",
|
||||
60,
|
||||
"channel.view",
|
||||
),
|
||||
MenuSpec("system", "系统治理", icon="shield", sort_order=50),
|
||||
MenuSpec(
|
||||
"system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view"
|
||||
@@ -121,6 +139,15 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
|
||||
MenuSpec(
|
||||
"system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view"
|
||||
),
|
||||
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 50, "system.audit.view"),
|
||||
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 60),
|
||||
MenuSpec(
|
||||
"system.identity",
|
||||
"企业身份",
|
||||
"#/system/identity",
|
||||
"shield",
|
||||
"system",
|
||||
50,
|
||||
"system.setting.manage",
|
||||
),
|
||||
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 60, "system.audit.view"),
|
||||
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 70),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 08:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_reorganize_navigation_menus'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EnterpriseIdentityProvider',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(max_length=80, unique=True)),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('provider_type', models.CharField(choices=[('oidc', 'OpenID Connect'), ('ldap', 'LDAP')], default='oidc', max_length=20)),
|
||||
('enabled', models.BooleanField(default=False)),
|
||||
('button_label', models.CharField(blank=True, max_length=120)),
|
||||
('issuer', models.URLField(blank=True, max_length=500)),
|
||||
('metadata_url', models.URLField(blank=True, max_length=500)),
|
||||
('client_id', models.CharField(blank=True, max_length=255)),
|
||||
('credential_encrypted', models.TextField(blank=True)),
|
||||
('scopes', models.JSONField(blank=True, default=list)),
|
||||
('claim_mappings', models.JSONField(blank=True, default=dict)),
|
||||
('configuration', models.JSONField(blank=True, default=dict)),
|
||||
('auto_create_users', models.BooleanField(default=True)),
|
||||
('sync_profile', models.BooleanField(default=True)),
|
||||
('sync_groups', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_identity_providers', to=settings.AUTH_USER_MODEL)),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_identity_providers', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnterpriseGroupMapping',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('external_group', models.CharField(max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enterprise_identity_mappings', to='accounts.usergroup')),
|
||||
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_mappings', to='accounts.enterpriseidentityprovider')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['provider_id', 'external_group'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnterpriseDirectorySyncRun',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('running', '运行中'), ('completed', '已完成'), ('partial', '部分完成'), ('failed', '失败')], default='running', max_length=20)),
|
||||
('total_count', models.PositiveIntegerField(default=0)),
|
||||
('created_count', models.PositiveIntegerField(default=0)),
|
||||
('updated_count', models.PositiveIntegerField(default=0)),
|
||||
('failed_count', models.PositiveIntegerField(default=0)),
|
||||
('errors', models.JSONField(blank=True, default=list)),
|
||||
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||
('initiated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='enterprise_directory_sync_runs', to=settings.AUTH_USER_MODEL)),
|
||||
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='directory_sync_runs', to='accounts.enterpriseidentityprovider')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-started_at', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExternalIdentity',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('subject', models.CharField(max_length=255)),
|
||||
('claims_snapshot', models.JSONField(blank=True, default=dict)),
|
||||
('last_login_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_synced_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_identities', to='accounts.enterpriseidentityprovider')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_identities', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['provider_id', 'subject'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='enterpriseidentityprovider',
|
||||
index=models.Index(fields=['provider_type', 'enabled'], name='acct_idp_type_enabled_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='enterprisegroupmapping',
|
||||
constraint=models.UniqueConstraint(fields=('provider', 'external_group'), name='acct_idp_external_group_uniq'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='enterprisedirectorysyncrun',
|
||||
index=models.Index(fields=['provider', 'started_at'], name='acct_dirsync_idp_started_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='externalidentity',
|
||||
index=models.Index(fields=['user', 'provider'], name='acct_identity_user_idp_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='externalidentity',
|
||||
constraint=models.UniqueConstraint(fields=('provider', 'subject'), name='acct_identity_provider_subject_uniq'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='externalidentity',
|
||||
constraint=models.UniqueConstraint(fields=('provider', 'user'), name='acct_identity_provider_user_uniq'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def add_identity_menu(apps, schema_editor):
|
||||
menu_item = apps.get_model("accounts", "MenuItem")
|
||||
parent = menu_item.objects.filter(code="system").first()
|
||||
menu_item.objects.update_or_create(
|
||||
code="system.identity",
|
||||
defaults={
|
||||
"title": "企业身份",
|
||||
"path": "#/system/identity",
|
||||
"icon": "shield",
|
||||
"parent": parent,
|
||||
"sort_order": 50,
|
||||
"permission_code": "system.setting.manage",
|
||||
"is_visible": True,
|
||||
"is_enabled": True,
|
||||
},
|
||||
)
|
||||
menu_item.objects.filter(code="system.audit").update(sort_order=60)
|
||||
menu_item.objects.filter(code="system.design").update(sort_order=70)
|
||||
|
||||
|
||||
def remove_identity_menu(apps, schema_editor):
|
||||
menu_item = apps.get_model("accounts", "MenuItem")
|
||||
menu_item.objects.filter(code="system.identity").delete()
|
||||
menu_item.objects.filter(code="system.audit").update(sort_order=50)
|
||||
menu_item.objects.filter(code="system.design").update(sort_order=60)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0003_enterpriseidentityprovider_enterprisegroupmapping_and_more")]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_identity_menu, remove_identity_menu),
|
||||
]
|
||||
@@ -44,3 +44,148 @@ class MenuItem(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
|
||||
class EnterpriseIdentityProvider(models.Model):
|
||||
class ProviderType(models.TextChoices):
|
||||
OIDC = "oidc", "OpenID Connect"
|
||||
LDAP = "ldap", "LDAP"
|
||||
|
||||
slug = models.SlugField(max_length=80, unique=True)
|
||||
name = models.CharField(max_length=120)
|
||||
provider_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ProviderType.choices,
|
||||
default=ProviderType.OIDC,
|
||||
)
|
||||
enabled = models.BooleanField(default=False)
|
||||
button_label = models.CharField(max_length=120, blank=True)
|
||||
issuer = models.URLField(max_length=500, blank=True)
|
||||
metadata_url = models.URLField(max_length=500, blank=True)
|
||||
client_id = models.CharField(max_length=255, blank=True)
|
||||
credential_encrypted = models.TextField(blank=True)
|
||||
scopes = models.JSONField(default=list, blank=True)
|
||||
claim_mappings = models.JSONField(default=dict, blank=True)
|
||||
configuration = models.JSONField(default=dict, blank=True)
|
||||
auto_create_users = models.BooleanField(default=True)
|
||||
sync_profile = models.BooleanField(default=True)
|
||||
sync_groups = models.BooleanField(default=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_identity_providers",
|
||||
)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="updated_identity_providers",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name", "id"]
|
||||
indexes = [
|
||||
models.Index(fields=["provider_type", "enabled"], name="acct_idp_type_enabled_idx"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class ExternalIdentity(models.Model):
|
||||
provider = models.ForeignKey(
|
||||
EnterpriseIdentityProvider,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="external_identities",
|
||||
)
|
||||
subject = models.CharField(max_length=255)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="external_identities",
|
||||
)
|
||||
claims_snapshot = models.JSONField(default=dict, blank=True)
|
||||
last_login_at = models.DateTimeField(null=True, blank=True)
|
||||
last_synced_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["provider_id", "subject"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["provider", "subject"],
|
||||
name="acct_identity_provider_subject_uniq",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["provider", "user"],
|
||||
name="acct_identity_provider_user_uniq",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "provider"], name="acct_identity_user_idp_idx"),
|
||||
]
|
||||
|
||||
|
||||
class EnterpriseGroupMapping(models.Model):
|
||||
provider = models.ForeignKey(
|
||||
EnterpriseIdentityProvider,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="group_mappings",
|
||||
)
|
||||
external_group = models.CharField(max_length=255)
|
||||
user_group = models.ForeignKey(
|
||||
UserGroup,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="enterprise_identity_mappings",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["provider_id", "external_group"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["provider", "external_group"],
|
||||
name="acct_idp_external_group_uniq",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class EnterpriseDirectorySyncRun(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
RUNNING = "running", "运行中"
|
||||
COMPLETED = "completed", "已完成"
|
||||
PARTIAL = "partial", "部分完成"
|
||||
FAILED = "failed", "失败"
|
||||
|
||||
provider = models.ForeignKey(
|
||||
EnterpriseIdentityProvider,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="directory_sync_runs",
|
||||
)
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.RUNNING)
|
||||
total_count = models.PositiveIntegerField(default=0)
|
||||
created_count = models.PositiveIntegerField(default=0)
|
||||
updated_count = models.PositiveIntegerField(default=0)
|
||||
failed_count = models.PositiveIntegerField(default=0)
|
||||
errors = models.JSONField(default=list, blank=True)
|
||||
initiated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="enterprise_directory_sync_runs",
|
||||
)
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-started_at", "-id"]
|
||||
indexes = [
|
||||
models.Index(fields=["provider", "started_at"], name="acct_dirsync_idp_started_idx"),
|
||||
]
|
||||
|
||||
72
backend/apps/accounts/oidc.py
Normal file
72
backend/apps/accounts/oidc.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""OIDC 客户端构建和安全重定向辅助函数。"""
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.accounts.models import EnterpriseIdentityProvider
|
||||
from apps.modelhub.crypto import decrypt_secret
|
||||
|
||||
|
||||
class OIDCConfigurationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def provider_metadata_url(provider: EnterpriseIdentityProvider) -> str:
|
||||
if provider.metadata_url:
|
||||
return provider.metadata_url
|
||||
if provider.issuer:
|
||||
return f"{provider.issuer.rstrip('/')}/.well-known/openid-configuration"
|
||||
raise OIDCConfigurationError("OIDC 提供方缺少发现文档地址或 issuer")
|
||||
|
||||
|
||||
def build_oidc_client(provider: EnterpriseIdentityProvider):
|
||||
"""构建依赖发现文档和加密凭据的 OIDC 客户端。"""
|
||||
|
||||
try:
|
||||
from authlib.integrations.django_client import OAuth
|
||||
except ImportError as error:
|
||||
raise OIDCConfigurationError("服务端未安装 Authlib") from error
|
||||
|
||||
if not provider.client_id:
|
||||
raise OIDCConfigurationError("OIDC 提供方缺少 client_id")
|
||||
if not provider.credential_encrypted:
|
||||
raise OIDCConfigurationError("OIDC 提供方缺少 client secret")
|
||||
scopes = provider.scopes or ["openid", "profile", "email"]
|
||||
if "openid" not in scopes:
|
||||
raise OIDCConfigurationError("OIDC scopes 必须包含 openid")
|
||||
oauth = OAuth()
|
||||
return oauth.register(
|
||||
name=f"enterprise_oidc_{provider.pk}",
|
||||
client_id=provider.client_id,
|
||||
client_secret=decrypt_secret(provider.credential_encrypted),
|
||||
server_metadata_url=provider_metadata_url(provider),
|
||||
client_kwargs={
|
||||
"scope": " ".join(scopes),
|
||||
"token_endpoint_auth_method": provider.configuration.get(
|
||||
"token_endpoint_auth_method",
|
||||
"client_secret_basic",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def callback_uri(request, provider: EnterpriseIdentityProvider) -> str:
|
||||
path = f"/api/identity/auth/oidc/{provider.slug}/callback"
|
||||
if settings.OIDC_CALLBACK_BASE_URL:
|
||||
return f"{settings.OIDC_CALLBACK_BASE_URL}{path}"
|
||||
return request.build_absolute_uri(path)
|
||||
|
||||
|
||||
def normalize_return_to(value: str | None) -> str:
|
||||
"""只允许站内绝对路径,阻断协议相对地址和外部开放重定向。"""
|
||||
|
||||
if not value or not value.startswith("/") or value.startswith("//") or len(value) > 500:
|
||||
return "/"
|
||||
return value
|
||||
|
||||
|
||||
def frontend_redirect(return_to: str = "/", error: str = "") -> str:
|
||||
if error:
|
||||
return f"{settings.FRONTEND_APP_URL}/#/login?auth_error={quote(error[:240])}"
|
||||
return f"{settings.FRONTEND_APP_URL}/#{normalize_return_to(return_to)}"
|
||||
238
backend/apps/accounts/tests/test_enterprise_identity.py
Normal file
238
backend/apps/accounts/tests/test_enterprise_identity.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
from apps.accounts.models import (
|
||||
EnterpriseGroupMapping,
|
||||
EnterpriseIdentityProvider,
|
||||
ExternalIdentity,
|
||||
UserGroup,
|
||||
)
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.modelhub.crypto import encrypt_secret
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def login_admin(client):
|
||||
call_command("bootstrap_governance")
|
||||
admin = get_user_model().objects.get(username="admin")
|
||||
client.force_login(admin)
|
||||
return admin
|
||||
|
||||
|
||||
def oidc_provider(**overrides):
|
||||
values = {
|
||||
"slug": "corporate",
|
||||
"name": "企业统一身份",
|
||||
"provider_type": "oidc",
|
||||
"enabled": True,
|
||||
"issuer": "https://idp.example.com",
|
||||
"client_id": "ticket-platform",
|
||||
"credential_encrypted": encrypt_secret("client-secret"),
|
||||
"scopes": ["openid", "profile", "email"],
|
||||
"claim_mappings": {},
|
||||
"configuration": {},
|
||||
}
|
||||
values.update(overrides)
|
||||
return EnterpriseIdentityProvider.objects.create(**values)
|
||||
|
||||
|
||||
def test_provider_management_encrypts_credentials_and_exposes_safe_public_list(client):
|
||||
login_admin(client)
|
||||
|
||||
response = client.post(
|
||||
"/api/identity/providers",
|
||||
data={
|
||||
"slug": "corp-oidc",
|
||||
"name": "公司 SSO",
|
||||
"provider_type": "oidc",
|
||||
"enabled": True,
|
||||
"issuer": "https://idp.example.com",
|
||||
"client_id": "ticket-platform",
|
||||
"credential": "top-secret-value",
|
||||
"scopes": ["openid", "profile", "email"],
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["credential_set"] is True
|
||||
assert "top-secret-value" not in str(payload)
|
||||
provider = EnterpriseIdentityProvider.objects.get(slug="corp-oidc")
|
||||
assert provider.credential_encrypted != "top-secret-value"
|
||||
|
||||
client.logout()
|
||||
public_response = client.get("/api/identity/auth/providers")
|
||||
assert public_response.status_code == 200
|
||||
assert public_response.json() == [
|
||||
{
|
||||
"slug": "corp-oidc",
|
||||
"name": "公司 SSO",
|
||||
"button_label": "使用 公司 SSO 登录",
|
||||
"provider_type": "oidc",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_rejects_plaintext_secrets_inside_configuration(client):
|
||||
login_admin(client)
|
||||
|
||||
response = client.post(
|
||||
"/api/identity/providers",
|
||||
data={
|
||||
"slug": "unsafe",
|
||||
"name": "不安全配置",
|
||||
"provider_type": "ldap",
|
||||
"configuration": {
|
||||
"server_uri": "ldaps://directory.example.com",
|
||||
"bind_password": "must-not-be-plain",
|
||||
},
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert not EnterpriseIdentityProvider.objects.filter(slug="unsafe").exists()
|
||||
|
||||
|
||||
def test_directory_sync_is_idempotent_and_maps_enterprise_groups(client):
|
||||
login_admin(client)
|
||||
provider = oidc_provider(
|
||||
enabled=False,
|
||||
claim_mappings={
|
||||
"subject": "employee.id",
|
||||
"username": "employee.account",
|
||||
"email": "employee.mail",
|
||||
"display_name": "employee.name",
|
||||
"groups": "organization.memberships",
|
||||
},
|
||||
)
|
||||
support = UserGroup.objects.create(name="一线支持", code="support_l1")
|
||||
EnterpriseGroupMapping.objects.create(
|
||||
provider=provider,
|
||||
external_group="IT-Support",
|
||||
user_group=support,
|
||||
)
|
||||
url = f"/api/identity/providers/{provider.id}/directory-sync"
|
||||
record = {
|
||||
"subject": "employee-1001",
|
||||
"username": "zhangsan",
|
||||
"email": "zhangsan@example.com",
|
||||
"display_name": "张三",
|
||||
"groups": ["IT-Support"],
|
||||
}
|
||||
|
||||
first = client.post(
|
||||
url,
|
||||
data={"records": [record]},
|
||||
content_type="application/json",
|
||||
)
|
||||
second = client.post(
|
||||
url,
|
||||
data={"records": [{**record, "display_name": "张三(支持)"}]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["created_count"] == 1
|
||||
assert second.status_code == 200
|
||||
assert second.json()["updated_count"] == 1
|
||||
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
|
||||
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
|
||||
assert identity.user.first_name == "张三(支持)"
|
||||
assert identity.user.business_groups.filter(id=support.id).exists()
|
||||
assert not identity.user.has_usable_password()
|
||||
|
||||
|
||||
def test_oidc_callback_logs_in_user_and_reuses_external_identity(client):
|
||||
provider = oidc_provider()
|
||||
support = UserGroup.objects.create(name="企业支持组", code="enterprise_support")
|
||||
EnterpriseGroupMapping.objects.create(
|
||||
provider=provider,
|
||||
external_group="support",
|
||||
user_group=support,
|
||||
)
|
||||
|
||||
class FakeOIDCClient:
|
||||
def authorize_access_token(self, request):
|
||||
return {
|
||||
"userinfo": {
|
||||
"sub": "stable-subject",
|
||||
"preferred_username": "corp-user",
|
||||
"email": "corp-user@example.com",
|
||||
"name": "企业用户",
|
||||
"groups": ["support"],
|
||||
}
|
||||
}
|
||||
|
||||
session = client.session
|
||||
session[f"identity_return_to:{provider.slug}"] = "/tickets"
|
||||
session.save()
|
||||
callback = f"/api/identity/auth/oidc/{provider.slug}/callback"
|
||||
|
||||
with patch(
|
||||
"apps.accounts.identity_api.build_oidc_client",
|
||||
return_value=FakeOIDCClient(),
|
||||
):
|
||||
first = client.get(callback)
|
||||
client.logout()
|
||||
second = client.get(callback)
|
||||
|
||||
assert first.status_code == 302
|
||||
assert first.url.endswith("/#/tickets")
|
||||
assert second.status_code == 302
|
||||
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
|
||||
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
|
||||
assert identity.user.business_groups.filter(id=support.id).exists()
|
||||
assert OperationAuditLog.objects.filter(
|
||||
action="oidc_login",
|
||||
target_id=str(identity.id),
|
||||
).count() == 2
|
||||
|
||||
|
||||
def test_oidc_start_uses_server_side_client_and_callback_failure_is_safe(client):
|
||||
provider = oidc_provider()
|
||||
|
||||
class FakeOIDCClient:
|
||||
def authorize_redirect(self, request, redirect_uri):
|
||||
assert redirect_uri.endswith(f"/api/identity/auth/oidc/{provider.slug}/callback")
|
||||
return HttpResponseRedirect("https://idp.example.com/authorize?state=managed")
|
||||
|
||||
def authorize_access_token(self, request):
|
||||
return {"access_token": "must-never-be-audited"}
|
||||
|
||||
with patch(
|
||||
"apps.accounts.identity_api.build_oidc_client",
|
||||
return_value=FakeOIDCClient(),
|
||||
):
|
||||
start = client.get(
|
||||
f"/api/identity/auth/oidc/{provider.slug}/start",
|
||||
{"return_to": "//evil.example.com"},
|
||||
)
|
||||
callback = client.get(f"/api/identity/auth/oidc/{provider.slug}/callback")
|
||||
|
||||
assert start.status_code == 302
|
||||
assert start.url.startswith("https://idp.example.com/authorize")
|
||||
assert callback.status_code == 302
|
||||
assert "auth_error=" in callback.url
|
||||
audit = OperationAuditLog.objects.get(action="oidc_callback_failed")
|
||||
assert "must-never-be-audited" not in str(audit.request_snapshot)
|
||||
|
||||
|
||||
def test_identity_governance_requires_permission(client):
|
||||
provider = oidc_provider(enabled=False)
|
||||
|
||||
assert client.get("/api/identity/providers").status_code == 401
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/identity/providers/{provider.id}/directory-sync",
|
||||
data={"records": []},
|
||||
content_type="application/json",
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
Reference in New Issue
Block a user