Files
Aether/src/api/admin/ldap.py

428 lines
17 KiB
Python
Raw Normal View History

2026-01-02 16:17:24 +08:00
"""LDAP配置管理API端点。"""
import re
2026-01-02 16:17:24 +08:00
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field, ValidationError, field_validator
2026-01-02 16:17:24 +08:00
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.pipeline import ApiRequestPipeline
from src.core.crypto import crypto_service
from src.core.enums import AuthSource
2026-01-02 16:17:24 +08:00
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
from src.core.logger import logger
2026-01-02 16:17:24 +08:00
from src.database import get_db
from src.models.database import AuditEventType, LDAPConfig, User, UserRole
from src.services.system.audit import AuditService
2026-01-02 16:17:24 +08:00
router = APIRouter(prefix="/api/admin/ldap", tags=["Admin - LDAP"])
pipeline = ApiRequestPipeline()
# bcrypt 哈希格式正则:$2a$, $2b$, $2y$ + 2位cost + $ + 53字符(22位salt + 31位hash)
BCRYPT_HASH_PATTERN = re.compile(r"^\$2[aby]\$\d{2}\$.{53}$")
2026-01-02 16:17:24 +08:00
# ========== Request/Response Models ==========
class LDAPConfigResponse(BaseModel):
"""LDAP配置响应不返回密码"""
server_url: Optional[str] = None
bind_dn: Optional[str] = None
base_dn: Optional[str] = None
has_bind_password: bool = False
2026-01-02 16:17:24 +08:00
user_search_filter: str
username_attr: str
email_attr: str
display_name_attr: str
is_enabled: bool
is_exclusive: bool
use_starttls: bool
2026-01-04 16:27:02 +08:00
connect_timeout: int
2026-01-02 16:17:24 +08:00
class LDAPConfigUpdate(BaseModel):
"""LDAP配置更新请求"""
server_url: str = Field(..., min_length=1, max_length=255)
bind_dn: str = Field(..., min_length=1, max_length=255)
# 允许空字符串表示"清除密码";非空时自动 strip 并校验不能为空
bind_password: Optional[str] = Field(None, max_length=1024)
2026-01-02 16:17:24 +08:00
base_dn: str = Field(..., min_length=1, max_length=255)
user_search_filter: str = Field(default="(uid={username})", max_length=500)
username_attr: str = Field(default="uid", max_length=50)
email_attr: str = Field(default="mail", max_length=50)
display_name_attr: str = Field(default="cn", max_length=50)
is_enabled: bool = False
is_exclusive: bool = False
use_starttls: bool = False
connect_timeout: int = Field(default=10, ge=1, le=60) # 单次操作超时,跨国网络建议 15-30 秒
@field_validator("bind_password")
@classmethod
def validate_bind_password(cls, v: Optional[str]) -> Optional[str]:
if v is None or v == "":
return v
v = v.strip()
if not v:
raise ValueError("绑定密码不能为空")
return v
@field_validator("user_search_filter")
@classmethod
def validate_search_filter(cls, v: str) -> str:
if "{username}" not in v:
raise ValueError("搜索过滤器必须包含 {username} 占位符")
# 验证括号匹配和嵌套正确性
depth = 0
for char in v:
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth < 0:
raise ValueError("搜索过滤器括号不匹配")
if depth != 0:
raise ValueError("搜索过滤器括号不匹配")
# 限制过滤器复杂度,防止构造复杂查询
# 检查嵌套层数而非括号总数
depth = 0
max_depth = 0
for char in v:
if char == "(":
depth += 1
max_depth = max(max_depth, depth)
elif char == ")":
depth -= 1
if max_depth > 5:
raise ValueError("搜索过滤器嵌套层数过深最多5层")
if len(v) > 200:
raise ValueError("搜索过滤器过长最多200字符")
return v
2026-01-02 16:17:24 +08:00
class LDAPTestResponse(BaseModel):
"""LDAP连接测试响应"""
success: bool
message: str
2026-01-04 16:27:02 +08:00
class LDAPConfigTest(BaseModel):
"""LDAP配置测试请求全部可选用于临时覆盖"""
server_url: Optional[str] = Field(None, min_length=1, max_length=255)
bind_dn: Optional[str] = Field(None, min_length=1, max_length=255)
bind_password: Optional[str] = Field(None, min_length=1)
base_dn: Optional[str] = Field(None, min_length=1, max_length=255)
user_search_filter: Optional[str] = Field(None, max_length=500)
username_attr: Optional[str] = Field(None, max_length=50)
email_attr: Optional[str] = Field(None, max_length=50)
display_name_attr: Optional[str] = Field(None, max_length=50)
is_enabled: Optional[bool] = None
is_exclusive: Optional[bool] = None
use_starttls: Optional[bool] = None
connect_timeout: Optional[int] = Field(None, ge=1, le=60)
@field_validator("user_search_filter")
@classmethod
def validate_search_filter(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
if "{username}" not in v:
raise ValueError("搜索过滤器必须包含 {username} 占位符")
# 验证括号匹配和嵌套正确性
depth = 0
for char in v:
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth < 0:
raise ValueError("搜索过滤器括号不匹配")
if depth != 0:
raise ValueError("搜索过滤器括号不匹配")
# 限制过滤器复杂度(检查嵌套层数而非括号总数)
depth = 0
max_depth = 0
for char in v:
if char == "(":
depth += 1
max_depth = max(max_depth, depth)
elif char == ")":
depth -= 1
if max_depth > 5:
raise ValueError("搜索过滤器嵌套层数过深最多5层")
if len(v) > 200:
raise ValueError("搜索过滤器过长最多200字符")
return v
2026-01-04 16:27:02 +08:00
2026-01-02 16:17:24 +08:00
# ========== API Endpoints ==========
@router.get("/config")
async def get_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
"""获取LDAP配置管理员"""
adapter = AdminGetLDAPConfigAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/config")
async def update_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
"""更新LDAP配置管理员"""
adapter = AdminUpdateLDAPConfigAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/test")
async def test_ldap_connection(request: Request, db: Session = Depends(get_db)) -> Any:
"""测试LDAP连接管理员"""
adapter = AdminTestLDAPConnectionAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ========== Adapters ==========
class AdminGetLDAPConfigAdapter(AdminApiAdapter):
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
db = context.db
config = db.query(LDAPConfig).first()
if not config:
return LDAPConfigResponse(
server_url=None,
bind_dn=None,
base_dn=None,
has_bind_password=False,
2026-01-02 16:17:24 +08:00
user_search_filter="(uid={username})",
username_attr="uid",
email_attr="mail",
display_name_attr="cn",
is_enabled=False,
is_exclusive=False,
use_starttls=False,
2026-01-04 16:27:02 +08:00
connect_timeout=10,
2026-01-02 16:17:24 +08:00
).model_dump()
return LDAPConfigResponse(
server_url=config.server_url,
bind_dn=config.bind_dn,
base_dn=config.base_dn,
has_bind_password=bool(config.bind_password_encrypted),
2026-01-02 16:17:24 +08:00
user_search_filter=config.user_search_filter,
username_attr=config.username_attr,
email_attr=config.email_attr,
display_name_attr=config.display_name_attr,
is_enabled=config.is_enabled,
is_exclusive=config.is_exclusive,
use_starttls=config.use_starttls,
2026-01-04 16:27:02 +08:00
connect_timeout=config.connect_timeout,
2026-01-02 16:17:24 +08:00
).model_dump()
class AdminUpdateLDAPConfigAdapter(AdminApiAdapter):
async def handle(self, context) -> Dict[str, str]: # type: ignore[override]
db = context.db
payload = context.ensure_json_body()
try:
config_update = LDAPConfigUpdate.model_validate(payload)
except ValidationError as e:
errors = e.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
# 使用行级锁防止并发修改导致的竞态条件
config = db.query(LDAPConfig).with_for_update().first()
is_new_config = config is None
2026-01-02 16:17:24 +08:00
if is_new_config:
# 首次创建配置时必须提供密码
if not config_update.bind_password:
raise InvalidRequestException("首次配置 LDAP 时必须设置绑定密码")
2026-01-02 16:17:24 +08:00
config = LDAPConfig()
db.add(config)
# 需要启用 LDAP 且未提交新密码时,验证已保存密码可解密(避免开启后不可用)
if config_update.is_enabled and config_update.bind_password is None:
try:
if not config.get_bind_password():
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
except InvalidRequestException:
raise
except Exception:
raise InvalidRequestException("绑定密码解密失败,请重新设置绑定密码")
# 计算更新后的密码状态(用于校验是否可启用/独占)
if config_update.bind_password is None:
will_have_password = bool(config.bind_password_encrypted)
elif config_update.bind_password == "":
will_have_password = False
else:
will_have_password = True
# 独占模式必须启用 LDAP 且必须有绑定密码(防止误锁定)
if config_update.is_exclusive and not config_update.is_enabled:
raise InvalidRequestException("仅允许 LDAP 登录 需要先启用 LDAP 认证")
if config_update.is_enabled and not will_have_password:
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
if config_update.is_exclusive and not will_have_password:
raise InvalidRequestException("仅允许 LDAP 登录 需要先设置绑定密码")
2026-01-02 16:17:24 +08:00
config.server_url = config_update.server_url
config.bind_dn = config_update.bind_dn
config.base_dn = config_update.base_dn
config.user_search_filter = config_update.user_search_filter
config.username_attr = config_update.username_attr
config.email_attr = config_update.email_attr
config.display_name_attr = config_update.display_name_attr
config.is_enabled = config_update.is_enabled
config.is_exclusive = config_update.is_exclusive
config.use_starttls = config_update.use_starttls
2026-01-04 16:27:02 +08:00
config.connect_timeout = config_update.connect_timeout
2026-01-02 16:17:24 +08:00
# 启用独占模式前检查是否有足够的本地管理员(防止锁定)
# 使用 with_for_update() 阻塞锁防止竞态条件(移除 nowait 确保并发安全)
if config_update.is_enabled and config_update.is_exclusive:
local_admins = (
db.query(User)
.filter(
User.role == UserRole.ADMIN,
User.auth_source == AuthSource.LOCAL,
User.is_active.is_(True),
User.is_deleted.is_(False),
)
.with_for_update()
.all()
)
# 验证至少有一个管理员有有效的密码哈希(可以登录)
# 使用严格的 bcrypt 格式校验:$2a$/$2b$/$2y$ + 2位cost + $ + 53字符
valid_admin_count = sum(
1
for admin in local_admins
if admin.password_hash
and isinstance(admin.password_hash, str)
and BCRYPT_HASH_PATTERN.match(admin.password_hash)
)
if valid_admin_count < 1:
raise InvalidRequestException(
"启用 LDAP 独占模式前,必须至少保留 1 个有效的本地管理员账户(含有效密码)作为紧急恢复通道"
)
if config_update.bind_password is not None:
if config_update.bind_password == "":
# 显式清除密码(设置为 NULL
config.bind_password_encrypted = None
password_changed = "cleared"
else:
config.bind_password_encrypted = crypto_service.encrypt(config_update.bind_password)
password_changed = "updated"
else:
password_changed = None
2026-01-02 16:17:24 +08:00
db.commit()
# 记录审计日志
AuditService.log_event(
db=db,
event_type=AuditEventType.CONFIG_CHANGED,
description=f"LDAP 配置已更新 (enabled={config_update.is_enabled}, exclusive={config_update.is_exclusive})",
user_id=str(context.user.id) if context.user else None,
metadata={
"server_url": config_update.server_url,
"is_enabled": config_update.is_enabled,
"is_exclusive": config_update.is_exclusive,
"password_changed": password_changed,
"is_new_config": is_new_config,
},
)
2026-01-02 16:17:24 +08:00
return {"message": "LDAP配置更新成功"}
class AdminTestLDAPConnectionAdapter(AdminApiAdapter):
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
2026-01-04 16:27:02 +08:00
from src.services.auth.ldap import LDAPService
2026-01-02 16:17:24 +08:00
db = context.db
2026-01-04 16:27:02 +08:00
if context.json_body is not None:
payload = context.json_body
elif not context.raw_body:
payload = {}
else:
payload = context.ensure_json_body()
2026-01-02 16:17:24 +08:00
2026-01-04 16:27:02 +08:00
saved_config = db.query(LDAPConfig).first()
2026-01-02 16:17:24 +08:00
try:
2026-01-04 16:27:02 +08:00
overrides = LDAPConfigTest.model_validate(payload)
except ValidationError as e:
errors = e.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
2026-01-02 16:17:24 +08:00
2026-01-04 16:27:02 +08:00
config_data: Dict[str, Any] = {}
if saved_config:
config_data = {
"server_url": saved_config.server_url,
"bind_dn": saved_config.bind_dn,
"base_dn": saved_config.base_dn,
"user_search_filter": saved_config.user_search_filter,
"username_attr": saved_config.username_attr,
"email_attr": saved_config.email_attr,
"display_name_attr": saved_config.display_name_attr,
"use_starttls": saved_config.use_starttls,
"connect_timeout": saved_config.connect_timeout,
}
2026-01-02 16:17:24 +08:00
2026-01-04 16:27:02 +08:00
# 应用前端传入的覆盖值
for field in [
"server_url",
"bind_dn",
"base_dn",
"user_search_filter",
"username_attr",
"email_attr",
"display_name_attr",
"use_starttls",
"is_enabled",
"is_exclusive",
"connect_timeout",
]:
value = getattr(overrides, field)
if value is not None:
config_data[field] = value
# bind_password 优先使用 overrides否则使用已保存的密码允许保存密码无法解密时依然用 overrides 测试)
if overrides.bind_password is not None:
2026-01-04 16:27:02 +08:00
config_data["bind_password"] = overrides.bind_password
elif saved_config and saved_config.bind_password_encrypted:
try:
config_data["bind_password"] = crypto_service.decrypt(
saved_config.bind_password_encrypted
)
except Exception as e:
logger.error(f"绑定密码解密失败: {type(e).__name__}: {e}")
return LDAPTestResponse(
success=False, message="绑定密码解密失败,请检查配置或重新设置密码"
).model_dump()
2026-01-04 16:27:02 +08:00
# 必填字段检查
required_fields = ["server_url", "bind_dn", "base_dn", "bind_password"]
missing = [f for f in required_fields if not config_data.get(f)]
if missing:
return LDAPTestResponse(
success=False, message=f"缺少必要字段: {', '.join(missing)}"
).model_dump()
2026-01-02 16:17:24 +08:00
2026-01-04 16:27:02 +08:00
success, message = LDAPService.test_connection_with_config(config_data)
return LDAPTestResponse(success=success, message=message).model_dump()