后端: - ticket_statistics/list_tickets 按用户身份过滤(admin全部, dba/auditor审批相关, developer自己的) - 新增 /dashboard/my-tickets 接口(按角色返回待处理工单) - auth.py 本地优先+LDAP fallback 登录认证 - ldap_config.py LDAPS 证书验证修复 - instance_detail.py developer/auditor 权限修复 前端: - Dashboard 仪表盘: 3核心大数字指标+公告+我的待处理工单 - 删除 ECharts 趋势图(无真实数据) - TicketDetailPage 审批流程时间线展示 - TicketsPage 工单统计按用户过滤+关联列(我提交的/待我审批) - types 新增 TicketApproval 等类型定义
546 lines
19 KiB
Python
546 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 — always filter by uid to avoid matching wrong user
|
|
base_filter = ldap.search_filter or "(objectClass=inetOrgPerson)"
|
|
if "%s" in base_filter:
|
|
user_filter = base_filter.replace("%s", data.test_user)
|
|
else:
|
|
# search_filter has no placeholder, append uid condition
|
|
user_filter = f"(&{base_filter}(uid={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)}")
|