mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-08 02:32:27 +08:00
428 lines
17 KiB
Python
428 lines
17 KiB
Python
"""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配置(管理员)"""
|
||
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,
|
||
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()
|