feat: 添加 LDAP 认证支持

- 新增 LDAP 服务和 API 接口
- 添加 LDAP 配置管理页面
- 登录页面支持 LDAP/本地认证切换
- 数据库迁移支持 LDAP 相关字段
This commit is contained in:
fawney19
2026-01-06 14:38:42 +08:00
21 changed files with 3947 additions and 2037 deletions

View File

@@ -160,6 +160,44 @@ export interface EmailTemplateResetResponse {
}
}
// LDAP 配置响应
export interface LdapConfigResponse {
server_url: string | null
bind_dn: string | null
base_dn: string | null
has_bind_password: boolean
user_search_filter: string
username_attr: string
email_attr: string
display_name_attr: string
is_enabled: boolean
is_exclusive: boolean
use_starttls: boolean
connect_timeout: number
}
// 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
connect_timeout?: number
}
// LDAP 连接测试响应
export interface LdapTestResponse {
success: boolean
message: string
}
// Provider 模型查询响应
export interface ProviderModelsQueryResponse {
success: boolean
@@ -487,5 +525,27 @@ export const adminApi = {
'/api/admin/system/version'
)
return response.data
},
// LDAP 配置相关
// 获取 LDAP 配置
async getLdapConfig(): Promise<LdapConfigResponse> {
const response = await apiClient.get<LdapConfigResponse>('/api/admin/ldap/config')
return response.data
},
// 更新 LDAP 配置
async updateLdapConfig(config: LdapConfigUpdateRequest): Promise<{ message: string }> {
const response = await apiClient.put<{ message: string }>(
'/api/admin/ldap/config',
config
)
return response.data
},
// 测试 LDAP 连接
async testLdapConnection(config: LdapConfigUpdateRequest): Promise<LdapTestResponse> {
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
return response.data
}
}

View File

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

View File

@@ -66,19 +66,59 @@
</div>
</div>
<!-- 认证方式切换 -->
<div
v-if="showAuthTypeTabs"
class="auth-type-tabs"
>
<button
type="button"
:class="['auth-tab', authType === 'local' && 'active']"
@click="authType = 'local'"
>
本地登录
</button>
<button
type="button"
:class="['auth-tab', authType === 'ldap' && 'active']"
@click="authType = 'ldap'"
>
LDAP 登录
</button>
</div>
<!-- 登录表单 -->
<form
class="space-y-4"
@submit.prevent="handleLogin"
>
<div class="space-y-2">
<Label for="login-email">邮箱</Label>
<div class="flex items-center justify-between">
<Label for="login-email">{{ emailLabel }}</Label>
<button
v-if="ldapExclusive && authType === 'ldap'"
type="button"
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
@click="authType = 'local'"
>
管理员本地登录
</button>
<button
v-if="ldapExclusive && authType === 'local'"
type="button"
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
@click="authType = 'ldap'"
>
返回 LDAP 登录
</button>
</div>
<Input
id="login-email"
v-model="form.email"
type="email"
type="text"
required
placeholder="hello@example.com"
placeholder="username 或 email"
autocomplete="off"
/>
</div>
@@ -180,6 +220,30 @@ const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false)
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
// LDAP authentication settings
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
function getStoredAuthType(): 'local' | 'ldap' {
const stored = localStorage.getItem(PREFERRED_AUTH_TYPE_KEY)
return (stored === 'ldap' || stored === 'local') ? stored : 'local'
}
const authType = ref<'local' | 'ldap'>(getStoredAuthType())
const localEnabled = ref(true)
const ldapEnabled = ref(false)
const ldapExclusive = ref(false)
// 保存用户的认证类型偏好
watch(authType, (newType) => {
localStorage.setItem(PREFERRED_AUTH_TYPE_KEY, newType)
})
const showAuthTypeTabs = computed(() => {
return localEnabled.value && ldapEnabled.value && !ldapExclusive.value
})
const emailLabel = computed(() => {
return '用户名/邮箱'
})
watch(() => props.modelValue, (val) => {
isOpen.value = val
// 打开对话框时重置表单
@@ -212,7 +276,7 @@ async function handleLogin() {
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) {
showSuccess('登录成功,正在跳转...')
@@ -246,16 +310,84 @@ function handleSwitchToLogin() {
isOpen.value = true
}
// Load registration settings on mount
// Load authentication and registration settings on mount
onMounted(async () => {
try {
const settings = await authApi.getRegistrationSettings()
allowRegistration.value = !!settings.enable_registration
requireEmailVerification.value = !!settings.require_email_verification
// Load registration settings
const regSettings = await authApi.getRegistrationSettings()
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
// 若仅允许 LDAP 登录,则禁用本地注册入口
if (ldapExclusive.value) {
allowRegistration.value = false
}
// 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) {
// If获取失败保持默认关闭注册 & 关闭邮箱验证
// If获取失败保持默认关闭注册 & 关闭邮箱验证 & 使用本地认证
allowRegistration.value = false
requireEmailVerification.value = false
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false
authType.value = 'local'
}
})
</script>
<style scoped>
.auth-type-tabs {
display: flex;
border-bottom: 1px solid hsl(var(--border));
}
.auth-tab {
flex: 1;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s ease;
position: relative;
}
.auth-tab::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: transparent;
transition: background 0.15s ease;
}
.auth-tab:hover:not(.active) {
color: hsl(var(--foreground));
}
.auth-tab.active {
color: var(--book-cloth);
font-weight: 600;
}
.auth-tab.active::after {
background: var(--book-cloth);
}
</style>

View File

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

View File

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

View File

@@ -31,12 +31,12 @@ export const useAuthStore = defineStore('auth', () => {
}
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
error.value = null
try {
const response = await authApi.login({ email, password })
const response = await authApi.login({ email, password, auth_type: authType })
token.value = response.access_token
// 获取用户信息

View File

@@ -106,23 +106,23 @@
type="text"
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
class="-webkit-text-security-disc"
:class="smtpPasswordIsSet ? 'pr-8' : ''"
:class="(smtpPasswordIsSet || emailConfig.smtp_password) ? 'pr-10' : ''"
autocomplete="one-time-code"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
/>
<button
v-if="smtpPasswordIsSet"
v-if="smtpPasswordIsSet || emailConfig.smtp_password"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
title="清除已保存的密码"
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
title="清除密码"
@click="handleClearSmtpPassword"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -498,6 +498,7 @@ const smtpEncryptionSelectOpen = ref(false)
const emailSuffixModeSelectOpen = ref(false)
const testSmtpLoading = ref(false)
const smtpPasswordIsSet = ref(false)
const clearSmtpPassword = ref(false) // 标记是否要清除密码
// 邮件模板相关状态
const templateLoading = ref(false)
@@ -710,6 +711,7 @@ async function loadEmailConfig() {
// 配置不存在时使用默认值,无需处理
}
}
clearSmtpPassword.value = false
} catch (err) {
error('加载邮件配置失败')
log.error('加载邮件配置失败:', err)
@@ -720,6 +722,12 @@ async function loadEmailConfig() {
async function saveSmtpConfig() {
smtpSaveLoading.value = true
try {
const passwordAction: 'unchanged' | 'updated' | 'cleared' = emailConfig.value.smtp_password
? 'updated'
: clearSmtpPassword.value
? 'cleared'
: 'unchanged'
const configItems = [
{
key: 'smtp_host',
@@ -737,7 +745,7 @@ async function saveSmtpConfig() {
description: 'SMTP 用户名'
},
// 只有输入了新密码才提交(空值表示保持原密码)
...(emailConfig.value.smtp_password
...(passwordAction === 'updated'
? [{
key: 'smtp_password',
value: emailConfig.value.smtp_password,
@@ -770,8 +778,23 @@ async function saveSmtpConfig() {
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
// 如果标记了清除密码,删除密码配置
if (passwordAction === 'cleared') {
promises.push(adminApi.deleteSystemConfig('smtp_password'))
}
await Promise.all(promises)
success('SMTP 配置已保存')
// 更新状态
if (passwordAction === 'cleared') {
clearSmtpPassword.value = false
smtpPasswordIsSet.value = false
} else if (passwordAction === 'updated') {
clearSmtpPassword.value = false
smtpPasswordIsSet.value = true
}
emailConfig.value.smtp_password = null
} catch (err) {
error('保存配置失败')
log.error('保存 SMTP 配置失败:', err)
@@ -812,15 +835,16 @@ async function saveEmailSuffixConfig() {
}
// 清除 SMTP 密码
async function handleClearSmtpPassword() {
try {
await adminApi.deleteSystemConfig('smtp_password')
smtpPasswordIsSet.value = false
function handleClearSmtpPassword() {
// 如果有输入内容,先清空输入框
if (emailConfig.value.smtp_password) {
emailConfig.value.smtp_password = null
success('SMTP 密码已清除')
} catch (err) {
error('清除密码失败')
log.error('清除 SMTP 密码失败:', err)
return
}
// 标记要清除服务端密码(保存时生效)
if (smtpPasswordIsSet.value) {
clearSmtpPassword.value = true
smtpPasswordIsSet.value = false
}
}

View File

@@ -0,0 +1,379 @@
<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>
<div class="relative mt-1">
<Input
id="bind-password"
v-model="ldapConfig.bind_password"
type="password"
:placeholder="hasPassword ? '已设置(留空保持不变)' : '请输入密码'"
:class="(hasPassword || ldapConfig.bind_password) ? 'pr-10' : ''"
autocomplete="new-password"
/>
<button
v-if="hasPassword || ldapConfig.bind_password"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
@click="handleClearPassword"
title="清除密码"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<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>
<Label for="connect-timeout" class="block text-sm font-medium">
连接超时 ()
</Label>
<Input
id="connect-timeout"
v-model.number="ldapConfig.connect_timeout"
type="number"
min="1"
max="60"
placeholder="10"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
单次 LDAP 操作超时时间 (1-60)跨国网络建议 15-30
</p>
</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="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="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="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, Input, Label, Switch } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { adminApi, type LdapConfigUpdateRequest } from '@/api/admin'
const { success, error } = useToast()
const loading = ref(false)
const saveLoading = ref(false)
const testLoading = ref(false)
const hasPassword = ref(false)
const clearPassword = 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,
connect_timeout: 10,
})
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,
connect_timeout: response.connect_timeout || 10,
}
hasPassword.value = !!response.has_bind_password
clearPassword.value = false
} catch (err) {
error('加载 LDAP 配置失败')
console.error('加载 LDAP 配置失败:', err)
} finally {
loading.value = false
}
}
async function handleSave() {
saveLoading.value = true
try {
const payload: LdapConfigUpdateRequest = {
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,
connect_timeout: ldapConfig.value.connect_timeout,
}
// 优先使用输入的新密码;否则如果标记清除则发送空字符串
let passwordAction: 'unchanged' | 'updated' | 'cleared' = 'unchanged'
if (ldapConfig.value.bind_password) {
payload.bind_password = ldapConfig.value.bind_password
passwordAction = 'updated'
} else if (clearPassword.value) {
payload.bind_password = ''
passwordAction = 'cleared'
}
await adminApi.updateLdapConfig(payload)
success('LDAP 配置保存成功')
if (passwordAction === 'cleared') {
hasPassword.value = false
clearPassword.value = false
} else if (passwordAction === 'updated') {
hasPassword.value = true
clearPassword.value = false
}
ldapConfig.value.bind_password = ''
} catch (err) {
error('保存 LDAP 配置失败')
console.error('保存 LDAP 配置失败:', err)
} finally {
saveLoading.value = false
}
}
async function handleTestConnection() {
if (clearPassword.value && !ldapConfig.value.bind_password) {
error('已标记清除绑定密码,请先保存或输入新的绑定密码再测试')
return
}
testLoading.value = true
try {
const payload: LdapConfigUpdateRequest = {
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,
connect_timeout: ldapConfig.value.connect_timeout,
...(ldapConfig.value.bind_password && { bind_password: ldapConfig.value.bind_password }),
}
const response = await adminApi.testLdapConnection(payload)
if (response.success) {
success('LDAP 连接测试成功')
} else {
error(`LDAP 连接测试失败: ${response.message}`)
}
} catch (err) {
error('LDAP 连接测试失败')
console.error('LDAP 连接测试失败:', err)
} finally {
testLoading.value = false
}
}
function handleClearPassword() {
// 如果有输入内容,先清空输入框
if (ldapConfig.value.bind_password) {
ldapConfig.value.bind_password = ''
return
}
// 标记要清除服务端密码(保存时生效)
if (hasPassword.value) {
clearPassword.value = true
hasPassword.value = false
}
}
</script>