Files
opsxsql/backend/app/api/v1/endpoints/ldap_config.py
T
logan be2d6f3894 feat: LDAP 账号管理 — OU管理/添加用户/删除用户/批量导入
后端新增 4 个 LDAP 管理端点:
- POST /ldap-config/create-ou: 创建组织单元
- POST /ldap-config/create-user: 创建单个用户 (SSHA加密)
- POST /ldap-config/delete-user: 删除用户
- POST /ldap-config/batch-import-ldap: 批量导入用户
- POST /ldap-config/user-list: 查询用户列表(恢复)

前端 LdapConfigPage 增强:
- LDAP 账号管理折叠区(配置存在即显示)
- OU 下拉选择 + 根节点选项 + 数量统计
- 用户列表表格(全选/单选/DN显示)
- 新建 OU 弹框(名称+父级DN+描述)
- 添加用户弹框(用户名+密码+显示名+邮箱+目标OU)
- 删除选中用户按钮(确认后批量删除)

前端 UsersPage BatchImportModal 三 Tab:
- 本地导入 / 导入到 LDAP / 从 LDAP 导入

测试修复:
- test_user_endpoint.py: 分页格式兼容
- test_users.py: 分页格式兼容
- test_system_settings.py: 精简后配置项匹配
2026-05-25 17:34:53 +08:00

542 lines
19 KiB
Python

import json
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import encrypt_password, decrypt_password
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.ldap_config import LdapConfig
from app.schemas.ldap_config import (
LdapConfigCreate,
LdapConfigUpdate,
LdapConfigResponse,
LdapTestRequest,
)
router = APIRouter(prefix="/ldap-config", tags=["LDAP配置"])
def _serialize_ldap(ldap: LdapConfig) -> dict:
"""Serialize LdapConfig to response dict with masked password."""
user_mapping = json.loads(ldap.user_mapping) if ldap.user_mapping else None
return {
"id": ldap.id,
"ldap_host": ldap.ldap_host,
"ldap_port": ldap.ldap_port,
"use_ldaps": ldap.use_ldaps,
"admin_dn": ldap.admin_dn,
"admin_password": "******",
"search_filter": ldap.search_filter,
"base_dn": ldap.base_dn,
"user_mapping": user_mapping,
"created_at": ldap.created_at,
"updated_at": ldap.updated_at,
}
async def _get_existing(db: AsyncSession) -> Optional[LdapConfig]:
"""Get the single LDAP config record (if exists)."""
result = await db.execute(select(LdapConfig).limit(1))
return result.scalar_one_or_none()
@router.get("", response_model=LdapConfigResponse)
async def get_ldap_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员获取 LDAP 配置"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
return _serialize_ldap(ldap)
@router.put("", response_model=LdapConfigResponse)
async def upsert_ldap_config(
data: LdapConfigCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员创建或更新 LDAP 配置 (upsert)"""
ldap = await _get_existing(db)
if ldap:
# Update existing
ldap.ldap_host = data.ldap_host
ldap.ldap_port = data.ldap_port
ldap.use_ldaps = data.use_ldaps
ldap.admin_dn = data.admin_dn
if data.admin_password:
ldap.admin_password = encrypt_password(data.admin_password)
ldap.search_filter = data.search_filter
ldap.base_dn = data.base_dn
ldap.user_mapping = json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None
else:
# Create new — 首次必须提供密码
if not data.admin_password:
raise HTTPException(status_code=400, detail="首次配置 LDAP 必须提供管理员密码")
ldap = LdapConfig(
ldap_host=data.ldap_host,
ldap_port=data.ldap_port,
use_ldaps=data.use_ldaps,
admin_dn=data.admin_dn,
admin_password=encrypt_password(data.admin_password),
search_filter=data.search_filter,
base_dn=data.base_dn,
user_mapping=json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None,
)
db.add(ldap)
await db.commit()
await db.refresh(ldap)
return _serialize_ldap(ldap)
@router.post("/test")
async def test_ldap_connection(
data: LdapTestRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""测试 LDAP 连接 + 用户 Lookup 详细信息"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
try:
import ldap as _ldap # noqa
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
# Search user
search_filter = ldap.search_filter or "(uid=%s)"
user_filter = search_filter.replace("%s", data.test_user)
results = conn.search_s(
ldap.base_dn,
_ldap.SCOPE_SUBTREE,
user_filter,
["uid", "cn", "mail", "telephoneNumber", "title", "department", "memberOf", "distinguishedName"],
)
if not results:
return {"success": False, "message": f"未找到用户: {data.test_user}"}
user_dn, user_attrs = results[0]
if not user_dn:
return {"success": False, "message": f"未找到用户: {data.test_user}"}
# Verify password by binding as user
conn.simple_bind_s(user_dn, data.test_password)
# Build user_info
user_mapping = json.loads(ldap.user_mapping) if ldap.user_mapping else {}
username_attr = user_mapping.get("username", "uid")
email_attr = user_mapping.get("email", "mail")
display_attr = user_mapping.get("display_name", "cn")
def _attr(key: str) -> str:
vals = user_attrs.get(key, [])
return vals[0].decode("utf-8", errors="ignore") if vals else ""
uid = _attr(username_attr) or data.test_user
display_name = _attr(display_attr)
email = _attr(email_attr)
# Lookup groups via memberOf
groups = []
group_vals = user_attrs.get("memberOf", [])
for g in group_vals:
g_str = g.decode("utf-8", errors="ignore")
# Extract CN from DN like "cn=ops,ou=groups,dc=..."
cn = g_str.split(",")[0]
if cn.lower().startswith("cn="):
cn = cn[3:]
groups.append(cn)
conn.unbind_s()
return {
"success": True,
"message": f"用户 {uid} 验证通过",
"user_info": {
"User lookup": "successful",
"User ID": uid,
"User DN": user_dn,
"Display Name": display_name or uid,
"Email": email or "(未设置)",
"Groups": groups,
"Group lookup": f"successful ({len(groups)} groups)" if groups else "no groups found",
},
}
except ImportError:
return {
"success": False,
"message": "python-ldap 未安装,无法测试 LDAP 连接。请执行: pip install python-ldap",
}
except Exception as e:
return {"success": False, "message": f"LDAP 连接失败: {str(e)}"}
@router.post("/ou-list")
async def list_ldap_ous(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取 LDAP OU(组织单元)列表"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
results = conn.search_s(
ldap.base_dn,
_ldap.SCOPE_SUBTREE,
"(objectClass=organizationalUnit)",
["ou", "description", "distinguishedName"],
)
conn.unbind_s()
ous = []
for dn, attrs in results:
if dn:
ou_name = attrs.get("ou", [b""])[0].decode("utf-8", errors="ignore")
desc = attrs.get("description", [b""])[0].decode("utf-8", errors="ignore")
ous.append({"dn": dn, "name": ou_name or dn, "description": desc})
return {"ous": ous}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"LDAP 查询失败: {str(e)}")
@router.post("/user-list")
async def list_ldap_users(
data: dict = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取 LDAP 用户列表"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
search_base = (data or {}).get("ou_dn", "") or ldap.base_dn
results = conn.search_s(
search_base,
_ldap.SCOPE_SUBTREE,
"(objectClass=inetOrgPerson)",
["uid", "cn", "mail", "dn"],
)
conn.unbind_s()
users = []
for dn, attrs in results:
if dn:
users.append({
"dn": dn,
"uid": attrs.get("uid", [b""])[0].decode("utf-8", errors="ignore"),
"cn": attrs.get("cn", [b""])[0].decode("utf-8", errors="ignore"),
"mail": attrs.get("mail", [b""])[0].decode("utf-8", errors="ignore"),
})
return {"users": users, "total": len(users)}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"LDAP 查询用户失败: {str(e)}")
@router.post("/create-ou")
async def create_ldap_ou(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""在 LDAP 中创建组织单元 (OU)"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
ou_name = data.get("ou_name", "").strip()
parent_dn = data.get("parent_dn", "").strip()
description = data.get("description", "").strip()
if not ou_name:
raise HTTPException(status_code=400, detail="OU 名称不能为空")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
new_ou_dn = f"ou={ou_name},{parent_dn}" if parent_dn else f"ou={ou_name},{ldap.base_dn}"
# Check if OU already exists
try:
conn.search_s(new_ou_dn, _ldap.SCOPE_BASE)
conn.unbind_s()
raise HTTPException(status_code=409, detail=f"OU 已存在: {new_ou_dn}")
except _ldap.NO_SUCH_OBJECT:
pass
attrs = {
"objectClass": [b"organizationalUnit", b"top"],
"ou": ou_name.encode("utf-8"),
}
if description:
attrs["description"] = description.encode("utf-8")
conn.add_s(new_ou_dn, list(attrs.items()))
conn.unbind_s()
return {"success": True, "dn": new_ou_dn, "message": f"OU \"{ou_name}\" 创建成功"}
except HTTPException:
raise
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建 OU 失败: {str(e)}")
@router.post("/create-user")
async def create_ldap_user(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""在 LDAP 中创建单个用户"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
username = data.get("username", "").strip()
display_name = data.get("display_name", "").strip()
email = data.get("email", "").strip()
password = data.get("password", "").strip()
ou_dn = data.get("ou_dn", "").strip() or ldap.base_dn
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
try:
import ldap as _ldap
import hashlib, base64
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
user_dn = f"uid={username},{ou_dn}"
try:
conn.search_s(user_dn, _ldap.SCOPE_BASE)
conn.unbind_s()
raise HTTPException(status_code=409, detail=f"用户已存在: {username}")
except _ldap.NO_SUCH_OBJECT:
pass
# SSHA password
salt = os.urandom(4)
digest = hashlib.sha1(password.encode() + salt).digest()
user_password = "{SSHA}" + base64.b64encode(digest + salt).decode()
attrs = {
"objectClass": [b"inetOrgPerson", b"organizationalPerson", b"person", b"top"],
"uid": username.encode("utf-8"),
"cn": (display_name or username).encode("utf-8"),
"sn": (display_name or username).encode("utf-8"),
"userPassword": user_password.encode("utf-8"),
}
if email:
attrs["mail"] = email.encode("utf-8")
conn.add_s(user_dn, list(attrs.items()))
conn.unbind_s()
return {"success": True, "dn": user_dn, "message": f"用户 \"{username}\" 创建成功"}
except HTTPException:
raise
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建用户失败: {str(e)}")
@router.post("/delete-user")
async def delete_ldap_user(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""从 LDAP 中删除用户"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
dn = data.get("dn", "").strip()
if not dn:
raise HTTPException(status_code=400, detail="用户 DN 不能为空")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
conn.delete_s(dn)
conn.unbind_s()
return {"success": True, "message": f"用户已删除: {dn}"}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除用户失败: {str(e)}")
@router.post("/batch-import-ldap")
async def batch_import_to_ldap(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""批量导入用户到 LDAP 服务器"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
users = data.get("users", [])
ou_dn = data.get("ou_dn", "").strip() or ldap.base_dn
if not users:
raise HTTPException(status_code=400, detail="用户列表不能为空")
try:
import ldap as _ldap
import hashlib, base64
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
success_count = 0
failed_count = 0
failed_users = []
for u in users:
username = u.get("username", "").strip()
display_name = u.get("display_name", u.get("nickname", "")).strip()
email = u.get("email", "").strip()
password = u.get("password", username)
if not username:
failed_count += 1
failed_users.append({"username": "", "error": "用户名为空"})
continue
user_dn = f"uid={username},{ou_dn}"
try:
try:
conn.search_s(user_dn, _ldap.SCOPE_BASE)
failed_count += 1
failed_users.append({"username": username, "error": "用户已存在"})
continue
except _ldap.NO_SUCH_OBJECT:
pass
salt = os.urandom(4)
digest = hashlib.sha1(password.encode() + salt).digest()
user_password = "{SSHA}" + base64.b64encode(digest + salt).decode()
attrs = {
"objectClass": [b"inetOrgPerson", b"organizationalPerson", b"person", b"top"],
"uid": username.encode("utf-8"),
"cn": (display_name or username).encode("utf-8"),
"sn": (display_name or username).encode("utf-8"),
"userPassword": user_password.encode("utf-8"),
}
if email:
attrs["mail"] = email.encode("utf-8")
conn.add_s(user_dn, list(attrs.items()))
success_count += 1
except Exception as e:
failed_count += 1
failed_users.append({"username": username, "error": str(e)})
conn.unbind_s()
return {
"success": True,
"success_count": success_count,
"failed_count": failed_count,
"failed_users": failed_users,
"message": f"导入完成: 成功 {success_count}, 失败 {failed_count}",
}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}")