feat: add ldap login

This commit is contained in:
RWDai
2026-01-02 16:17:24 +08:00
parent cddc22d2b3
commit 9bfb295238
18 changed files with 1007 additions and 18 deletions

View File

@@ -51,7 +51,7 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
```bash ```bash
# 1. 克隆代码 # 1. 克隆代码
git clone https://github.com/fawney19/Aether.git git clone https://github.com/fawney19/Aether.git
cd aether cd Aether
# 2. 配置环境变量 # 2. 配置环境变量
cp .env.example .env cp .env.example .env
@@ -72,7 +72,7 @@ docker-compose pull && docker-compose up -d && ./migrate.sh
```bash ```bash
# 1. 克隆代码 # 1. 克隆代码
git clone https://github.com/fawney19/Aether.git git clone https://github.com/fawney19/Aether.git
cd aether cd Aether
# 2. 配置环境变量 # 2. 配置环境变量
cp .env.example .env cp .env.example .env

View File

@@ -0,0 +1,65 @@
"""add ldap authentication support
Revision ID: c3d4e5f6g7h8
Revises: b2c3d4e5f6g7
Create Date: 2026-01-01 14:00:00.000000+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'c3d4e5f6g7h8'
down_revision = 'b2c3d4e5f6g7'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 LDAP 认证支持
1. 创建 authsource 枚举类型
2. 在 users 表添加 auth_source 字段
3. 创建 ldap_configs 表
"""
conn = op.get_bind()
# 1. 创建 authsource 枚举类型
conn.execute(text("CREATE TYPE authsource AS ENUM ('local', 'ldap')"))
# 2. 在 users 表添加 auth_source 字段
op.add_column('users', sa.Column('auth_source', sa.Enum('local', 'ldap', name='authsource', create_type=False), nullable=False, server_default='local'))
# 3. 创建 ldap_configs 表
op.create_table(
'ldap_configs',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('server_url', sa.String(length=255), nullable=False),
sa.Column('bind_dn', sa.String(length=255), nullable=False),
sa.Column('bind_password_encrypted', sa.Text(), nullable=False),
sa.Column('base_dn', sa.String(length=255), nullable=False),
sa.Column('user_search_filter', sa.String(length=500), nullable=False, server_default='(uid={username})'),
sa.Column('username_attr', sa.String(length=50), nullable=False, server_default='uid'),
sa.Column('email_attr', sa.String(length=50), nullable=False, server_default='mail'),
sa.Column('display_name_attr', sa.String(length=50), nullable=False, server_default='cn'),
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('is_exclusive', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('use_starttls', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
"""回滚 LDAP 认证支持"""
# 1. 删除 ldap_configs 表
op.drop_table('ldap_configs')
# 2. 删除 users 表的 auth_source 字段
op.drop_column('users', 'auth_source')
# 3. 删除 authsource 枚举类型
conn = op.get_bind()
conn.execute(text("DROP TYPE authsource"))

View File

@@ -473,5 +473,30 @@ export const adminApi = {
`/api/admin/system/email/templates/${templateType}/reset` `/api/admin/system/email/templates/${templateType}/reset`
) )
return response.data return response.data
},
// LDAP 配置相关
// 获取 LDAP 配置
async getLdapConfig(): Promise<any> {
const response = await apiClient.get<any>('/api/admin/ldap/config')
return response.data
},
// 更新 LDAP 配置
async updateLdapConfig(config: any): Promise<{ message: string }> {
const response = await apiClient.put<{ message: string }>(
'/api/admin/ldap/config',
config
)
return response.data
},
// 测试 LDAP 连接
async testLdapConnection(config?: any): Promise<{ success: boolean; message: string }> {
const response = await apiClient.post<{ success: boolean; message: string }>(
'/api/admin/ldap/test',
config || {}
)
return response.data
} }
} }

View File

@@ -4,6 +4,7 @@ import { log } from '@/utils/logger'
export interface LoginRequest { export interface LoginRequest {
email: string email: string
password: string password: string
auth_type?: 'local' | 'ldap'
} }
export interface LoginResponse { export interface LoginResponse {
@@ -81,6 +82,12 @@ export interface RegistrationSettingsResponse {
require_email_verification: boolean require_email_verification: boolean
} }
export interface AuthSettingsResponse {
local_enabled: boolean
ldap_enabled: boolean
ldap_exclusive: boolean
}
export interface User { export interface User {
id: string // UUID id: string // UUID
username: string username: string
@@ -173,5 +180,10 @@ export const authApi = {
{ email } { email }
) )
return response.data return response.data
},
async getAuthSettings(): Promise<AuthSettingsResponse> {
const response = await apiClient.get<AuthSettingsResponse>('/api/auth/settings')
return response.data
} }
} }

View File

@@ -66,19 +66,35 @@
</div> </div>
</div> </div>
<!-- 认证方式切换 -->
<Tabs
v-if="showAuthTypeTabs"
v-model="authType"
class="w-full"
>
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="local">
本地登录
</TabsTrigger>
<TabsTrigger value="ldap">
LDAP 登录
</TabsTrigger>
</TabsList>
</Tabs>
<!-- 登录表单 --> <!-- 登录表单 -->
<form <form
class="space-y-4" class="space-y-4"
@submit.prevent="handleLogin" @submit.prevent="handleLogin"
> >
<div class="space-y-2"> <div class="space-y-2">
<Label for="login-email">邮箱</Label> <Label for="login-email">{{ emailLabel }}</Label>
<Input <Input
id="login-email" id="login-email"
v-model="form.email" v-model="form.email"
type="email" :type="authType === 'ldap' ? 'text' : 'email'"
required required
placeholder="hello@example.com" :placeholder="authType === 'ldap' ? 'username 或 email' : 'hello@example.com'"
autocomplete="off" autocomplete="off"
/> />
</div> </div>
@@ -156,6 +172,9 @@ import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue' import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue' import Label from '@/components/ui/label.vue'
import Tabs from '@/components/ui/tabs.vue'
import TabsList from '@/components/ui/tabs-list.vue'
import TabsTrigger from '@/components/ui/tabs-trigger.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo' import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
@@ -180,6 +199,20 @@ const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false) const requireEmailVerification = ref(false)
const allowRegistration = ref(false) // 由系统配置控制,默认关闭 const allowRegistration = ref(false) // 由系统配置控制,默认关闭
// LDAP authentication settings
const authType = ref<'local' | 'ldap'>('local')
const localEnabled = ref(true)
const ldapEnabled = ref(false)
const ldapExclusive = ref(false)
const showAuthTypeTabs = computed(() => {
return localEnabled.value && ldapEnabled.value && !ldapExclusive.value
})
const emailLabel = computed(() => {
return authType.value === 'ldap' ? '用户名/邮箱' : '邮箱'
})
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
isOpen.value = val isOpen.value = val
// 打开对话框时重置表单 // 打开对话框时重置表单
@@ -212,7 +245,7 @@ async function handleLogin() {
return return
} }
const success = await authStore.login(form.value.email, form.value.password) const success = await authStore.login(form.value.email, form.value.password, authType.value)
if (success) { if (success) {
showSuccess('登录成功,正在跳转...') showSuccess('登录成功,正在跳转...')
@@ -246,16 +279,36 @@ function handleSwitchToLogin() {
isOpen.value = true isOpen.value = true
} }
// Load registration settings on mount // Load authentication and registration settings on mount
onMounted(async () => { onMounted(async () => {
try { try {
const settings = await authApi.getRegistrationSettings() // Load registration settings
allowRegistration.value = !!settings.enable_registration const regSettings = await authApi.getRegistrationSettings()
requireEmailVerification.value = !!settings.require_email_verification allowRegistration.value = !!regSettings.enable_registration
requireEmailVerification.value = !!regSettings.require_email_verification
// Load authentication settings
const authSettings = await authApi.getAuthSettings()
localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled
ldapExclusive.value = authSettings.ldap_exclusive
// Set default auth type based on settings
if (authSettings.ldap_exclusive) {
authType.value = 'ldap'
} else if (!authSettings.local_enabled && authSettings.ldap_enabled) {
authType.value = 'ldap'
} else {
authType.value = 'local'
}
} catch (error) { } catch (error) {
// If获取失败保持默认关闭注册 & 关闭邮箱验证 // If获取失败保持默认关闭注册 & 关闭邮箱验证 & 使用本地认证
allowRegistration.value = false allowRegistration.value = false
requireEmailVerification.value = false requireEmailVerification.value = false
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false
authType.value = 'local'
} }
}) })
</script> </script>

View File

@@ -423,6 +423,7 @@ const navigation = computed(() => {
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield }, { name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle }, { name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
{ name: '邮件配置', href: '/admin/email', icon: Mail }, { name: '邮件配置', href: '/admin/email', icon: Mail },
{ name: 'LDAP 配置', href: '/admin/ldap', icon: Shield },
{ name: '系统设置', href: '/admin/system', icon: Cog }, { name: '系统设置', href: '/admin/system', icon: Cog },
] ]
} }

View File

@@ -111,6 +111,11 @@ const routes: RouteRecordRaw[] = [
name: 'EmailSettings', name: 'EmailSettings',
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue')) component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
}, },
{
path: 'ldap',
name: 'LdapSettings',
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue'))
},
{ {
path: 'audit-logs', path: 'audit-logs',
name: 'AuditLogs', name: 'AuditLogs',

View File

@@ -31,12 +31,12 @@ export const useAuthStore = defineStore('auth', () => {
} }
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
async function login(email: string, password: string) { async function login(email: string, password: string, authType: 'local' | 'ldap' = 'local') {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const response = await authApi.login({ email, password }) const response = await authApi.login({ email, password, auth_type: authType })
token.value = response.access_token token.value = response.access_token
// 获取用户信息 // 获取用户信息

View File

@@ -0,0 +1,303 @@
<template>
<PageContainer>
<PageHeader
title="LDAP 配置"
description="配置 LDAP 认证服务"
/>
<div class="mt-6 space-y-6">
<CardSection
title="LDAP 服务器配置"
description="配置 LDAP 服务器连接参数"
>
<template #actions>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
:disabled="testLoading"
@click="handleTestConnection"
>
{{ testLoading ? '测试中...' : '测试连接' }}
</Button>
<Button
size="sm"
:disabled="saveLoading"
@click="handleSave"
>
{{ saveLoading ? '保存中...' : '保存' }}
</Button>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label for="server-url" class="block text-sm font-medium">
服务器地址
</Label>
<Input
id="server-url"
v-model="ldapConfig.server_url"
type="text"
placeholder="ldap://ldap.example.com:389"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
格式: ldap://host:389 或 ldaps://host:636
</p>
</div>
<div>
<Label for="bind-dn" class="block text-sm font-medium">
绑定 DN
</Label>
<Input
id="bind-dn"
v-model="ldapConfig.bind_dn"
type="text"
placeholder="cn=admin,dc=example,dc=com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
用于连接 LDAP 服务器的管理员 DN
</p>
</div>
<div>
<Label for="bind-password" class="block text-sm font-medium">
绑定密码
</Label>
<Input
id="bind-password"
v-model="ldapConfig.bind_password"
type="password"
:placeholder="hasPassword ? '已设置(留空保持不变)' : '请输入密码'"
class="mt-1"
autocomplete="new-password"
/>
<p class="mt-1 text-xs text-muted-foreground">
绑定账号的密码
</p>
</div>
<div>
<Label for="base-dn" class="block text-sm font-medium">
基础 DN
</Label>
<Input
id="base-dn"
v-model="ldapConfig.base_dn"
type="text"
placeholder="ou=users,dc=example,dc=com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
用户搜索的基础 DN
</p>
</div>
<div>
<Label for="user-search-filter" class="block text-sm font-medium">
用户搜索过滤器
</Label>
<Input
id="user-search-filter"
v-model="ldapConfig.user_search_filter"
type="text"
placeholder="(uid={username})"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
{username} 会被替换为登录用户名
</p>
</div>
<div>
<Label for="username-attr" class="block text-sm font-medium">
用户名属性
</Label>
<Input
id="username-attr"
v-model="ldapConfig.username_attr"
type="text"
placeholder="uid"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
常用: uid (OpenLDAP), sAMAccountName (AD)
</p>
</div>
<div>
<Label for="email-attr" class="block text-sm font-medium">
邮箱属性
</Label>
<Input
id="email-attr"
v-model="ldapConfig.email_attr"
type="text"
placeholder="mail"
class="mt-1"
/>
</div>
<div>
<Label for="display-name-attr" class="block text-sm font-medium">
显示名称属性
</Label>
<Input
id="display-name-attr"
v-model="ldapConfig.display_name_attr"
type="text"
placeholder="cn"
class="mt-1"
/>
</div>
</div>
<div class="mt-6 space-y-4">
<div class="flex items-center justify-between">
<div>
<Label class="text-sm font-medium">使用 STARTTLS</Label>
<p class="text-xs text-muted-foreground">
在非 SSL 连接上启用 TLS 加密
</p>
</div>
<Switch v-model:checked="ldapConfig.use_starttls" />
</div>
<div class="flex items-center justify-between">
<div>
<Label class="text-sm font-medium">启用 LDAP 认证</Label>
<p class="text-xs text-muted-foreground">
允许用户使用 LDAP 账号登录
</p>
</div>
<Switch v-model:checked="ldapConfig.is_enabled" />
</div>
<div class="flex items-center justify-between">
<div>
<Label class="text-sm font-medium">仅允许 LDAP 登录</Label>
<p class="text-xs text-muted-foreground">
禁用本地账号登录仅允许 LDAP 认证
</p>
</div>
<Switch v-model:checked="ldapConfig.is_exclusive" />
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/composables/useToast'
import { useLogger } from '@/composables/useLogger'
import { adminApi } from '@/api/admin'
const { success, error } = useToast()
const log = useLogger('LdapSettings')
const loading = ref(false)
const saveLoading = ref(false)
const testLoading = ref(false)
const hasPassword = ref(false)
const ldapConfig = ref({
server_url: '',
bind_dn: '',
bind_password: '',
base_dn: '',
user_search_filter: '(uid={username})',
username_attr: 'uid',
email_attr: 'mail',
display_name_attr: 'cn',
is_enabled: false,
is_exclusive: false,
use_starttls: false,
})
onMounted(async () => {
await loadConfig()
})
async function loadConfig() {
loading.value = true
try {
const response = await adminApi.getLdapConfig()
ldapConfig.value = {
server_url: response.server_url || '',
bind_dn: response.bind_dn || '',
bind_password: '',
base_dn: response.base_dn || '',
user_search_filter: response.user_search_filter || '(uid={username})',
username_attr: response.username_attr || 'uid',
email_attr: response.email_attr || 'mail',
display_name_attr: response.display_name_attr || 'cn',
is_enabled: response.is_enabled || false,
is_exclusive: response.is_exclusive || false,
use_starttls: response.use_starttls || false,
}
hasPassword.value = !!response.server_url
} catch (err) {
error('加载 LDAP 配置失败')
log.error('加载 LDAP 配置失败:', err)
} finally {
loading.value = false
}
}
async function handleSave() {
saveLoading.value = true
try {
const payload: Record<string, unknown> = {
server_url: ldapConfig.value.server_url,
bind_dn: ldapConfig.value.bind_dn,
base_dn: ldapConfig.value.base_dn,
user_search_filter: ldapConfig.value.user_search_filter,
username_attr: ldapConfig.value.username_attr,
email_attr: ldapConfig.value.email_attr,
display_name_attr: ldapConfig.value.display_name_attr,
is_enabled: ldapConfig.value.is_enabled,
is_exclusive: ldapConfig.value.is_exclusive,
use_starttls: ldapConfig.value.use_starttls,
}
if (ldapConfig.value.bind_password) {
payload.bind_password = ldapConfig.value.bind_password
}
await adminApi.updateLdapConfig(payload)
success('LDAP 配置保存成功')
hasPassword.value = true
ldapConfig.value.bind_password = ''
} catch (err) {
error('保存 LDAP 配置失败')
log.error('保存 LDAP 配置失败:', err)
} finally {
saveLoading.value = false
}
}
async function handleTestConnection() {
testLoading.value = true
try {
const response = await adminApi.testLdapConnection()
if (response.success) {
success('LDAP 连接测试成功')
} else {
error(`LDAP 连接测试失败: ${response.message}`)
}
} catch (err) {
error('LDAP 连接测试失败')
log.error('LDAP 连接测试失败:', err)
} finally {
testLoading.value = false
}
}
</script>

View File

@@ -47,6 +47,7 @@ dependencies = [
"redis>=5.0.0", "redis>=5.0.0",
"prometheus-client>=0.20.0", "prometheus-client>=0.20.0",
"apscheduler>=3.10.0", "apscheduler>=3.10.0",
"ldap3>=2.9.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -5,6 +5,7 @@ from fastapi import APIRouter
from .adaptive import router as adaptive_router from .adaptive import router as adaptive_router
from .api_keys import router as api_keys_router from .api_keys import router as api_keys_router
from .endpoints import router as endpoints_router from .endpoints import router as endpoints_router
from .ldap import router as ldap_router
from .models import router as models_router from .models import router as models_router
from .monitoring import router as monitoring_router from .monitoring import router as monitoring_router
from .provider_query import router as provider_query_router from .provider_query import router as provider_query_router
@@ -28,5 +29,6 @@ router.include_router(adaptive_router)
router.include_router(models_router) router.include_router(models_router)
router.include_router(security_router) router.include_router(security_router)
router.include_router(provider_query_router) router.include_router(provider_query_router)
router.include_router(ldap_router)
__all__ = ["router"] __all__ = ["router"]

190
src/api/admin/ldap.py Normal file
View File

@@ -0,0 +1,190 @@
"""LDAP配置管理API端点。"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field, ValidationError
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.exceptions import InvalidRequestException, translate_pydantic_error
from src.database import get_db
from src.models.database import LDAPConfig
router = APIRouter(prefix="/api/admin/ldap", tags=["Admin - LDAP"])
pipeline = ApiRequestPipeline()
# ========== Request/Response Models ==========
class LDAPConfigResponse(BaseModel):
"""LDAP配置响应不返回密码"""
server_url: Optional[str] = None
bind_dn: Optional[str] = None
base_dn: Optional[str] = None
user_search_filter: str
username_attr: str
email_attr: str
display_name_attr: str
is_enabled: bool
is_exclusive: bool
use_starttls: bool
class LDAPConfigUpdate(BaseModel):
"""LDAP配置更新请求"""
server_url: str = Field(..., min_length=1, max_length=255)
bind_dn: str = Field(..., min_length=1, max_length=255)
bind_password: Optional[str] = Field(None, min_length=1)
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
class LDAPTestResponse(BaseModel):
"""LDAP连接测试响应"""
success: bool
message: str
# ========== 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,
user_search_filter="(uid={username})",
username_attr="uid",
email_attr="mail",
display_name_attr="cn",
is_enabled=False,
is_exclusive=False,
use_starttls=False,
).model_dump()
return LDAPConfigResponse(
server_url=config.server_url,
bind_dn=config.bind_dn,
base_dn=config.base_dn,
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,
).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).first()
if not config:
config = LDAPConfig()
db.add(config)
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
if config_update.bind_password:
config.bind_password_encrypted = crypto_service.encrypt(config_update.bind_password)
db.commit()
return {"message": "LDAP配置更新成功"}
class AdminTestLDAPConnectionAdapter(AdminApiAdapter):
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
db = context.db
config = db.query(LDAPConfig).first()
if not config:
return LDAPTestResponse(success=False, message="LDAP配置不存在").model_dump()
try:
import ldap3
bind_password = crypto_service.decrypt(config.bind_password_encrypted)
use_ssl = config.server_url.startswith("ldaps://")
server = ldap3.Server(config.server_url, use_ssl=use_ssl, get_info=ldap3.ALL)
conn = ldap3.Connection(server, user=config.bind_dn, password=bind_password)
if config.use_starttls and not use_ssl:
conn.start_tls()
if not conn.bind():
return LDAPTestResponse(
success=False, message=f"绑定失败: {conn.result}"
).model_dump()
conn.unbind()
return LDAPTestResponse(success=True, message="LDAP连接测试成功").model_dump()
except ImportError:
return LDAPTestResponse(success=False, message="ldap3库未安装").model_dump()
except Exception as e:
return LDAPTestResponse(success=False, message=f"连接失败: {str(e)}").model_dump()

View File

@@ -33,6 +33,7 @@ from src.models.api import (
) )
from src.models.database import AuditEventType, User, UserRole from src.models.database import AuditEventType, User, UserRole
from src.services.auth.service import AuthService from src.services.auth.service import AuthService
from src.services.auth.ldap import LDAPService
from src.services.rate_limit.ip_limiter import IPRateLimiter from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService from src.services.system.audit import AuditService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
@@ -99,6 +100,13 @@ async def registration_settings(request: Request, db: Session = Depends(get_db))
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/settings")
async def auth_settings(request: Request, db: Session = Depends(get_db)):
"""公开获取认证设置(用于前端判断显示哪些登录选项)"""
adapter = AuthSettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
async def login(request: Request, db: Session = Depends(get_db)): async def login(request: Request, db: Session = Depends(get_db)):
adapter = AuthLoginAdapter() adapter = AuthLoginAdapter()
@@ -193,7 +201,9 @@ class AuthLoginAdapter(AuthPublicAdapter):
detail=f"登录请求过于频繁,请在 {reset_after} 秒后重试", detail=f"登录请求过于频繁,请在 {reset_after} 秒后重试",
) )
user = await AuthService.authenticate_user(db, login_request.email, login_request.password) user = await AuthService.authenticate_user(
db, login_request.email, login_request.password, login_request.auth_type
)
if not user: if not user:
AuditService.log_login_attempt( AuditService.log_login_attempt(
db=db, db=db,
@@ -305,6 +315,21 @@ class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
).model_dump() ).model_dump()
class AuthSettingsAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
"""公开返回认证设置"""
db = context.db
ldap_enabled = LDAPService.is_ldap_enabled(db)
ldap_exclusive = LDAPService.is_ldap_exclusive(db)
return {
"local_enabled": not ldap_exclusive,
"ldap_enabled": ldap_enabled,
"ldap_exclusive": ldap_exclusive,
}
class AuthRegisterAdapter(AuthPublicAdapter): class AuthRegisterAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
from src.models.database import SystemConfig from src.models.database import SystemConfig

View File

@@ -30,3 +30,10 @@ class ProviderBillingType(Enum):
MONTHLY_QUOTA = "monthly_quota" # 月卡额度 MONTHLY_QUOTA = "monthly_quota" # 月卡额度
PAY_AS_YOU_GO = "pay_as_you_go" # 按量付费 PAY_AS_YOU_GO = "pay_as_you_go" # 按量付费
FREE_TIER = "free_tier" # 免费额度 FREE_TIER = "free_tier" # 免费额度
class AuthSource(str, Enum):
"""认证来源枚举"""
LOCAL = "local" # 本地认证
LDAP = "ldap" # LDAP 认证

View File

@@ -4,7 +4,7 @@ API端点请求/响应模型定义
import re import re
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, 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
@@ -17,6 +17,7 @@ class LoginRequest(BaseModel):
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址") email: str = Field(..., min_length=3, 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="认证类型")
@classmethod @classmethod
@field_validator("email") @field_validator("email")

View File

@@ -30,7 +30,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm import declarative_base, relationship
from ..config import config from ..config import config
from ..core.enums import ProviderBillingType, UserRole from ..core.enums import AuthSource, ProviderBillingType, UserRole
Base = declarative_base() Base = declarative_base()
@@ -54,6 +54,16 @@ class User(Base):
default=UserRole.USER, default=UserRole.USER,
nullable=False, nullable=False,
) )
auth_source = Column(
Enum(
AuthSource,
name="authsource",
create_type=False,
values_callable=lambda x: [e.value for e in x],
),
default=AuthSource.LOCAL,
nullable=False,
)
# 访问限制NULL 表示不限制,允许访问所有资源) # 访问限制NULL 表示不限制,允许访问所有资源)
allowed_providers = Column(JSON, nullable=True) # 允许使用的提供商 ID 列表 allowed_providers = Column(JSON, nullable=True) # 允许使用的提供商 ID 列表
@@ -428,6 +438,67 @@ class SystemConfig(Base):
) )
class LDAPConfig(Base):
"""LDAP认证配置表 - 单行配置"""
__tablename__ = "ldap_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
server_url = Column(String(255), nullable=False) # ldap://host:389 或 ldaps://host:636
bind_dn = Column(String(255), nullable=False) # 绑定账号 DN
bind_password_encrypted = Column(Text, nullable=False) # 加密的绑定密码
base_dn = Column(String(255), nullable=False) # 用户搜索基础 DN
user_search_filter = Column(
String(500), default="(uid={username})", nullable=False
) # 用户搜索过滤器
username_attr = Column(String(50), default="uid", nullable=False) # 用户名属性 (uid/sAMAccountName)
email_attr = Column(String(50), default="mail", nullable=False) # 邮箱属性
display_name_attr = Column(String(50), default="cn", nullable=False) # 显示名称属性
is_enabled = Column(Boolean, default=False, nullable=False) # 是否启用 LDAP 认证
is_exclusive = Column(
Boolean, default=False, nullable=False
) # 是否仅允许 LDAP 登录(禁用本地认证)
use_starttls = Column(Boolean, default=False, nullable=False) # 是否使用 STARTTLS
# 时间戳
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
def set_bind_password(self, password: str) -> None:
"""
设置并加密绑定密码
Args:
password: 明文密码
"""
from src.core.crypto import crypto_service
self.bind_password_encrypted = crypto_service.encrypt(password)
def get_bind_password(self) -> str:
"""
获取解密后的绑定密码
Returns:
str: 解密后的明文密码
Raises:
DecryptionException: 解密失败时抛出异常
"""
from src.core.crypto import crypto_service
if not self.bind_password_encrypted:
return ""
return crypto_service.decrypt(self.bind_password_encrypted)
class Provider(Base): class Provider(Base):
"""提供商配置表""" """提供商配置表"""

157
src/services/auth/ldap.py Normal file
View File

@@ -0,0 +1,157 @@
"""LDAP 认证服务"""
from typing import Optional, Tuple
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import LDAPConfig
class LDAPService:
"""LDAP 认证服务"""
@staticmethod
def get_config(db: Session) -> Optional[LDAPConfig]:
"""获取 LDAP 配置"""
return db.query(LDAPConfig).first()
@staticmethod
def is_ldap_enabled(db: Session) -> bool:
"""检查 LDAP 是否启用"""
config = LDAPService.get_config(db)
return config.is_enabled if config else False
@staticmethod
def is_ldap_exclusive(db: Session) -> bool:
"""检查是否仅允许 LDAP 登录"""
config = LDAPService.get_config(db)
return config.is_exclusive if config and config.is_enabled else False
@staticmethod
def authenticate(db: Session, username: str, password: str) -> Optional[dict]:
"""
LDAP bind 验证
Args:
db: 数据库会话
username: 用户名
password: 密码
Returns:
用户属性 dict {username, email, display_name} 或 None
"""
try:
import ldap3
from ldap3 import Server, Connection, SUBTREE
from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError
except ImportError:
logger.error("ldap3 库未安装")
return None
config = LDAPService.get_config(db)
if not config or not config.is_enabled:
logger.warning("LDAP 未配置或未启用")
return None
try:
# 创建服务器连接
use_ssl = config.server_url.startswith("ldaps://")
server = Server(config.server_url, use_ssl=use_ssl, get_info=ldap3.ALL)
# 使用管理员账号连接
bind_password = config.get_bind_password()
admin_conn = Connection(server, user=config.bind_dn, password=bind_password)
if config.use_starttls and not use_ssl:
admin_conn.start_tls()
if not admin_conn.bind():
logger.error(f"LDAP 管理员绑定失败: {admin_conn.result}")
return None
# 搜索用户
search_filter = config.user_search_filter.replace("{username}", username)
admin_conn.search(
search_base=config.base_dn,
search_filter=search_filter,
search_scope=SUBTREE,
attributes=[config.username_attr, config.email_attr, config.display_name_attr],
)
if not admin_conn.entries:
logger.warning(f"LDAP 用户未找到: {username}")
admin_conn.unbind()
return None
user_entry = admin_conn.entries[0]
user_dn = user_entry.entry_dn
admin_conn.unbind()
# 用户密码验证
user_conn = Connection(server, user=user_dn, password=password)
if config.use_starttls and not use_ssl:
user_conn.start_tls()
if not user_conn.bind():
logger.warning(f"LDAP 密码验证失败: {username}")
return None
user_conn.unbind()
# 提取用户属性
email = str(getattr(user_entry, config.email_attr, "")) or f"{username}@ldap.local"
display_name = str(getattr(user_entry, config.display_name_attr, "")) or username
logger.info(f"LDAP 认证成功: {username}")
return {
"username": username,
"email": email,
"display_name": display_name,
}
except LDAPSocketOpenError as e:
logger.error(f"LDAP 服务器连接失败: {e}")
return None
except LDAPBindError as e:
logger.error(f"LDAP 绑定失败: {e}")
return None
except Exception as e:
logger.error(f"LDAP 认证异常: {e}")
return None
@staticmethod
def test_connection(db: Session) -> Tuple[bool, str]:
"""
测试 LDAP 连接
Returns:
(success, message)
"""
try:
import ldap3
from ldap3 import Server, Connection
except ImportError:
return False, "ldap3 库未安装"
config = LDAPService.get_config(db)
if not config:
return False, "LDAP 配置不存在"
try:
use_ssl = config.server_url.startswith("ldaps://")
server = Server(config.server_url, use_ssl=use_ssl, get_info=ldap3.ALL)
bind_password = config.get_bind_password()
conn = Connection(server, user=config.bind_dn, password=bind_password)
if config.use_starttls and not use_ssl:
conn.start_tls()
if not conn.bind():
return False, f"绑定失败: {conn.result}"
conn.unbind()
return True, "连接成功"
except Exception as e:
return False, f"连接失败: {str(e)}"

View File

@@ -15,8 +15,10 @@ from sqlalchemy.orm import Session, joinedload
from src.config import config from src.config import config
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.logger import logger from src.core.logger import logger
from src.core.enums import AuthSource
from src.models.database import ApiKey, User, UserRole from src.models.database import ApiKey, User, UserRole
from src.services.auth.jwt_blacklist import JWTBlacklistService from src.services.auth.jwt_blacklist import JWTBlacklistService
from src.services.auth.ldap import LDAPService
from src.services.cache.user_cache import UserCacheService from src.services.cache.user_cache import UserCacheService
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
@@ -92,8 +94,36 @@ class AuthService:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的Token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的Token")
@staticmethod @staticmethod
async def authenticate_user(db: Session, email: str, password: str) -> Optional[User]: async def authenticate_user(
"""用户登录认证""" db: Session, email: str, password: str, auth_type: str = "local"
) -> Optional[User]:
"""用户登录认证
Args:
db: 数据库会话
email: 邮箱/用户名
password: 密码
auth_type: 认证类型 ("local""ldap")
"""
if auth_type == "ldap":
# LDAP 认证
if not LDAPService.is_ldap_enabled(db):
logger.warning("登录失败 - LDAP 认证未启用")
return None
ldap_user = LDAPService.authenticate(db, email, password)
if not ldap_user:
return None
# 获取或创建本地用户
user = await AuthService._get_or_create_ldap_user(db, ldap_user)
return user
# 本地认证
if LDAPService.is_ldap_exclusive(db):
logger.warning("登录失败 - 仅允许 LDAP 登录")
return None
# 登录校验必须读取密码哈希,不能使用不包含 password_hash 的缓存对象 # 登录校验必须读取密码哈希,不能使用不包含 password_hash 的缓存对象
user = db.query(User).filter(User.email == email).first() user = db.query(User).filter(User.email == email).first()
@@ -101,6 +131,11 @@ class AuthService:
logger.warning(f"登录失败 - 用户不存在: {email}") logger.warning(f"登录失败 - 用户不存在: {email}")
return None return None
# 检查用户认证来源
if user.auth_source == AuthSource.LDAP:
logger.warning(f"登录失败 - 该用户使用 LDAP 认证: {email}")
return None
if not user.verify_password(password): if not user.verify_password(password):
logger.warning(f"登录失败 - 密码错误: {email}") logger.warning(f"登录失败 - 密码错误: {email}")
return None return None
@@ -118,6 +153,42 @@ class AuthService:
logger.info(f"用户登录成功: {email} (ID: {user.id})") logger.info(f"用户登录成功: {email} (ID: {user.id})")
return user return user
@staticmethod
async def _get_or_create_ldap_user(db: Session, ldap_user: dict) -> User:
"""获取或创建 LDAP 用户
Args:
ldap_user: LDAP 用户信息 {username, email, display_name}
"""
# 先按 email 查找
user = db.query(User).filter(User.email == ldap_user["email"]).first()
if user:
# 更新 auth_source如果是首次 LDAP 登录)
if user.auth_source != AuthSource.LDAP:
user.auth_source = AuthSource.LDAP
user.last_login_at = datetime.now(timezone.utc)
db.commit()
await UserCacheService.invalidate_user_cache(user.id, user.email)
logger.info(f"LDAP 用户登录成功: {ldap_user['email']} (ID: {user.id})")
return user
# 创建新用户
user = User(
email=ldap_user["email"],
username=ldap_user["username"],
password_hash="", # LDAP 用户无本地密码
auth_source=AuthSource.LDAP,
role=UserRole.USER,
is_active=True,
last_login_at=datetime.now(timezone.utc),
)
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"LDAP 用户创建成功: {ldap_user['email']} (ID: {user.id})")
return user
@staticmethod @staticmethod
def authenticate_api_key(db: Session, api_key: str) -> Optional[tuple[User, ApiKey]]: def authenticate_api_key(db: Session, api_key: str) -> Optional[tuple[User, ApiKey]]:
"""API密钥认证""" """API密钥认证"""