Files
Aether/src/api/admin/ldap.py
fawney19 0061fc04b7 feat: 添加访问令牌管理功能并升级至 0.2.4
- 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理
- 前端添加访问令牌管理页面,支持普通用户和管理员
- 后端实现完整的令牌生命周期管理 API
- 添加数据库迁移脚本创建 management_tokens 表
- Nginx 配置添加 gzip 压缩,优化响应传输
- Dialog 组件添加 persistent 属性,防止意外关闭
- 为管理后台 API 添加详细的中文文档注释
- 简化多处类型注解,统一代码风格
2026-01-07 14:55:07 +08:00

502 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""LDAP配置管理API端点。"""
import re
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field, ValidationError, field_validator
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
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
from src.core.logger import logger
from src.database import get_db
from src.models.database import AuditEventType, LDAPConfig, User, UserRole
from src.services.system.audit import AuditService
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}$")
# ========== 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
user_search_filter: str
username_attr: str
email_attr: str
display_name_attr: str
is_enabled: bool
is_exclusive: bool
use_starttls: bool
connect_timeout: int
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)
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
class LDAPTestResponse(BaseModel):
"""LDAP连接测试响应"""
success: bool
message: str
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
# ========== API Endpoints ==========
@router.get("/config")
async def get_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
"""
获取 LDAP 配置
获取系统当前的 LDAP 认证配置信息,用于管理界面显示和编辑。
密码字段不会返回原文,仅返回是否已设置的标志。
**返回字段**:
- `server_url`: LDAP 服务器地址ldap://ldap.example.com:389
- `bind_dn`: 绑定 DNcn=admin,dc=example,dc=com
- `base_dn`: 搜索基准 DNou=users,dc=example,dc=com
- `has_bind_password`: 是否已设置绑定密码(布尔值)
- `user_search_filter`: 用户搜索过滤器(默认:(uid={username})
- `username_attr`: 用户名属性默认uid
- `email_attr`: 邮箱属性默认mail
- `display_name_attr`: 显示名称属性默认cn
- `is_enabled`: 是否启用 LDAP 认证
- `is_exclusive`: 是否仅允许 LDAP 登录(独占模式)
- `use_starttls`: 是否使用 STARTTLS 加密连接
- `connect_timeout`: 连接超时时间1-60
"""
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 配置
更新系统的 LDAP 认证配置。支持完整配置更新,包括连接参数、
搜索过滤器、属性映射等。提供多重安全校验,防止误锁定管理员。
**请求体字段**:
- `server_url`: LDAP 服务器地址必填1-255字符
- `bind_dn`: 绑定 DN必填1-255字符
- `bind_password`: 绑定密码(可选,设为空字符串可清除密码)
- `base_dn`: 搜索基准 DN必填1-255字符
- `user_search_filter`: 用户搜索过滤器(必须包含 {username} 占位符,默认:(uid={username})
- `username_attr`: 用户名属性默认uid
- `email_attr`: 邮箱属性默认mail
- `display_name_attr`: 显示名称属性默认cn
- `is_enabled`: 是否启用 LDAP 认证
- `is_exclusive`: 是否仅允许 LDAP 登录(需先启用 LDAP
- `use_starttls`: 是否使用 STARTTLS 加密连接
- `connect_timeout`: 连接超时时间1-60默认 10
**安全校验**:
- 启用 LDAP 时必须设置有效的绑定密码
- 启用独占模式前会检查是否有至少 1 个有效的本地管理员账户
- 独占模式要求先启用 LDAP 认证
- 搜索过滤器必须包含 {username} 占位符且括号匹配
- 搜索过滤器嵌套层数不超过 5 层,长度不超过 200 字符
**返回字段**:
- `message`: 操作结果消息
"""
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 连接
在保存配置前测试 LDAP 服务器连接是否正常。支持使用已保存的配置,
也支持通过请求体覆盖任意配置项进行临时测试,而不影响已保存的配置。
**请求体字段**(均为可选,用于临时覆盖):
- `server_url`: LDAP 服务器地址(覆盖已保存的配置)
- `bind_dn`: 绑定 DN覆盖已保存的配置
- `bind_password`: 绑定密码(覆盖已保存的密码)
- `base_dn`: 搜索基准 DN覆盖已保存的配置
- `user_search_filter`: 用户搜索过滤器(覆盖已保存的配置)
- `username_attr`: 用户名属性(覆盖已保存的配置)
- `email_attr`: 邮箱属性(覆盖已保存的配置)
- `display_name_attr`: 显示名称属性(覆盖已保存的配置)
- `use_starttls`: 是否使用 STARTTLS覆盖已保存的配置
- `connect_timeout`: 连接超时时间(覆盖已保存的配置)
**测试逻辑**:
- 未提供的字段使用已保存的配置值
- `bind_password` 优先使用请求体中的值,否则使用已保存的加密密码
- 测试时会尝试连接 LDAP 服务器并验证绑定 DN
**返回字段**:
- `success`: 测试是否成功(布尔值)
- `message`: 测试结果消息(成功或失败原因)
"""
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,
user_search_filter="(uid={username})",
username_attr="uid",
email_attr="mail",
display_name_attr="cn",
is_enabled=False,
is_exclusive=False,
use_starttls=False,
connect_timeout=10,
).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),
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,
connect_timeout=config.connect_timeout,
).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
if is_new_config:
# 首次创建配置时必须提供密码
if not config_update.bind_password:
raise InvalidRequestException("首次配置 LDAP 时必须设置绑定密码")
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 登录 需要先设置绑定密码")
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
config.connect_timeout = config_update.connect_timeout
# 启用独占模式前检查是否有足够的本地管理员(防止锁定)
# 使用 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
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,
},
)
return {"message": "LDAP配置更新成功"}
class AdminTestLDAPConnectionAdapter(AdminApiAdapter):
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
from src.services.auth.ldap import LDAPService
db = context.db
if context.json_body is not None:
payload = context.json_body
elif not context.raw_body:
payload = {}
else:
payload = context.ensure_json_body()
saved_config = db.query(LDAPConfig).first()
try:
overrides = LDAPConfigTest.model_validate(payload)
except ValidationError as e:
errors = e.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
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,
}
# 应用前端传入的覆盖值
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:
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()
# 必填字段检查
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()
success, message = LDAPService.test_connection_with_config(config_data)
return LDAPTestResponse(success=success, message=message).model_dump()