feat(ldap): 完善 LDAP 认证功能和安全性

- 添加 LDAP 配置类型定义,移除 any 类型
- 首次配置 LDAP 时强制要求设置绑定密码
- 根据认证类型区分登录标识验证(本地需邮箱,LDAP 允许用户名)
- 添加 LDAP 过滤器转义函数防止注入攻击
- 增加 LDAP 连接超时设置
- 添加账户来源冲突检查,防止 LDAP 覆盖本地账户
- 添加用户名冲突自动重命名机制
This commit is contained in:
RWDai
2026-01-04 11:18:28 +08:00
parent 612992fa1f
commit 64bfa955f4
8 changed files with 2147 additions and 2020 deletions

View File

@@ -155,6 +155,41 @@ export interface EmailTemplateResetResponse {
} }
} }
// LDAP 配置响应
export interface LdapConfigResponse {
server_url: string | null
bind_dn: string | null
base_dn: string | null
user_search_filter: string
username_attr: string
email_attr: string
display_name_attr: string
is_enabled: boolean
is_exclusive: boolean
use_starttls: boolean
}
// LDAP 配置更新请求
export interface LdapConfigUpdateRequest {
server_url: string
bind_dn: string
bind_password?: string
base_dn: string
user_search_filter?: string
username_attr?: string
email_attr?: string
display_name_attr?: string
is_enabled?: boolean
is_exclusive?: boolean
use_starttls?: boolean
}
// LDAP 连接测试响应
export interface LdapTestResponse {
success: boolean
message: string
}
// Provider 模型查询响应 // Provider 模型查询响应
export interface ProviderModelsQueryResponse { export interface ProviderModelsQueryResponse {
success: boolean success: boolean
@@ -477,13 +512,13 @@ export const adminApi = {
// LDAP 配置相关 // LDAP 配置相关
// 获取 LDAP 配置 // 获取 LDAP 配置
async getLdapConfig(): Promise<any> { async getLdapConfig(): Promise<LdapConfigResponse> {
const response = await apiClient.get<any>('/api/admin/ldap/config') const response = await apiClient.get<LdapConfigResponse>('/api/admin/ldap/config')
return response.data return response.data
}, },
// 更新 LDAP 配置 // 更新 LDAP 配置
async updateLdapConfig(config: any): Promise<{ message: string }> { async updateLdapConfig(config: LdapConfigUpdateRequest): Promise<{ message: string }> {
const response = await apiClient.put<{ message: string }>( const response = await apiClient.put<{ message: string }>(
'/api/admin/ldap/config', '/api/admin/ldap/config',
config config
@@ -492,10 +527,10 @@ export const adminApi = {
}, },
// 测试 LDAP 连接 // 测试 LDAP 连接
async testLdapConnection(config?: any): Promise<{ success: boolean; message: string }> { async testLdapConnection(): Promise<LdapTestResponse> {
const response = await apiClient.post<{ success: boolean; message: string }>( const response = await apiClient.post<LdapTestResponse>(
'/api/admin/ldap/test', '/api/admin/ldap/test',
config || {} {}
) )
return response.data return response.data
} }

View File

@@ -200,7 +200,7 @@ import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useLogger } from '@/composables/useLogger' import { useLogger } from '@/composables/useLogger'
import { adminApi } from '@/api/admin' import { adminApi, type LdapConfigUpdateRequest } from '@/api/admin'
const { success, error } = useToast() const { success, error } = useToast()
const log = useLogger('LdapSettings') const log = useLogger('LdapSettings')
@@ -257,7 +257,7 @@ async function loadConfig() {
async function handleSave() { async function handleSave() {
saveLoading.value = true saveLoading.value = true
try { try {
const payload: Record<string, unknown> = { const payload: LdapConfigUpdateRequest = {
server_url: ldapConfig.value.server_url, server_url: ldapConfig.value.server_url,
bind_dn: ldapConfig.value.bind_dn, bind_dn: ldapConfig.value.bind_dn,
base_dn: ldapConfig.value.base_dn, base_dn: ldapConfig.value.base_dn,
@@ -268,9 +268,7 @@ async function handleSave() {
is_enabled: ldapConfig.value.is_enabled, is_enabled: ldapConfig.value.is_enabled,
is_exclusive: ldapConfig.value.is_exclusive, is_exclusive: ldapConfig.value.is_exclusive,
use_starttls: ldapConfig.value.use_starttls, use_starttls: ldapConfig.value.use_starttls,
} ...(ldapConfig.value.bind_password && { bind_password: ldapConfig.value.bind_password }),
if (ldapConfig.value.bind_password) {
payload.bind_password = ldapConfig.value.bind_password
} }
await adminApi.updateLdapConfig(payload) await adminApi.updateLdapConfig(payload)
success('LDAP 配置保存成功') success('LDAP 配置保存成功')

View File

@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
commit_id: COMMIT_ID commit_id: COMMIT_ID
__commit_id__: COMMIT_ID __commit_id__: COMMIT_ID
__version__ = version = '0.1.1.dev0+g393d4d13f.d20251213' __version__ = version = '0.2.1.dev3+g612992fa1.d20260104'
__version_tuple__ = version_tuple = (0, 1, 1, 'dev0', 'g393d4d13f.d20251213') __version_tuple__ = version_tuple = (0, 2, 1, 'dev3', 'g612992fa1.d20260104')
__commit_id__ = commit_id = None __commit_id__ = commit_id = None

View File

@@ -132,8 +132,12 @@ class AdminUpdateLDAPConfigAdapter(AdminApiAdapter):
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
config = db.query(LDAPConfig).first() config = db.query(LDAPConfig).first()
is_new_config = config is None
if not config: if is_new_config:
# 首次创建配置时必须提供密码
if not config_update.bind_password:
raise InvalidRequestException("首次配置 LDAP 时必须设置绑定密码")
config = LDAPConfig() config = LDAPConfig()
db.add(config) db.add(config)

View File

@@ -6,7 +6,7 @@ import re
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Literal, Optional from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ..core.enums import UserRole from ..core.enums import UserRole
@@ -15,19 +15,10 @@ from ..core.enums import UserRole
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
"""登录请求""" """登录请求"""
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址") email: str = Field(..., min_length=1, max_length=255, description="邮箱/用户名")
password: str = Field(..., min_length=1, max_length=128, description="密码") password: str = Field(..., min_length=1, max_length=128, description="密码")
auth_type: Literal["local", "ldap"] = Field(default="local", description="认证类型") auth_type: Literal["local", "ldap"] = Field(default="local", description="认证类型")
@classmethod
@field_validator("email")
def validate_email(cls, v):
"""验证邮箱格式"""
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, v):
raise ValueError("邮箱格式无效")
return v.lower()
@classmethod @classmethod
@field_validator("password") @field_validator("password")
def validate_password(cls, v): def validate_password(cls, v):
@@ -37,6 +28,23 @@ class LoginRequest(BaseModel):
raise ValueError("密码不能为空") raise ValueError("密码不能为空")
return v return v
@model_validator(mode="after")
def validate_login(self):
"""根据认证类型校验并规范化登录标识"""
identifier = self.email.strip()
if self.auth_type == "local":
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, identifier):
raise ValueError("邮箱格式无效")
self.email = identifier.lower()
else:
if not identifier:
raise ValueError("用户名/邮箱不能为空")
self.email = identifier
return self
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
"""登录响应""" """登录响应"""

View File

@@ -7,6 +7,32 @@ from sqlalchemy.orm import Session
from src.core.logger import logger from src.core.logger import logger
from src.models.database import LDAPConfig from src.models.database import LDAPConfig
# LDAP 连接超时时间(秒)
LDAP_CONNECT_TIMEOUT = 10
def escape_ldap_filter(value: str) -> str:
"""
转义 LDAP 过滤器中的特殊字符,防止 LDAP 注入攻击
Args:
value: 需要转义的字符串
Returns:
转义后的安全字符串
"""
# LDAP 过滤器特殊字符: \ * ( ) NUL
escape_chars = {
"\\": r"\5c",
"*": r"\2a",
"(": r"\28",
")": r"\29",
"\x00": r"\00",
}
for char, escaped in escape_chars.items():
value = value.replace(char, escaped)
return value
class LDAPService: class LDAPService:
"""LDAP 认证服务""" """LDAP 认证服务"""
@@ -57,7 +83,12 @@ class LDAPService:
try: try:
# 创建服务器连接 # 创建服务器连接
use_ssl = config.server_url.startswith("ldaps://") use_ssl = config.server_url.startswith("ldaps://")
server = Server(config.server_url, use_ssl=use_ssl, get_info=ldap3.ALL) server = Server(
config.server_url,
use_ssl=use_ssl,
get_info=ldap3.ALL,
connect_timeout=LDAP_CONNECT_TIMEOUT,
)
# 使用管理员账号连接 # 使用管理员账号连接
bind_password = config.get_bind_password() bind_password = config.get_bind_password()
@@ -70,8 +101,9 @@ class LDAPService:
logger.error(f"LDAP 管理员绑定失败: {admin_conn.result}") logger.error(f"LDAP 管理员绑定失败: {admin_conn.result}")
return None return None
# 搜索用户 # 搜索用户(转义用户名防止 LDAP 注入)
search_filter = config.user_search_filter.replace("{username}", username) safe_username = escape_ldap_filter(username)
search_filter = config.user_search_filter.replace("{username}", safe_username)
admin_conn.search( admin_conn.search(
search_base=config.base_dn, search_base=config.base_dn,
search_filter=search_filter, search_filter=search_filter,
@@ -140,7 +172,12 @@ class LDAPService:
try: try:
use_ssl = config.server_url.startswith("ldaps://") use_ssl = config.server_url.startswith("ldaps://")
server = Server(config.server_url, use_ssl=use_ssl, get_info=ldap3.ALL) server = Server(
config.server_url,
use_ssl=use_ssl,
get_info=ldap3.ALL,
connect_timeout=LDAP_CONNECT_TIMEOUT,
)
bind_password = config.get_bind_password() bind_password = config.get_bind_password()
conn = Connection(server, user=config.bind_dn, password=bind_password) conn = Connection(server, user=config.bind_dn, password=bind_password)

View File

@@ -117,6 +117,12 @@ class AuthService:
# 获取或创建本地用户 # 获取或创建本地用户
user = await AuthService._get_or_create_ldap_user(db, ldap_user) user = await AuthService._get_or_create_ldap_user(db, ldap_user)
if not user:
# 已有本地账号但来源不匹配等情况
return None
if not user.is_active:
logger.warning(f"登录失败 - 用户已禁用: {email}")
return None
return user return user
# 本地认证 # 本地认证
@@ -154,7 +160,7 @@ class AuthService:
return user return user
@staticmethod @staticmethod
async def _get_or_create_ldap_user(db: Session, ldap_user: dict) -> User: async def _get_or_create_ldap_user(db: Session, ldap_user: dict) -> Optional[User]:
"""获取或创建 LDAP 用户 """获取或创建 LDAP 用户
Args: Args:
@@ -166,17 +172,33 @@ class AuthService:
if user: if user:
# 更新 auth_source如果是首次 LDAP 登录) # 更新 auth_source如果是首次 LDAP 登录)
if user.auth_source != AuthSource.LDAP: if user.auth_source != AuthSource.LDAP:
user.auth_source = AuthSource.LDAP # 避免覆盖已有本地账户(不同来源时拒绝登录)
logger.warning(
f"LDAP 登录拒绝 - 账户来源不匹配(现有:{user.auth_source}, 请求:LDAP): {ldap_user['email']}"
)
return None
user.last_login_at = datetime.now(timezone.utc) user.last_login_at = datetime.now(timezone.utc)
db.commit() db.commit()
await UserCacheService.invalidate_user_cache(user.id, user.email) await UserCacheService.invalidate_user_cache(user.id, user.email)
logger.info(f"LDAP 用户登录成功: {ldap_user['email']} (ID: {user.id})") logger.info(f"LDAP 用户登录成功: {ldap_user['email']} (ID: {user.id})")
return user return user
# 检查 username 是否已被占用
username = ldap_user["username"]
existing_user_with_username = db.query(User).filter(User.username == username).first()
if existing_user_with_username:
# 如果 username 已存在,添加后缀使其唯一
base_username = username
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{base_username}_ldap_{counter}"
counter += 1
logger.info(f"LDAP 用户名冲突,使用新用户名: {base_username} -> {username}")
# 创建新用户 # 创建新用户
user = User( user = User(
email=ldap_user["email"], email=ldap_user["email"],
username=ldap_user["username"], username=username,
password_hash="", # LDAP 用户无本地密码 password_hash="", # LDAP 用户无本地密码
auth_source=AuthSource.LDAP, auth_source=AuthSource.LDAP,
role=UserRole.USER, role=UserRole.USER,

3999
uv.lock generated

File diff suppressed because it is too large Load Diff