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)}")