feat: 添加 OAuth 认证支持及相关改进

- 新增 OAuth 模块,支持 LinuxDo/GitHub/Google 等第三方登录
- 用户邮箱改为可选字段,支持无邮箱注册
- 新增模块配置验证状态 (config_validated/config_error)
- 系统设置界面改为分块独立保存
- 用户设置新增 OAuth 绑定管理和首次密码设置
- 登录界面支持 OAuth 按钮展示
- 邮箱验证设置移至邮件设置页面
This commit is contained in:
fawney19
2026-01-19 03:19:17 +08:00
parent e2e14fd09c
commit 3d88dfd98a
61 changed files with 4548 additions and 950 deletions

View File

@@ -0,0 +1,223 @@
"""make users email/password nullable add email_verified and oauth tables
Revision ID: 33e347f97c0c
Revises: ddd59cdf0349
Create Date: 2026-01-18 11:18:15.940559+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "33e347f97c0c"
down_revision = "ddd59cdf0349"
branch_labels = None
depends_on = None
def column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_is_nullable(table_name: str, column_name: str) -> bool:
"""检查列是否允许 NULL"""
bind = op.get_bind()
inspector = inspect(bind)
for col in inspector.get_columns(table_name):
if col["name"] == column_name:
return col["nullable"]
return False
def enum_value_exists(enum_name: str, value: str) -> bool:
"""检查 PostgreSQL ENUM 是否包含指定值"""
bind = op.get_bind()
if bind.dialect.name != "postgresql":
return True # 非 PostgreSQL 跳过检查
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_enum WHERE enumlabel = :value "
"AND enumtypid = (SELECT oid FROM pg_type WHERE typname = :enum_name)"
),
{"value": value, "enum_name": enum_name},
).first()
return result is not None
def upgrade() -> None:
"""应用迁移:升级到新版本"""
bind = op.get_bind()
# ========== Part 1: users 表修改 ==========
# 1) 新增 email_verified
if not column_exists("users", "email_verified"):
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=True))
# 历史数据回填:已有邮箱的用户默认视为已验证
op.execute(sa.text("UPDATE users SET email_verified = true WHERE email IS NOT NULL"))
op.execute(sa.text("UPDATE users SET email_verified = false WHERE email IS NULL"))
# 收紧约束
op.alter_column("users", "email_verified", existing_type=sa.Boolean(), nullable=False)
# 2) email 放宽为可空
if not column_is_nullable("users", "email"):
op.alter_column(
"users",
"email",
existing_type=sa.String(length=255),
nullable=True,
)
# 3) password_hash 放宽为可空
if not column_is_nullable("users", "password_hash"):
op.alter_column(
"users",
"password_hash",
existing_type=sa.String(length=255),
nullable=True,
)
# ========== Part 2: OAuth 相关 ==========
# 4) 扩展 authsource enum
if bind.dialect.name == "postgresql" and not enum_value_exists("authsource", "oauth"):
ctx = op.get_context()
with ctx.autocommit_block():
op.execute("ALTER TYPE authsource ADD VALUE IF NOT EXISTS 'oauth'")
# 5) OAuth provider 配置表
if not table_exists("oauth_providers"):
op.create_table(
"oauth_providers",
sa.Column("provider_type", sa.String(length=50), primary_key=True),
sa.Column("display_name", sa.String(length=100), nullable=False),
sa.Column("client_id", sa.String(length=255), nullable=False),
sa.Column("client_secret_encrypted", sa.Text(), nullable=True),
sa.Column("authorization_url_override", sa.String(length=500), nullable=True),
sa.Column("token_url_override", sa.String(length=500), nullable=True),
sa.Column("userinfo_url_override", sa.String(length=500), nullable=True),
sa.Column("scopes", sa.JSON(), nullable=True),
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
sa.Column("frontend_callback_url", sa.String(length=500), nullable=False),
sa.Column("attribute_mapping", sa.JSON(), nullable=True),
sa.Column("extra_config", sa.JSON(), nullable=True),
sa.Column(
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
)
# 6) 用户 OAuth 绑定关系表
if not table_exists("user_oauth_links"):
op.create_table(
"user_oauth_links",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"user_id",
sa.String(length=36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"provider_type",
sa.String(length=50),
sa.ForeignKey("oauth_providers.provider_type", ondelete="CASCADE"),
nullable=False,
),
sa.Column("provider_user_id", sa.String(length=255), nullable=False),
sa.Column("provider_username", sa.String(length=255), nullable=True),
sa.Column("provider_email", sa.String(length=255), nullable=True),
sa.Column("extra_data", sa.JSON(), nullable=True),
sa.Column(
"linked_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint(
"provider_type", "provider_user_id", name="uq_oauth_provider_user"
),
sa.UniqueConstraint("user_id", "provider_type", name="uq_user_oauth_provider"),
)
op.create_index("ix_user_oauth_links_user_id", "user_oauth_links", ["user_id"])
op.create_index(
"ix_user_oauth_links_provider_type", "user_oauth_links", ["provider_type"]
)
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
bind = op.get_bind()
# ========== Part 2: OAuth 相关(先删除,因为有外键依赖) ==========
if table_exists("user_oauth_links"):
op.drop_index("ix_user_oauth_links_provider_type", table_name="user_oauth_links")
op.drop_index("ix_user_oauth_links_user_id", table_name="user_oauth_links")
op.drop_table("user_oauth_links")
if table_exists("oauth_providers"):
op.drop_table("oauth_providers")
# 注意Postgres 不支持从 ENUM 删除值authsource 不回退
# ========== Part 1: users 表修改 ==========
# 降级前检查:避免把包含 NULL 的列强制改回 NOT NULL
has_null_email = bind.execute(
sa.text("SELECT 1 FROM users WHERE email IS NULL LIMIT 1")
).first()
if has_null_email:
raise RuntimeError("Cannot downgrade: users.email contains NULL values")
has_null_password = bind.execute(
sa.text("SELECT 1 FROM users WHERE password_hash IS NULL LIMIT 1")
).first()
if has_null_password:
raise RuntimeError("Cannot downgrade: users.password_hash contains NULL values")
# 恢复 NOT NULL 约束
if column_is_nullable("users", "email"):
op.alter_column(
"users",
"email",
existing_type=sa.String(length=255),
nullable=False,
)
if column_is_nullable("users", "password_hash"):
op.alter_column(
"users",
"password_hash",
existing_type=sa.String(length=255),
nullable=False,
)
if column_exists("users", "email_verified"):
op.drop_column("users", "email_verified")

View File

@@ -65,14 +65,14 @@ export interface VerificationStatusResponse {
} }
export interface RegisterRequest { export interface RegisterRequest {
email: string email?: string
username: string username: string
password: string password: string
} }
export interface RegisterResponse { export interface RegisterResponse {
user_id: string user_id: string
email: string email?: string
username: string username: string
message: string message: string
} }
@@ -80,6 +80,7 @@ export interface RegisterResponse {
export interface RegistrationSettingsResponse { export interface RegistrationSettingsResponse {
enable_registration: boolean enable_registration: boolean
require_email_verification: boolean require_email_verification: boolean
email_configured: boolean
} }
export interface AuthSettingsResponse { export interface AuthSettingsResponse {

View File

@@ -3,7 +3,7 @@ import type { ActivityHeatmap } from '@/types/activity'
export interface Profile { export interface Profile {
id: string // UUID id: string // UUID
email: string email?: string | null
username: string username: string
role: string role: string
is_active: boolean is_active: boolean
@@ -13,7 +13,8 @@ export interface Profile {
created_at: string created_at: string
updated_at?: string updated_at?: string
last_login_at?: string last_login_at?: string
auth_source?: 'local' | 'ldap' auth_source?: 'local' | 'ldap' | 'oauth'
has_password?: boolean
preferences?: UserPreferences preferences?: UserPreferences
} }
@@ -132,7 +133,7 @@ export interface ApiKey {
// 不再需要 ProviderBinding 接口 // 不再需要 ProviderBinding 接口
export interface ChangePasswordRequest { export interface ChangePasswordRequest {
old_password: string old_password?: string // 可选:首次设置密码时不需要
new_password: string new_password: string
} }

View File

@@ -5,6 +5,8 @@ export interface ModuleStatus {
available: boolean available: boolean
enabled: boolean enabled: boolean
active: boolean active: boolean
config_validated: boolean
config_error: string | null
display_name: string display_name: string
description: string description: string
category: 'auth' | 'monitoring' | 'security' | 'integration' category: 'auth' | 'monitoring' | 'security' | 'integration'

136
frontend/src/api/oauth.ts Normal file
View File

@@ -0,0 +1,136 @@
import apiClient from './client'
export interface OAuthProviderInfo {
provider_type: string
display_name: string
}
export interface OAuthProvidersResponse {
providers: OAuthProviderInfo[]
}
export interface OAuthLinkInfo {
provider_type: string
display_name: string
provider_username?: string | null
provider_email?: string | null
linked_at?: string | null
last_login_at?: string | null
provider_enabled?: boolean
}
export interface OAuthLinksResponse {
links: OAuthLinkInfo[]
}
// Admin
export interface SupportedOAuthType {
provider_type: string
display_name: string
default_authorization_url: string
default_token_url: string
default_userinfo_url: string
default_scopes: string[]
}
export interface OAuthProviderAdminConfig {
provider_type: string
display_name: string
client_id: string
has_secret: boolean
authorization_url_override?: string | null
token_url_override?: string | null
userinfo_url_override?: string | null
scopes?: string[] | null
redirect_uri: string
frontend_callback_url: string
attribute_mapping?: Record<string, any> | null
extra_config?: Record<string, any> | null
is_enabled: boolean
}
export interface OAuthProviderUpsertRequest {
display_name: string
client_id: string
client_secret?: string
authorization_url_override?: string | null
token_url_override?: string | null
userinfo_url_override?: string | null
scopes?: string[] | null
redirect_uri: string
frontend_callback_url: string
attribute_mapping?: Record<string, any> | null
extra_config?: Record<string, any> | null
is_enabled: boolean
force?: boolean
}
export interface OAuthProviderTestResponse {
authorization_url_reachable: boolean
token_url_reachable: boolean
secret_status: 'likely_valid' | 'invalid' | 'unknown' | 'not_provided' | string
details?: string
}
export interface OAuthProviderTestRequest {
client_id: string
client_secret?: string
authorization_url_override?: string | null
token_url_override?: string | null
redirect_uri: string
}
export const oauthApi = {
async getProviders(): Promise<OAuthProviderInfo[]> {
const response = await apiClient.get<OAuthProvidersResponse>('/api/oauth/providers')
return response.data.providers || []
},
async getBindableProviders(): Promise<OAuthProviderInfo[]> {
const response = await apiClient.get<OAuthProvidersResponse>('/api/user/oauth/bindable-providers')
return response.data.providers || []
},
async getMyLinks(): Promise<OAuthLinkInfo[]> {
const response = await apiClient.get<OAuthLinksResponse>('/api/user/oauth/links')
return response.data.links || []
},
async unbind(providerType: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string }>(`/api/user/oauth/${providerType}`)
return response.data
},
admin: {
async getSupportedTypes(): Promise<SupportedOAuthType[]> {
const response = await apiClient.get<SupportedOAuthType[]>('/api/admin/oauth/supported-types')
return response.data || []
},
async listProviderConfigs(): Promise<OAuthProviderAdminConfig[]> {
const response = await apiClient.get<OAuthProviderAdminConfig[]>('/api/admin/oauth/providers')
return response.data || []
},
async getProviderConfig(providerType: string): Promise<OAuthProviderAdminConfig> {
const response = await apiClient.get<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${providerType}`)
return response.data
},
async upsertProviderConfig(providerType: string, payload: OAuthProviderUpsertRequest): Promise<OAuthProviderAdminConfig> {
const response = await apiClient.put<OAuthProviderAdminConfig>(`/api/admin/oauth/providers/${providerType}`, payload)
return response.data
},
async deleteProviderConfig(providerType: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string }>(`/api/admin/oauth/providers/${providerType}`)
return response.data
},
async testProviderConfig(providerType: string, payload: OAuthProviderTestRequest): Promise<OAuthProviderTestResponse> {
const response = await apiClient.post<OAuthProviderTestResponse>(`/api/admin/oauth/providers/${providerType}/test`, payload)
return response.data
},
}
}

View File

@@ -1,75 +1,111 @@
<template> <template>
<Dialog <Dialog
v-model="isOpen" v-model="isOpen"
size="lg" size="md"
no-padding
> >
<div class="space-y-6"> <div class="px-6 py-6 sm:px-8 sm:py-8">
<!-- Logo 和标题 --> <!-- Logo 和标题 -->
<div class="flex flex-col items-center text-center"> <div class="flex flex-col items-center text-center mb-6">
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
<img <img
src="/aether_adaptive.svg" src="/aether_adaptive.svg"
alt="Logo" alt="Aether"
class="h-16 w-16" class="h-10 w-10 mb-3"
> >
</div> <h2 class="text-xl font-semibold text-foreground">
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white"> 登录到 Aether
欢迎回来
</h2> </h2>
</div> </div>
<!-- Demo 模式提示 --> <!-- Demo 模式提示 -->
<div <div
v-if="isDemo" v-if="isDemo"
class="rounded-lg border border-primary/20 dark:border-primary/30 bg-primary/5 dark:bg-primary/10 p-4" class="rounded-lg border border-primary/20 bg-primary/5 p-3 mb-5"
> >
<div class="flex items-start gap-3"> <p class="text-xs font-medium text-foreground mb-2">
<div class="flex-shrink-0 text-primary dark:text-primary/90">
<svg
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground">
演示模式 演示模式
</p> </p>
<p class="mt-1 text-xs text-muted-foreground"> <div class="space-y-1.5">
当前处于演示模式所有数据均为模拟数据
</p>
<div class="mt-3 space-y-2">
<button <button
type="button" type="button"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group" class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
@click="fillDemoAccount('admin')" @click="fillDemoAccount('admin')"
> >
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-primary/20 dark:bg-primary/30 text-primary text-[10px] font-bold group-hover:bg-primary/30 dark:group-hover:bg-primary/40 transition-colors">A</span> <span class="inline-flex items-center justify-center w-4 h-4 rounded bg-primary/20 text-primary text-[10px] font-bold">A</span>
<span>管理员admin@demo.aether.io / demo123</span> <span>admin@demo.aether.io / demo123</span>
</button> </button>
<button <button
type="button" type="button"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group" class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
@click="fillDemoAccount('user')" @click="fillDemoAccount('user')"
> >
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-muted text-muted-foreground text-[10px] font-bold group-hover:bg-muted/80 transition-colors">U</span> <span class="inline-flex items-center justify-center w-4 h-4 rounded bg-muted text-muted-foreground text-[10px] font-bold">U</span>
<span>普通用户user@demo.aether.io / demo123</span> <span>user@demo.aether.io / demo123</span>
</button>
</div>
</div>
<!-- OAuth 登录按钮 -->
<div
v-if="oauthProviders.length > 0"
class="mb-5"
>
<!-- 单个 provider: 完整按钮 -->
<div
v-if="oauthProviders.length === 1"
class="space-y-2"
>
<button
type="button"
class="oauth-btn"
@click="handleOAuthLogin(oauthProviders[0].provider_type)"
>
<span
class="oauth-icon"
v-html="getOAuthIcon(oauthProviders[0].provider_type)"
/>
<span>使用 {{ oauthProviders[0].display_name }} 登录</span>
</button>
</div>
<!-- 多个 provider: 图标按钮组 -->
<div
v-else
class="flex flex-col items-center gap-3"
>
<span class="text-xs text-muted-foreground">使用以下方式登录</span>
<div class="flex items-center justify-center gap-3">
<button
v-for="p in oauthProviders"
:key="p.provider_type"
type="button"
class="oauth-icon-btn"
:title="p.display_name"
@click="handleOAuthLogin(p.provider_type)"
>
<span
class="oauth-icon-lg"
v-html="getOAuthIcon(p.provider_type)"
/>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- 分隔线 -->
<div
v-if="oauthProviders.length > 0"
class="flex items-center gap-3 mb-5"
>
<div class="flex-1 h-px bg-border" />
<span class="text-xs text-muted-foreground px-2">或使用账号密码</span>
<div class="flex-1 h-px bg-border" />
</div> </div>
<!-- 认证方式切换 --> <!-- 认证方式切换 -->
<div <div
v-if="showAuthTypeTabs" v-if="showAuthTypeTabs"
class="auth-type-tabs" class="auth-type-tabs mb-4"
> >
<button <button
type="button" type="button"
@@ -89,15 +125,19 @@
</button> </button>
</div> </div>
<!-- 登录表单 --> <!-- 登录表单 -->
<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-1.5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label for="login-email">{{ emailLabel }}</Label> <Label
for="login-email"
class="text-sm"
>
{{ emailLabel }}
</Label>
<button <button
v-if="ldapExclusive && authType === 'ldap'" v-if="ldapExclusive && authType === 'ldap'"
type="button" type="button"
@@ -120,72 +160,69 @@
v-model="form.email" v-model="form.email"
type="text" type="text"
required required
placeholder="username 或 email" placeholder="用户名或邮箱"
autocomplete="off" autocomplete="off"
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-1.5">
<Label for="login-password">密码</Label> <Label
for="login-password"
class="text-sm"
>
密码
</Label>
<Input <Input
id="login-password" id="login-password"
v-model="form.password" v-model="form.password"
type="password" type="password"
required required
placeholder="********" placeholder="输入密码"
autocomplete="off" autocomplete="off"
@keyup.enter="handleLogin" @keyup.enter="handleLogin"
/> />
</div> </div>
<!-- 登录按钮 -->
<Button
type="submit"
:disabled="authStore.loading"
class="w-full h-10"
>
{{ authStore.loading ? '登录中...' : '登录' }}
</Button>
<!-- 提示信息 --> <!-- 提示信息 -->
<p <p
v-if="!isDemo && !allowRegistration" v-if="!isDemo && !allowRegistration"
class="text-xs text-slate-400 dark:text-muted-foreground/80" class="text-xs text-muted-foreground text-center"
> >
如需开通账户请联系管理员配置访问权限 如需开通账户请联系管理员
</p> </p>
</form> </form>
<!-- 注册链接 --> <!-- 注册链接 -->
<div <div
v-if="allowRegistration" v-if="allowRegistration"
class="mt-4 text-center text-sm" class="mt-5 pt-5 border-t border-border text-center text-sm text-muted-foreground"
> >
还没有账户 还没有账户
<Button <button
variant="link" type="button"
class="h-auto p-0" class="text-primary hover:text-primary/80 font-medium transition-colors"
@click="handleSwitchToRegister" @click="handleSwitchToRegister"
> >
立即注册 立即注册
</Button> </button>
</div> </div>
</div> </div>
<template #footer>
<Button
type="button"
variant="outline"
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
@click="isOpen = false"
>
取消
</Button>
<Button
:disabled="authStore.loading"
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
@click="handleLogin"
>
{{ authStore.loading ? '登录中...' : '登录' }}
</Button>
</template>
</Dialog> </Dialog>
<!-- Register Dialog --> <!-- Register Dialog -->
<RegisterDialog <RegisterDialog
v-model:open="showRegisterDialog" v-model:open="showRegisterDialog"
:require-email-verification="requireEmailVerification" :require-email-verification="requireEmailVerification"
:email-configured="emailConfigured"
@success="handleRegisterSuccess" @success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin" @switch-to-login="handleSwitchToLogin"
/> />
@@ -203,6 +240,19 @@ import { useToast } from '@/composables/useToast'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo' import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue' import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth' import { authApi } from '@/api/auth'
import { oauthApi, type OAuthProviderInfo } from '@/api/oauth'
import { getApiUrl } from '@/utils/url'
// OAuth provider icons
const OAUTH_ICONS: Record<string, string> = {
linuxdo: `<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg"><clipPath id="ld"><circle cx="60" cy="60" r="47"/></clipPath><circle fill="#f0f0f0" cx="60" cy="60" r="50"/><rect fill="#1c1c1e" clip-path="url(#ld)" x="10" y="10" width="100" height="30"/><rect fill="#f0f0f0" clip-path="url(#ld)" x="10" y="40" width="100" height="40"/><rect fill="#ffb003" clip-path="url(#ld)" x="10" y="80" width="100" height="30"/></svg>`,
github: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`,
google: `<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`,
}
function getOAuthIcon(providerType: string): string {
return OAUTH_ICONS[providerType] || OAUTH_ICONS.github
}
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -220,6 +270,7 @@ const isOpen = ref(props.modelValue)
const isDemo = computed(() => isDemoMode()) const isDemo = computed(() => isDemoMode())
const showRegisterDialog = ref(false) const showRegisterDialog = ref(false)
const requireEmailVerification = ref(false) const requireEmailVerification = ref(false)
const emailConfigured = ref(true) // 邮箱服务是否已配置
const allowRegistration = ref(false) // 由系统配置控制,默认关闭 const allowRegistration = ref(false) // 由系统配置控制,默认关闭
// LDAP authentication settings // LDAP authentication settings
@@ -233,6 +284,8 @@ const localEnabled = ref(true)
const ldapEnabled = ref(false) const ldapEnabled = ref(false)
const ldapExclusive = ref(false) const ldapExclusive = ref(false)
const oauthProviders = ref<OAuthProviderInfo[]>([])
// 保存用户的认证类型偏好 // 保存用户的认证类型偏好
watch(authType, (newType) => { watch(authType, (newType) => {
localStorage.setItem(PREFERRED_AUTH_TYPE_KEY, newType) localStorage.setItem(PREFERRED_AUTH_TYPE_KEY, newType)
@@ -296,6 +349,12 @@ async function handleLogin() {
} }
} }
function handleOAuthLogin(providerType: string) {
// 如果 sessionStorage 中没有 redirectPath用户直接点击登录而非被守卫拦截
// 则不设置,让 AuthCallback 使用默认跳转逻辑
window.location.href = getApiUrl(`/api/oauth/${providerType}/authorize`)
}
function handleSwitchToRegister() { function handleSwitchToRegister() {
isOpen.value = false isOpen.value = false
showRegisterDialog.value = true showRegisterDialog.value = true
@@ -315,13 +374,16 @@ function handleSwitchToLogin() {
// Load authentication and registration settings on mount // Load authentication and registration settings on mount
onMounted(async () => { onMounted(async () => {
try { try {
// Load registration settings const [regSettings, authSettings, providers] = await Promise.all([
const regSettings = await authApi.getRegistrationSettings() authApi.getRegistrationSettings(),
authApi.getAuthSettings(),
oauthApi.getProviders().catch(() => []),
])
allowRegistration.value = !!regSettings.enable_registration allowRegistration.value = !!regSettings.enable_registration
requireEmailVerification.value = !!regSettings.require_email_verification requireEmailVerification.value = !!regSettings.require_email_verification
emailConfigured.value = !!regSettings.email_configured
// Load authentication settings
const authSettings = await authApi.getAuthSettings()
localEnabled.value = authSettings.local_enabled localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled ldapEnabled.value = authSettings.ldap_enabled
ldapExclusive.value = authSettings.ldap_exclusive ldapExclusive.value = authSettings.ldap_exclusive
@@ -338,19 +400,85 @@ onMounted(async () => {
} else { } else {
authType.value = 'local' authType.value = 'local'
} }
oauthProviders.value = providers
} catch { } catch {
// If获取失败保持默认关闭注册 & 关闭邮箱验证 & 使用本地认证 // If获取失败保持默认关闭注册 & 关闭邮箱验证 & 使用本地认证
allowRegistration.value = false allowRegistration.value = false
requireEmailVerification.value = false requireEmailVerification.value = false
emailConfigured.value = false
localEnabled.value = true localEnabled.value = true
ldapEnabled.value = false ldapEnabled.value = false
ldapExclusive.value = false ldapExclusive.value = false
authType.value = 'local' authType.value = 'local'
oauthProviders.value = []
} }
}) })
</script> </script>
<style scoped> <style scoped>
.oauth-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
}
.oauth-btn:hover {
background: hsl(var(--muted));
border-color: hsl(var(--primary) / 0.5);
}
.oauth-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.oauth-icon :deep(svg) {
width: 100%;
height: 100%;
}
.oauth-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.oauth-icon-btn:hover {
background: hsl(var(--muted));
border-color: hsl(var(--primary) / 0.5);
transform: translateY(-1px);
}
.oauth-icon-lg {
width: 1.5rem;
height: 1.5rem;
}
.oauth-icon-lg :deep(svg) {
width: 100%;
height: 100%;
}
.auth-type-tabs { .auth-type-tabs {
display: flex; display: flex;
border-bottom: 1px solid hsl(var(--border)); border-bottom: 1px solid hsl(var(--border));
@@ -358,7 +486,7 @@ onMounted(async () => {
.auth-tab { .auth-tab {
flex: 1; flex: 1;
padding: 0.625rem 1rem; padding: 0.5rem 1rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
@@ -385,11 +513,11 @@ onMounted(async () => {
} }
.auth-tab.active { .auth-tab.active {
color: var(--book-cloth); color: hsl(var(--primary));
font-weight: 600; font-weight: 600;
} }
.auth-tab.active::after { .auth-tab.active::after {
background: var(--book-cloth); background: hsl(var(--primary));
} }
</style> </style>

View File

@@ -17,7 +17,7 @@
注册新账户 注册新账户
</h2> </h2>
<p class="mt-1 text-sm text-muted-foreground"> <p class="mt-1 text-sm text-muted-foreground">
请填写您的邮箱和个人信息完成注册 {{ emailConfigured ? '请填写您的信息完成注册' : '请填写用户名和密码完成注册' }}
</p> </p>
</div> </div>
@@ -28,27 +28,40 @@
data-form-type="other" data-form-type="other"
@submit.prevent="handleSubmit" @submit.prevent="handleSubmit"
> >
<!-- Email --> <!-- Email (仅当邮箱服务已配置时显示) -->
<div class="space-y-2"> <div
<Label for="reg-email">邮箱 <span class="text-muted-foreground">*</span></Label> v-if="emailConfigured"
class="space-y-2"
>
<Label for="reg-email">
邮箱
<span
v-if="requireEmailVerification"
class="text-destructive"
>*</span>
<span
v-else
class="text-muted-foreground text-xs"
>可选</span>
</Label>
<Input <Input
id="reg-email" id="reg-email"
v-model="formData.email" v-model="formData.email"
type="email" type="email"
placeholder="hello@example.com" placeholder="hello@example.com"
required :required="requireEmailVerification"
disable-autofill disable-autofill
:disabled="isLoading || emailVerified" :disabled="isLoading || emailVerified"
/> />
</div> </div>
<!-- Verification Code Section --> <!-- Verification Code Section (仅当需要邮箱验证时显示) -->
<div <div
v-if="requireEmailVerification" v-if="emailConfigured && requireEmailVerification"
class="space-y-3" class="space-y-3"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label>验证码 <span class="text-muted-foreground">*</span></Label> <Label>验证码 <span class="text-destructive">*</span></Label>
<Button <Button
type="button" type="button"
variant="link" variant="link"
@@ -113,7 +126,7 @@
<!-- Username --> <!-- Username -->
<div class="space-y-2"> <div class="space-y-2">
<Label for="reg-uname">用户名 <span class="text-muted-foreground">*</span></Label> <Label for="reg-uname">用户名 <span class="text-destructive">*</span></Label>
<Input <Input
id="reg-uname" id="reg-uname"
v-model="formData.username" v-model="formData.username"
@@ -127,7 +140,7 @@
<!-- Password --> <!-- Password -->
<div class="space-y-2"> <div class="space-y-2">
<Label :for="`pwd-${formNonce}`">密码 <span class="text-muted-foreground">*</span></Label> <Label :for="`pwd-${formNonce}`">密码 <span class="text-destructive">*</span></Label>
<Input <Input
:id="`pwd-${formNonce}`" :id="`pwd-${formNonce}`"
v-model="formData.password" v-model="formData.password"
@@ -146,7 +159,7 @@
<!-- Confirm Password --> <!-- Confirm Password -->
<div class="space-y-2"> <div class="space-y-2">
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-muted-foreground">*</span></Label> <Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-destructive">*</span></Label>
<Input <Input
:id="`pwd-confirm-${formNonce}`" :id="`pwd-confirm-${formNonce}`"
v-model="formData.confirmPassword" v-model="formData.confirmPassword"
@@ -210,6 +223,7 @@ import Label from '@/components/ui/label.vue'
interface Props { interface Props {
open?: boolean open?: boolean
requireEmailVerification?: boolean requireEmailVerification?: boolean
emailConfigured?: boolean
} }
interface Emits { interface Emits {
@@ -220,7 +234,8 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
open: false, open: false,
requireEmailVerification: false requireEmailVerification: false,
emailConfigured: true
}) })
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
@@ -353,18 +368,20 @@ const sendCodeButtonText = computed(() => {
}) })
const canSubmit = computed(() => { const canSubmit = computed(() => {
// 基本信息:用户名和密码必填
const hasBasicInfo = const hasBasicInfo =
formData.value.email &&
formData.value.username && formData.value.username &&
formData.value.password && formData.value.password &&
formData.value.confirmPassword formData.value.confirmPassword
if (!hasBasicInfo) return false if (!hasBasicInfo) return false
// If email verification is required, check if verified // 如果需要邮箱验证,邮箱和验证都必须完成
if (props.requireEmailVerification && !emailVerified.value) { if (props.requireEmailVerification) {
if (!formData.value.email || !emailVerified.value) {
return false return false
} }
}
// Check password match // Check password match
if (formData.value.password !== formData.value.confirmPassword) { if (formData.value.password !== formData.value.confirmPassword) {
@@ -608,11 +625,17 @@ const handleSubmit = async () => {
loadingText.value = '注册中...' loadingText.value = '注册中...'
try { try {
const response = await authApi.register({ // 构建请求数据:邮箱可选
email: formData.value.email, const registerData: { email?: string; username: string; password: string } = {
username: formData.value.username, username: formData.value.username,
password: formData.value.password password: formData.value.password
}) }
// 只有当邮箱有值时才添加
if (formData.value.email && formData.value.email.trim()) {
registerData.email = formData.value.email
}
const response = await authApi.register(registerData)
success(response.message || '欢迎加入!请登录以继续', '注册成功') success(response.message || '欢迎加入!请登录以继续', '注册成功')

View File

@@ -118,14 +118,13 @@
<Label <Label
for="form-email" for="form-email"
class="text-sm font-medium" class="text-sm font-medium"
>邮箱 <span class="text-muted-foreground">*</span></Label> >邮箱</Label>
<Input <Input
id="form-email" id="form-email"
v-model="form.email" v-model="form.email"
type="email" type="email"
autocomplete="off" autocomplete="off"
data-form-type="other" data-form-type="other"
required
class="h-10" class="h-10"
/> />
</div> </div>
@@ -472,11 +471,10 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
// 表单验证 // 表单验证
const isFormValid = computed(() => { const isFormValid = computed(() => {
const hasUsername = form.value.username.trim().length > 0 const hasUsername = form.value.username.trim().length > 0
const hasEmail = form.value.email.trim().length > 0
const hasPassword = isEditMode.value || form.value.password.length >= 6 const hasPassword = isEditMode.value || form.value.password.length >= 6
// 编辑模式下如果填写了密码,必须确认密码一致 // 编辑模式下如果填写了密码,必须确认密码一致
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
return hasUsername && hasEmail && hasPassword && passwordConfirmed return hasUsername && hasPassword && passwordConfirmed
}) })
// 加载访问控制选项 // 加载访问控制选项
@@ -508,16 +506,11 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a
// 提交表单 // 提交表单
async function handleSubmit() { async function handleSubmit() {
// 验证邮箱必填
if (!form.value.email || !form.value.email.trim()) {
return
}
saving.value = true saving.value = true
try { try {
const data: UserFormData & { password?: string; unlimited?: boolean } = { const data: UserFormData & { password?: string; unlimited?: boolean } = {
username: form.value.username, username: form.value.username,
email: form.value.email.trim(), email: form.value.email.trim() || '',
quota_usd: form.value.unlimited ? null : form.value.quota, quota_usd: form.value.unlimited ? null : form.value.quota,
role: form.value.role, role: form.value.role,
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null, allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,

View File

@@ -362,7 +362,9 @@ import {
X, X,
Mail, Mail,
Puzzle, Puzzle,
type LucideIcon,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import GithubIcon from '@/components/icons/GithubIcon.vue' import GithubIcon from '@/components/icons/GithubIcon.vue'
const router = useRouter() const router = useRouter()
@@ -497,7 +499,7 @@ const navigation = computed(() => {
] ]
// 系统菜单项(静态部分) // 系统菜单项(静态部分)
const systemItems = [ const systemItems: { name: string; href: string; icon: LucideIcon }[] = [
{ name: '公告管理', href: '/admin/announcements', icon: Megaphone }, { name: '公告管理', href: '/admin/announcements', icon: Megaphone },
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge }, { name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield }, { name: 'IP 安全', href: '/admin/ip-security', icon: Shield },

View File

@@ -19,6 +19,12 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/public/LogoColorDemo.vue')), component: () => importWithRetry(() => import('@/views/public/LogoColorDemo.vue')),
meta: { requiresAuth: false } meta: { requiresAuth: false }
}, },
{
path: '/auth/callback',
name: 'AuthCallback',
component: () => importWithRetry(() => import('@/views/public/AuthCallback.vue')),
meta: { requiresAuth: false }
},
{ {
path: '/dashboard', path: '/dashboard',
@@ -133,6 +139,12 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue')), component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue')),
meta: { module: 'ldap' } meta: { module: 'ldap' }
}, },
{
path: 'oauth',
name: 'OAuthSettings',
component: () => importWithRetry(() => import('@/views/admin/OAuthSettings.vue')),
meta: { module: 'oauth' }
},
{ {
path: 'audit-logs', path: 'audit-logs',
name: 'AuditLogs', name: 'AuditLogs',
@@ -234,9 +246,10 @@ router.beforeEach(async (to, from, next) => {
return return
} }
} }
// 如果模块未激活available && enabled),重定向到管理员首页 // 如果模块不可用(未部署),重定向到管理员首页
if (!moduleStore.isActive(moduleName)) { // 注意:只检查 available不检查 enabled/active允许管理员配置未启用的模块
log.warn(`Module ${moduleName} is not active, redirecting to admin dashboard`) if (!moduleStore.isAvailable(moduleName)) {
log.warn(`Module ${moduleName} is not available, redirecting to admin dashboard`)
next('/admin/dashboard') next('/admin/dashboard')
return return
} }

View File

@@ -52,6 +52,7 @@ export const useModuleStore = defineStore('modules', () => {
/** /**
* 设置模块启用状态 * 设置模块启用状态
* @throws 如果设置失败会抛出错误
*/ */
async function setEnabled(moduleName: string, enabled: boolean) { async function setEnabled(moduleName: string, enabled: boolean) {
try { try {
@@ -62,7 +63,8 @@ export const useModuleStore = defineStore('modules', () => {
} catch (err: any) { } catch (err: any) {
log.error(`Failed to set module ${moduleName} enabled=${enabled}`, err) log.error(`Failed to set module ${moduleName} enabled=${enabled}`, err)
error.value = err.response?.data?.detail || '设置模块状态失败' error.value = err.response?.data?.detail || '设置模块状态失败'
return false // 重新抛出错误,让调用方可以获取详细错误信息
throw err
} }
} }

View File

@@ -12,6 +12,7 @@ export interface ApiErrorResponse {
error?: { error?: {
type?: string type?: string
message?: string message?: string
details?: Record<string, unknown>
} }
detail?: string detail?: string
message?: string message?: string
@@ -57,9 +58,12 @@ export function getErrorMessage(error: unknown, defaultMessage = '操作失败')
if (error.response?.data?.message) { if (error.response?.data?.message) {
return error.response.data.message return error.response.data.message
} }
// API 错误但没有可用的错误消息,返回默认消息
// 不使用 error.message因为那是 Axios 的默认消息如 "Request failed with status code 400"
return defaultMessage
} }
// Error 实例 // 非 API 错误的 Error 实例
if (error instanceof Error) { if (error instanceof Error) {
return error.message return error.message
} }

11
frontend/src/utils/url.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* 构建完整的 API URL
*
* 用于需要完整 URL 的场景(如 OAuth 重定向),
* 处理 VITE_API_URL 环境变量和路径拼接。
*/
export function getApiUrl(path: string): string {
const base = import.meta.env.VITE_API_URL || ''
// 移除 base 尾部的 `/`,避免拼接成 `//api/...`
return base ? `${base.replace(/\/$/, '')}${path}` : path
}

View File

@@ -99,40 +99,13 @@
> >
SMTP 密码 SMTP 密码
</Label> </Label>
<div class="relative mt-1"> <div class="mt-1">
<Input <Input
id="smtp-password" id="smtp-password"
v-model="emailConfig.smtp_password" v-model="emailConfig.smtp_password"
type="text" masked
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'" :placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
class="-webkit-text-security-disc"
: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 || emailConfig.smtp_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"
title="清除密码"
@click="handleClearSmtpPassword"
>
<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"
>
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div> </div>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
邮箱密码或应用专用密码 邮箱密码或应用专用密码
@@ -213,6 +186,116 @@
</div> </div>
</CardSection> </CardSection>
<!-- 注册邮箱验证 -->
<CardSection
title="注册邮箱验证"
description="控制用户注册时的邮箱验证要求和后缀限制"
>
<template #actions>
<Button
size="sm"
:disabled="emailVerificationSaveLoading"
@click="saveEmailVerificationConfig"
>
{{ emailVerificationSaveLoading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="space-y-6">
<!-- 第一行需要邮箱验证 + 后缀限制模式 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 需要邮箱验证 -->
<div class="flex items-center justify-between h-full">
<div>
<Label
for="require-email-verification"
class="block text-sm font-medium cursor-pointer"
:class="{ 'text-muted-foreground': !smtpConfigured }"
>
需要邮箱验证
</Label>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="!smtpConfigured">
需先配置 SMTP 服务
</template>
<template v-else>
开启后用户注册时必须验证邮箱
</template>
</p>
</div>
<Switch
id="require-email-verification"
v-model="requireEmailVerification"
:disabled="!smtpConfigured"
/>
</div>
<!-- 后缀限制模式 -->
<div>
<Label
for="email-suffix-mode"
class="block text-sm font-medium mb-2"
>
后缀限制模式
</Label>
<Select
v-model="emailConfig.email_suffix_mode"
v-model:open="emailSuffixModeSelectOpen"
:disabled="!requireEmailVerification"
>
<SelectTrigger
id="email-suffix-mode"
class="mt-1"
:disabled="!requireEmailVerification"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
不限制 - 允许所有邮箱
</SelectItem>
<SelectItem value="whitelist">
白名单 - 仅允许列出的后缀
</SelectItem>
<SelectItem value="blacklist">
黑名单 - 拒绝列出的后缀
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="emailConfig.email_suffix_mode === 'none'">
不限制邮箱后缀所有邮箱均可注册
</template>
<template v-else-if="emailConfig.email_suffix_mode === 'whitelist'">
仅允许列出后缀的邮箱注册
</template>
<template v-else>
拒绝列出后缀的邮箱注册
</template>
</p>
</div>
</div>
<!-- 第二行邮箱后缀列表 -->
<div v-if="emailConfig.email_suffix_mode !== 'none'">
<Label
for="email-suffix-list"
class="block text-sm font-medium mb-2"
>
邮箱后缀列表
</Label>
<Input
id="email-suffix-list"
v-model="emailSuffixListStr"
placeholder="gmail.com, outlook.com, qq.com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
逗号分隔例如: gmail.com, outlook.com, qq.com
</p>
</div>
</div>
</CardSection>
<!-- 邮件模板配置 --> <!-- 邮件模板配置 -->
<CardSection <CardSection
title="邮件模板" title="邮件模板"
@@ -227,15 +310,22 @@
{{ templateSaveLoading ? '保存中...' : '保存' }} {{ templateSaveLoading ? '保存中...' : '保存' }}
</Button> </Button>
</template> </template>
<!-- 模板类型选择 -->
<div class="flex items-center gap-2 mb-4"> <!-- 当前模板编辑区 -->
<div
v-if="currentTemplate"
class="space-y-4"
>
<!-- 模板类型选择 + 可用变量 -->
<div class="flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center border-b border-border">
<button <button
v-for="tpl in templateTypes" v-for="tpl in templateTypes"
:key="tpl.type" :key="tpl.type"
class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors" class="px-4 py-2 text-sm font-medium transition-colors relative"
:class="activeTemplateType === tpl.type :class="activeTemplateType === tpl.type
? 'bg-primary text-primary-foreground' ? 'text-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'" : 'text-muted-foreground hover:text-foreground'"
@click="handleTemplateTypeChange(tpl.type)" @click="handleTemplateTypeChange(tpl.type)"
> >
{{ tpl.name }} {{ tpl.name }}
@@ -243,23 +333,21 @@
v-if="tpl.is_custom" v-if="tpl.is_custom"
class="ml-1 text-xs opacity-70" class="ml-1 text-xs opacity-70"
>(已自定义)</span> >(已自定义)</span>
<span
v-if="activeTemplateType === tpl.type"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
/>
</button> </button>
</div> </div>
<div class="text-xs text-muted-foreground">
<!-- 当前模板编辑区 -->
<div
v-if="currentTemplate"
class="space-y-4"
>
<!-- 可用变量提示 -->
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
可用变量: 可用变量:
<code <code
v-for="(v, i) in currentTemplate.variables" v-for="(v, i) in currentTemplate.variables"
:key="v" :key="v"
class="mx-1 px-1.5 py-0.5 bg-background rounded text-foreground" class="mx-0.5 px-1.5 py-0.5 bg-muted rounded text-foreground"
>{{ formatVariable(v) }}<span v-if="i < currentTemplate.variables.length - 1">,</span></code> >{{ formatVariable(v) }}<span v-if="i < currentTemplate.variables.length - 1">,</span></code>
</div> </div>
</div>
<!-- 邮件主题 --> <!-- 邮件主题 -->
<div> <div>
@@ -289,7 +377,7 @@
<textarea <textarea
id="template-html" id="template-html"
v-model="templateHtml" v-model="templateHtml"
rows="16" rows="12"
class="mt-1 w-full font-mono text-sm bg-muted/30 border border-border rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y" class="mt-1 w-full font-mono text-sm bg-muted/30 border border-border rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y"
:placeholder="currentTemplate.default_html || '<!DOCTYPE html>...'" :placeholder="currentTemplate.default_html || '<!DOCTYPE html>...'"
spellcheck="false" spellcheck="false"
@@ -300,6 +388,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm"
:disabled="previewLoading" :disabled="previewLoading"
@click="handlePreviewTemplate" @click="handlePreviewTemplate"
> >
@@ -307,6 +396,7 @@
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm"
:disabled="!currentTemplate.is_custom" :disabled="!currentTemplate.is_custom"
@click="handleResetTemplate" @click="handleResetTemplate"
> >
@@ -378,83 +468,6 @@
</div> </div>
</div> </div>
</Dialog> </Dialog>
<!-- 注册邮箱限制 -->
<CardSection
title="注册邮箱限制"
description="控制允许注册的邮箱后缀,支持白名单或黑名单模式"
>
<template #actions>
<Button
size="sm"
:disabled="emailSuffixSaveLoading"
@click="saveEmailSuffixConfig"
>
{{ emailSuffixSaveLoading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="space-y-4">
<div>
<Label
for="email-suffix-mode"
class="block text-sm font-medium mb-2"
>
限制模式
</Label>
<Select
v-model="emailConfig.email_suffix_mode"
v-model:open="emailSuffixModeSelectOpen"
>
<SelectTrigger
id="email-suffix-mode"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
不限制 - 允许所有邮箱
</SelectItem>
<SelectItem value="whitelist">
白名单 - 仅允许列出的后缀
</SelectItem>
<SelectItem value="blacklist">
黑名单 - 拒绝列出的后缀
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="emailConfig.email_suffix_mode === 'none'">
不限制邮箱后缀所有邮箱均可注册
</template>
<template v-else-if="emailConfig.email_suffix_mode === 'whitelist'">
仅允许下方列出后缀的邮箱注册
</template>
<template v-else>
拒绝下方列出后缀的邮箱注册
</template>
</p>
</div>
<div v-if="emailConfig.email_suffix_mode !== 'none'">
<Label
for="email-suffix-list"
class="block text-sm font-medium"
>
邮箱后缀列表
</Label>
<Input
id="email-suffix-list"
v-model="emailSuffixListStr"
placeholder="gmail.com, outlook.com, qq.com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
逗号分隔例如: gmail.com, outlook.com, qq.com
</p>
</div>
</div>
</CardSection>
</div> </div>
</PageContainer> </PageContainer>
</template> </template>
@@ -464,6 +477,7 @@ import { ref, computed, onMounted } from 'vue'
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 Switch from '@/components/ui/switch.vue'
import Select from '@/components/ui/select.vue' import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue' import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue' import SelectValue from '@/components/ui/select-value.vue'
@@ -473,6 +487,7 @@ import Dialog from '@/components/ui/dialog/Dialog.vue'
import { PageHeader, PageContainer, CardSection } from '@/components/layout' import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { adminApi, type EmailTemplateInfo } from '@/api/admin' import { adminApi, type EmailTemplateInfo } from '@/api/admin'
import { authApi } from '@/api/auth'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
const { success, error } = useToast() const { success, error } = useToast()
@@ -493,12 +508,13 @@ interface EmailConfig {
} }
const smtpSaveLoading = ref(false) const smtpSaveLoading = ref(false)
const emailSuffixSaveLoading = ref(false) const emailVerificationSaveLoading = ref(false)
const smtpEncryptionSelectOpen = ref(false) const smtpEncryptionSelectOpen = ref(false)
const emailSuffixModeSelectOpen = ref(false) const emailSuffixModeSelectOpen = ref(false)
const testSmtpLoading = ref(false) const testSmtpLoading = ref(false)
const smtpPasswordIsSet = ref(false) const smtpPasswordIsSet = ref(false)
const clearSmtpPassword = ref(false) // 标记是否要清除密码 const requireEmailVerification = ref(false) // 是否开启了邮箱验证
const smtpConfigured = ref(false) // SMTP 是否已配置
// 邮件模板相关状态 // 邮件模板相关状态
const templateLoading = ref(false) const templateLoading = ref(false)
@@ -583,10 +599,57 @@ const smtpEncryption = computed({
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadEmailConfig(), loadEmailConfig(),
loadEmailTemplates() loadEmailTemplates(),
loadRequireEmailVerification(),
]) ])
}) })
async function loadRequireEmailVerification() {
try {
const settings = await authApi.getRegistrationSettings()
requireEmailVerification.value = !!settings.require_email_verification
smtpConfigured.value = !!settings.email_configured
} catch {
requireEmailVerification.value = false
smtpConfigured.value = false
}
}
async function saveEmailVerificationConfig() {
emailVerificationSaveLoading.value = true
try {
const configItems = [
{
key: 'require_email_verification',
value: requireEmailVerification.value,
description: '是否需要邮箱验证'
},
{
key: 'email_suffix_mode',
value: emailConfig.value.email_suffix_mode,
description: '邮箱后缀限制模式'
},
{
key: 'email_suffix_list',
value: emailConfig.value.email_suffix_list,
description: '邮箱后缀列表'
},
]
await Promise.all(
configItems.map(item =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
)
success('配置已保存')
} catch (err) {
error('保存配置失败')
log.error('保存邮箱验证配置失败:', err)
} finally {
emailVerificationSaveLoading.value = false
}
}
async function loadEmailTemplates() { async function loadEmailTemplates() {
templateLoading.value = true templateLoading.value = true
try { try {
@@ -711,7 +774,6 @@ async function loadEmailConfig() {
// 配置不存在时使用默认值,无需处理 // 配置不存在时使用默认值,无需处理
} }
} }
clearSmtpPassword.value = false
} catch (err) { } catch (err) {
error('加载邮件配置失败') error('加载邮件配置失败')
log.error('加载邮件配置失败:', err) log.error('加载邮件配置失败:', err)
@@ -722,12 +784,6 @@ async function loadEmailConfig() {
async function saveSmtpConfig() { async function saveSmtpConfig() {
smtpSaveLoading.value = true smtpSaveLoading.value = true
try { try {
const passwordAction: 'unchanged' | 'updated' | 'cleared' = emailConfig.value.smtp_password
? 'updated'
: clearSmtpPassword.value
? 'cleared'
: 'unchanged'
const configItems = [ const configItems = [
{ {
key: 'smtp_host', key: 'smtp_host',
@@ -745,7 +801,7 @@ async function saveSmtpConfig() {
description: 'SMTP 用户名' description: 'SMTP 用户名'
}, },
// 只有输入了新密码才提交(空值表示保持原密码) // 只有输入了新密码才提交(空值表示保持原密码)
...(passwordAction === 'updated' ...(emailConfig.value.smtp_password
? [{ ? [{
key: 'smtp_password', key: 'smtp_password',
value: emailConfig.value.smtp_password, value: emailConfig.value.smtp_password,
@@ -774,24 +830,15 @@ async function saveSmtpConfig() {
}, },
] ]
const promises = configItems.map(item => await Promise.all(
configItems.map(item =>
adminApi.updateSystemConfig(item.key, item.value, item.description) adminApi.updateSystemConfig(item.key, item.value, item.description)
) )
)
// 如果标记了清除密码,删除密码配置
if (passwordAction === 'cleared') {
promises.push(adminApi.deleteSystemConfig('smtp_password'))
}
await Promise.all(promises)
success('SMTP 配置已保存') success('SMTP 配置已保存')
// 更新状态 // 更新状态
if (passwordAction === 'cleared') { if (emailConfig.value.smtp_password) {
clearSmtpPassword.value = false
smtpPasswordIsSet.value = false
} else if (passwordAction === 'updated') {
clearSmtpPassword.value = false
smtpPasswordIsSet.value = true smtpPasswordIsSet.value = true
} }
emailConfig.value.smtp_password = null emailConfig.value.smtp_password = null
@@ -803,51 +850,6 @@ async function saveSmtpConfig() {
} }
} }
// 保存邮箱后缀限制配置
async function saveEmailSuffixConfig() {
emailSuffixSaveLoading.value = true
try {
const configItems = [
{
key: 'email_suffix_mode',
value: emailConfig.value.email_suffix_mode,
description: '邮箱后缀限制模式none/whitelist/blacklist'
},
{
key: 'email_suffix_list',
value: emailConfig.value.email_suffix_list,
description: '邮箱后缀列表'
},
]
const promises = configItems.map(item =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
await Promise.all(promises)
success('邮箱限制配置已保存')
} catch (err) {
error('保存配置失败')
log.error('保存邮箱限制配置失败:', err)
} finally {
emailSuffixSaveLoading.value = false
}
}
// 清除 SMTP 密码
function handleClearSmtpPassword() {
// 如果有输入内容,先清空输入框
if (emailConfig.value.smtp_password) {
emailConfig.value.smtp_password = null
return
}
// 标记要清除服务端密码(保存时生效)
if (smtpPasswordIsSet.value) {
clearSmtpPassword.value = true
smtpPasswordIsSet.value = false
}
}
// 测试 SMTP 连接 // 测试 SMTP 连接
async function handleTestSmtp() { async function handleTestSmtp() {
testSmtpLoading.value = true testSmtpLoading.value = true

View File

@@ -76,44 +76,14 @@
> >
绑定密码 绑定密码
</Label> </Label>
<div class="relative mt-1"> <div class="mt-1">
<Input <Input
id="bind-password" id="bind-password"
v-model="ldapConfig.bind_password" v-model="ldapConfig.bind_password"
type="password" masked
:placeholder="hasPassword ? '已设置(留空保持不变)' : '请输入密码'" :placeholder="hasPassword ? '已设置(留空保持不变)' : '请输入密码'"
:class="(hasPassword || ldapConfig.bind_password) ? 'pr-10' : ''"
autocomplete="new-password" 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"
title="清除密码"
@click="handleClearPassword"
>
<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> </div>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
绑定账号的密码 绑定账号的密码
@@ -280,7 +250,6 @@ const loading = ref(false)
const saveLoading = ref(false) const saveLoading = ref(false)
const testLoading = ref(false) const testLoading = ref(false)
const hasPassword = ref(false) const hasPassword = ref(false)
const clearPassword = ref(false) // 标记是否要清除密码
const ldapConfig = ref({ const ldapConfig = ref({
server_url: '', server_url: '',
@@ -320,7 +289,6 @@ async function loadConfig() {
connect_timeout: response.connect_timeout || 10, connect_timeout: response.connect_timeout || 10,
} }
hasPassword.value = !!response.has_bind_password hasPassword.value = !!response.has_bind_password
clearPassword.value = false
} catch (err) { } catch (err) {
error('加载 LDAP 配置失败') error('加载 LDAP 配置失败')
console.error('加载 LDAP 配置失败:', err) console.error('加载 LDAP 配置失败:', err)
@@ -346,25 +314,16 @@ async function handleSave() {
connect_timeout: ldapConfig.value.connect_timeout, connect_timeout: ldapConfig.value.connect_timeout,
} }
// 优先使用输入的新密码;否则如果标记清除则发送空字符串 // 只有输入了新密码才更新密码
let passwordAction: 'unchanged' | 'updated' | 'cleared' = 'unchanged'
if (ldapConfig.value.bind_password) { if (ldapConfig.value.bind_password) {
payload.bind_password = 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) await adminApi.updateLdapConfig(payload)
success('LDAP 配置保存成功') success('LDAP 配置保存成功')
if (passwordAction === 'cleared') { if (ldapConfig.value.bind_password) {
hasPassword.value = false
clearPassword.value = false
} else if (passwordAction === 'updated') {
hasPassword.value = true hasPassword.value = true
clearPassword.value = false
} }
ldapConfig.value.bind_password = '' ldapConfig.value.bind_password = ''
} catch (err) { } catch (err) {
@@ -376,11 +335,6 @@ async function handleSave() {
} }
async function handleTestConnection() { async function handleTestConnection() {
if (clearPassword.value && !ldapConfig.value.bind_password) {
error('已标记清除绑定密码,请先保存或输入新的绑定密码再测试')
return
}
testLoading.value = true testLoading.value = true
try { try {
const payload: LdapConfigUpdateRequest = { const payload: LdapConfigUpdateRequest = {
@@ -410,17 +364,4 @@ async function handleTestConnection() {
testLoading.value = false 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> </script>

View File

@@ -55,25 +55,17 @@
</div> </div>
<!-- 模块图标和名称 --> <!-- 模块图标和名称 -->
<div class="flex items-start gap-4 mb-4"> <div class="flex items-start gap-4 mb-3">
<div <div
class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-colors" class="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-colors"
:class="module.active :class="module.active
? 'bg-primary/15 text-primary' ? 'bg-primary/15 text-primary'
: 'bg-muted text-muted-foreground group-hover:bg-muted/80'" : 'bg-muted text-muted-foreground group-hover:bg-muted/80'"
> >
<component :is="getCategoryIcon(module.category)" class="w-6 h-6" /> <component :is="getCategoryIcon(module.category)" class="w-5 h-5" />
</div> </div>
<div class="flex-1 min-w-0 pt-0.5"> <div class="flex-1 min-w-0 pt-1">
<h4 class="font-semibold text-base truncate">{{ module.display_name }}</h4> <h4 class="font-semibold text-base truncate">{{ module.display_name }}</h4>
<div class="mt-1.5">
<Badge
:variant="getStatusBadgeVariant(module)"
class="text-xs"
>
{{ getStatusText(module) }}
</Badge>
</div>
</div> </div>
</div> </div>
@@ -82,21 +74,6 @@
{{ module.description }} {{ module.description }}
</p> </p>
<!-- 模块信息 -->
<div class="mt-4 pt-4 border-t border-border/50 space-y-2">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="font-mono bg-muted/50 px-1.5 py-0.5 rounded">{{ module.name }}</span>
<span class="text-border">|</span>
<span :class="{
'text-green-600': module.health === 'healthy',
'text-amber-600': module.health === 'degraded',
'text-red-600': module.health === 'unhealthy',
}">
{{ getHealthText(module.health) }}
</span>
</div>
</div>
<!-- 不可用提示 --> <!-- 不可用提示 -->
<div <div
v-if="!module.available" v-if="!module.available"
@@ -110,15 +87,24 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Switch <Switch
:model-value="module.enabled" :model-value="module.enabled"
:disabled="!module.available || toggling[module.name]" :disabled="!module.available || !module.config_validated || toggling[module.name]"
@update:model-value="(val: boolean) => toggleModule(module.name, val)" @update:model-value="(val: boolean) => toggleModule(module.name, val)"
/> />
<div class="flex flex-col">
<span class="text-sm" :class="module.enabled ? 'text-foreground' : 'text-muted-foreground'"> <span class="text-sm" :class="module.enabled ? 'text-foreground' : 'text-muted-foreground'">
{{ module.enabled ? '已启用' : '已禁用' }} {{ module.enabled ? '已启用' : '已禁用' }}
</span> </span>
<!-- 配置未验证提示(小字) -->
<span
v-if="module.available && !module.config_validated"
class="text-xs text-muted-foreground"
>
{{ module.config_error || '请先完成配置' }}
</span>
</div>
</div> </div>
<Button <Button
v-if="module.admin_route && module.active" v-if="module.admin_route"
variant="outline" variant="outline"
size="sm" size="sm"
class="gap-1.5" class="gap-1.5"
@@ -157,14 +143,13 @@ import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { RefreshCw, Puzzle, Users, Shield, Gauge, Link, Search, Settings } from 'lucide-vue-next' import { RefreshCw, Puzzle, Users, Shield, Gauge, Link, Search, Settings } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Switch from '@/components/ui/switch.vue' import Switch from '@/components/ui/switch.vue'
import Input from '@/components/ui/input.vue' import Input from '@/components/ui/input.vue'
import { PageHeader, PageContainer } from '@/components/layout' import { PageHeader, PageContainer } from '@/components/layout'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useModuleStore } from '@/stores/modules' import { useModuleStore } from '@/stores/modules'
import type { ModuleStatus } from '@/api/modules'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { getErrorMessage } from '@/types/api-error'
const router = useRouter() const router = useRouter()
const { success, error } = useToast() const { success, error } = useToast()
@@ -185,33 +170,6 @@ function getCategoryIcon(category: string) {
return icons[category] || Puzzle return icons[category] || Puzzle
} }
// 获取状态文本
function getStatusText(module: ModuleStatus): string {
if (!module.available) return '不可用'
if (module.active) return '已激活'
if (module.enabled) return '已启用'
return '已禁用'
}
// 获取状态徽章样式
function getStatusBadgeVariant(module: ModuleStatus): 'default' | 'secondary' | 'outline' | 'destructive' {
if (!module.available) return 'destructive'
if (module.active) return 'default'
if (module.enabled) return 'secondary'
return 'outline'
}
// 获取健康状态文本
function getHealthText(health: string): string {
const texts: Record<string, string> = {
healthy: '健康',
degraded: '降级',
unhealthy: '异常',
unknown: '未知',
}
return texts[health] || health
}
// 所有模块列表(按 admin_menu_order 排序) // 所有模块列表(按 admin_menu_order 排序)
const allModules = computed(() => { const allModules = computed(() => {
return Object.values(moduleStore.modules) return Object.values(moduleStore.modules)
@@ -249,14 +207,10 @@ async function fetchModules() {
async function toggleModule(moduleName: string, enabled: boolean) { async function toggleModule(moduleName: string, enabled: boolean) {
toggling.value[moduleName] = true toggling.value[moduleName] = true
try { try {
const result = await moduleStore.setEnabled(moduleName, enabled) await moduleStore.setEnabled(moduleName, enabled)
if (result) {
success(enabled ? '模块已启用' : '模块已禁用') success(enabled ? '模块已启用' : '模块已禁用')
} else {
error('操作失败')
}
} catch (err) { } catch (err) {
error('操作失败') error(getErrorMessage(err, '操作失败'))
log.error('切换模块状态失败:', err) log.error('切换模块状态失败:', err)
} finally { } finally {
toggling.value[moduleName] = false toggling.value[moduleName] = false

View File

@@ -0,0 +1,454 @@
<template>
<PageContainer>
<PageHeader
title="OAuth 配置"
description="配置 OAuth Providers登录/绑定)"
>
<template #actions>
<Button
variant="outline"
:disabled="loading"
@click="loadAll"
>
刷新
</Button>
</template>
</PageHeader>
<div class="mt-6">
<!-- Provider 选择 Tab -->
<div class="flex flex-wrap gap-2 mb-6">
<button
v-for="t in supportedTypes"
:key="t.provider_type"
class="flex items-center gap-3 px-4 py-2 rounded-lg text-sm font-medium transition-colors border"
:class="selectedType === t.provider_type
? 'border-primary text-primary'
: 'border-border text-muted-foreground hover:border-primary/50 hover:text-foreground'"
@click="handleTabClick(t.provider_type)"
>
<div class="flex flex-col items-center leading-none">
<span>{{ t.display_name }}</span>
<span class="text-[10px] text-muted-foreground">
{{ configs[t.provider_type]
? (configs[t.provider_type]?.is_enabled ? '点击禁用' : '点击启用')
: '未配置' }}
</span>
</div>
<span
class="w-2 h-2 rounded-full"
:class="configs[t.provider_type]?.is_enabled ? 'bg-green-500' : 'bg-gray-300'"
/>
</button>
</div>
<!-- Provider 提示 -->
<div
v-if="supportedTypes.length === 0 && !loading"
class="text-center py-12 text-muted-foreground"
>
未发现可用的 OAuth Provider
</div>
<!-- 配置表单 -->
<CardSection
v-if="selectedType"
:title="selectedTypeMeta?.display_name || selectedType"
:description="configs[selectedType]?.is_enabled ? '已启用' : '未配置'"
>
<template #actions>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
:disabled="saving || testing"
@click="handleTest"
>
{{ testing ? '测试中...' : '测试' }}
</Button>
<Button
size="sm"
:disabled="saving"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</div>
</template>
<div class="space-y-6">
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Client ID</Label>
<Input
v-model="form.client_id"
class="mt-1"
placeholder="client_id"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Client Secret</Label>
<Input
v-model="form.client_secret"
masked
class="mt-1"
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
/>
</div>
</div>
<!-- 回调地址 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Redirect URI后端回调</Label>
<Input
v-model="form.redirect_uri"
class="mt-1"
placeholder="http://localhost:8084/api/oauth/xxx/callback"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">前端回调页</Label>
<Input
v-model="form.frontend_callback_url"
class="mt-1"
placeholder="http://localhost:5173/auth/callback"
autocomplete="off"
/>
</div>
</div>
<!-- 高级选项折叠 -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
高级选项
</summary>
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
<div>
<Label class="block text-sm font-medium">Scopes</Label>
<Input
v-model="form.scopes_input"
class="mt-1"
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
空格/逗号分隔留空使用默认值
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
autocomplete="off"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Attribute Mapping</Label>
<Textarea
v-model="form.attribute_mapping_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder='{"id": "user_id", "username": "login"}'
/>
</div>
<div>
<Label class="block text-sm font-medium">Extra Config</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder='{"min_trust_level": 1}'
/>
</div>
</div>
</div>
</details>
</div>
<!-- 测试结果 -->
<div
v-if="lastTestResult"
class="mt-6 rounded-lg border border-border p-4 text-sm"
>
<div class="font-medium mb-2">
测试结果
</div>
<div class="flex flex-wrap gap-4 text-xs">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="lastTestResult.authorization_url_reachable ? 'bg-green-500' : 'bg-red-500'"
/>
<span class="text-muted-foreground">Authorization URL</span>
</div>
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="lastTestResult.token_url_reachable ? 'bg-green-500' : 'bg-red-500'"
/>
<span class="text-muted-foreground">Token URL</span>
</div>
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="lastTestResult.secret_status === 'likely_valid' ? 'bg-green-500' : lastTestResult.secret_status === 'invalid' ? 'bg-red-500' : 'bg-yellow-500'"
/>
<span class="text-muted-foreground">Secret: {{ lastTestResult.secret_status }}</span>
</div>
<span
v-if="lastTestResult.details"
class="text-muted-foreground"
>
{{ lastTestResult.details }}
</span>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { oauthApi, type OAuthProviderAdminConfig, type OAuthProviderTestResponse, type SupportedOAuthType } from '@/api/oauth'
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Textarea from '@/components/ui/textarea.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { log } from '@/utils/logger'
import { getErrorMessage, getErrorStatus, isApiError } from '@/types/api-error'
const { success, error: showError } = useToast()
const { confirmWarning } = useConfirm()
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const supportedTypes = ref<SupportedOAuthType[]>([])
const configs = ref<Record<string, OAuthProviderAdminConfig>>({})
const selectedType = ref<string>('')
const lastTestResult = ref<OAuthProviderTestResponse | null>(null)
const form = ref({
client_id: '',
client_secret: '',
authorization_url_override: '',
token_url_override: '',
userinfo_url_override: '',
scopes_input: '',
redirect_uri: '',
frontend_callback_url: '',
attribute_mapping_json: '',
extra_config_json: '',
})
const hasSecret = computed(() => !!configs.value[selectedType.value]?.has_secret)
const selectedTypeMeta = computed(() => supportedTypes.value.find((t) => t.provider_type === selectedType.value))
function defaultRedirectUri(providerType: string): string {
return new URL(`/api/oauth/${providerType}/callback`, window.location.origin).toString()
}
function defaultFrontendCallbackUrl(): string {
return new URL('/auth/callback', window.location.origin).toString()
}
function parseScopes(input: string): string[] | null {
const raw = input.trim()
if (!raw) return null
const parts = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean)
return parts.length ? parts : null
}
function parseJsonOrNull(input: string): Record<string, any> | null {
const raw = input.trim()
if (!raw) return null
return JSON.parse(raw)
}
function handleTabClick(providerType: string) {
// 如果点击的是当前选中的 Provider且已配置则切换启用状态
if (selectedType.value === providerType && configs.value[providerType]) {
toggleProviderEnabled(providerType, !configs.value[providerType].is_enabled)
return
}
// 否则切换到该 Provider
selectedType.value = providerType
syncFormFromSelected()
}
function syncFormFromSelected() {
lastTestResult.value = null
const cfg = configs.value[selectedType.value]
form.value = {
client_id: cfg?.client_id || '',
client_secret: '',
authorization_url_override: cfg?.authorization_url_override || '',
token_url_override: cfg?.token_url_override || '',
userinfo_url_override: cfg?.userinfo_url_override || '',
scopes_input: (cfg?.scopes || []).join(' '),
redirect_uri: cfg?.redirect_uri || defaultRedirectUri(selectedType.value),
frontend_callback_url: cfg?.frontend_callback_url || defaultFrontendCallbackUrl(),
attribute_mapping_json: cfg?.attribute_mapping ? JSON.stringify(cfg.attribute_mapping, null, 2) : '',
extra_config_json: cfg?.extra_config ? JSON.stringify(cfg.extra_config, null, 2) : '',
}
}
async function toggleProviderEnabled(providerType: string, enabled: boolean, force = false) {
const cfg = configs.value[providerType]
if (!cfg) {
showError('请先保存配置后再启用')
return
}
saving.value = true
try {
const payload = {
display_name: cfg.display_name,
client_id: cfg.client_id,
redirect_uri: cfg.redirect_uri,
frontend_callback_url: cfg.frontend_callback_url,
is_enabled: enabled,
force,
}
await oauthApi.admin.upsertProviderConfig(providerType, payload)
success(enabled ? '已启用' : '已禁用')
await loadAll()
} catch (err: unknown) {
// 检查是否是需要确认的冲突错误
if (isApiError(err) && getErrorStatus(err) === 409) {
const errorData = err.response?.data?.error
if (errorData?.type === 'confirmation_required') {
const affectedCount = errorData.details?.affected_count ?? 0
const confirmed = await confirmWarning(
`禁用该 Provider 会导致 ${affectedCount} 个用户无法登录,是否继续?`,
'确认禁用'
)
if (confirmed) {
await toggleProviderEnabled(providerType, enabled, true)
}
return
}
}
showError(getErrorMessage(err, '操作失败'))
} finally {
saving.value = false
}
}
async function loadAll() {
loading.value = true
try {
const [types, list] = await Promise.all([
oauthApi.admin.getSupportedTypes(),
oauthApi.admin.listProviderConfigs(),
])
supportedTypes.value = types
configs.value = Object.fromEntries(list.map((c) => [c.provider_type, c]))
if (!selectedType.value && supportedTypes.value.length > 0) {
selectedType.value = supportedTypes.value[0].provider_type
}
if (selectedType.value) {
syncFormFromSelected()
}
} catch (err: any) {
log.error('加载 OAuth 配置失败:', err)
showError(getErrorMessage(err, '加载失败'))
} finally {
loading.value = false
}
}
async function handleSave() {
if (!selectedType.value) return
saving.value = true
lastTestResult.value = null
try {
const typeMeta = supportedTypes.value.find((t) => t.provider_type === selectedType.value)
const existingConfig = configs.value[selectedType.value]
const payload = {
display_name: typeMeta?.display_name || selectedType.value,
client_id: form.value.client_id.trim(),
client_secret: form.value.client_secret.trim() || undefined,
authorization_url_override: form.value.authorization_url_override.trim() || null,
token_url_override: form.value.token_url_override.trim() || null,
userinfo_url_override: form.value.userinfo_url_override.trim() || null,
scopes: parseScopes(form.value.scopes_input),
redirect_uri: form.value.redirect_uri.trim(),
frontend_callback_url: form.value.frontend_callback_url.trim(),
attribute_mapping: parseJsonOrNull(form.value.attribute_mapping_json),
extra_config: parseJsonOrNull(form.value.extra_config_json),
is_enabled: existingConfig?.is_enabled || false,
}
await oauthApi.admin.upsertProviderConfig(selectedType.value, payload)
success('保存成功')
await loadAll()
} catch (err: any) {
showError(getErrorMessage(err, '保存失败'))
} finally {
saving.value = false
form.value.client_secret = ''
}
}
async function handleTest() {
if (!selectedType.value) return
testing.value = true
try {
const testPayload = {
client_id: form.value.client_id.trim(),
client_secret: form.value.client_secret.trim() || undefined,
authorization_url_override: form.value.authorization_url_override.trim() || null,
token_url_override: form.value.token_url_override.trim() || null,
redirect_uri: form.value.redirect_uri.trim(),
}
lastTestResult.value = await oauthApi.admin.testProviderConfig(selectedType.value, testPayload)
success('测试完成')
} catch (err: any) {
showError(getErrorMessage(err, '测试失败'))
} finally {
testing.value = false
}
}
onMounted(async () => {
await loadAll()
})
</script>

View File

@@ -3,17 +3,7 @@
<PageHeader <PageHeader
title="系统设置" title="系统设置"
description="管理系统级别的配置和参数" description="管理系统级别的配置和参数"
> />
<template #actions>
<Button
:disabled="loading"
class="shadow-none hover:shadow-none"
@click="saveSystemConfig"
>
{{ loading ? '保存中...' : '保存所有配置' }}
</Button>
</template>
</PageHeader>
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<!-- 配置导出/导入 --> <!-- 配置导出/导入 -->
@@ -109,6 +99,15 @@
title="基础配置" title="基础配置"
description="配置系统默认参数" description="配置系统默认参数"
> >
<template #actions>
<Button
size="sm"
:disabled="basicConfigLoading || !hasBasicConfigChanges"
@click="saveBasicConfig"
>
{{ basicConfigLoading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<Label <Label
@@ -148,49 +147,27 @@
0 表示不限制 0 表示不限制
</p> </p>
</div> </div>
</div>
</CardSection>
<!-- 用户注册配置 --> <div class="flex items-center h-full">
<CardSection
title="用户注册"
description="控制用户注册和验证"
>
<div class="space-y-4">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Checkbox <Checkbox
id="enable-registration" id="enable-registration"
v-model:checked="systemConfig.enable_registration" v-model:checked="systemConfig.enable_registration"
/> />
<div>
<Label <Label
for="enable-registration" for="enable-registration"
class="cursor-pointer" class="cursor-pointer"
> >
开放用户注册 开放用户注册
</Label> </Label>
<p class="text-xs text-muted-foreground">
允许新用户自助注册账户
</p>
</div>
</div>
</div> </div>
<div class="flex items-center space-x-2">
<Checkbox
id="require-email-verification"
v-model:checked="systemConfig.require_email_verification"
/>
<Label
for="require-email-verification"
class="cursor-pointer"
>
需要邮箱验证
</Label>
</div>
</div>
</CardSection>
<!-- 独立余额 Key 过期管理 -->
<CardSection
title="独立余额 Key 过期管理"
description="独立余额 Key 的过期处理策略(普通用户 Key 不会过期)"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-center h-full"> <div class="flex items-center h-full">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Checkbox <Checkbox
@@ -205,7 +182,7 @@
自动删除过期 Key 自动删除过期 Key
</Label> </Label>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
关闭时仅禁用过期 Key不会物理删除 关闭时仅禁用过期的独立余额 Key
</p> </p>
</div> </div>
</div> </div>
@@ -218,6 +195,15 @@
title="日志记录" title="日志记录"
description="控制请求日志的记录方式和内容" description="控制请求日志的记录方式和内容"
> >
<template #actions>
<Button
size="sm"
:disabled="logConfigLoading || !hasLogConfigChanges"
@click="saveLogConfig"
>
{{ logConfigLoading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<Label <Label
@@ -316,25 +302,36 @@
title="日志清理策略" title="日志清理策略"
description="配置日志的分级保留和自动清理" description="配置日志的分级保留和自动清理"
> >
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <template #actions>
<div class="md:col-span-2"> <div class="flex items-center gap-4">
<div class="flex items-center space-x-2 mb-4"> <div class="flex items-center gap-2">
<Checkbox <Switch
id="enable-auto-cleanup" id="enable-auto-cleanup"
v-model:checked="systemConfig.enable_auto_cleanup" :model-value="systemConfig.enable_auto_cleanup"
@update:model-value="handleAutoCleanupToggle"
/> />
<div>
<Label <Label
for="enable-auto-cleanup" for="enable-auto-cleanup"
class="cursor-pointer" class="text-sm cursor-pointer"
> >
启用自动清理任务 启用自动清理
</Label> </Label>
<span class="text-xs text-muted-foreground ml-2"> <p class="text-xs text-muted-foreground">
(每天凌晨执行) 每天凌晨执行
</span> </p>
</div> </div>
</div> </div>
<Button
size="sm"
:disabled="cleanupConfigLoading || !hasCleanupConfigChanges"
@click="saveCleanupConfig"
>
{{ cleanupConfigLoading ? '保存中...' : '保存' }}
</Button>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<Label <Label
for="detail-log-retention-days" for="detail-log-retention-days"
@@ -814,6 +811,7 @@ 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 Checkbox from '@/components/ui/checkbox.vue' import Checkbox from '@/components/ui/checkbox.vue'
import Switch from '@/components/ui/switch.vue'
import Select from '@/components/ui/select.vue' import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue' import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue' import SelectValue from '@/components/ui/select-value.vue'
@@ -833,9 +831,7 @@ interface SystemConfig {
// 基础配置 // 基础配置
default_user_quota_usd: number default_user_quota_usd: number
rate_limit_per_minute: number rate_limit_per_minute: number
// 用户注册
enable_registration: boolean enable_registration: boolean
require_email_verification: boolean
// 独立余额 Key 过期管理 // 独立余额 Key 过期管理
auto_delete_expired_keys: boolean auto_delete_expired_keys: boolean
// 日志记录 // 日志记录
@@ -853,7 +849,9 @@ interface SystemConfig {
audit_log_retention_days: number audit_log_retention_days: number
} }
const loading = ref(false) const basicConfigLoading = ref(false)
const logConfigLoading = ref(false)
const cleanupConfigLoading = ref(false)
const logLevelSelectOpen = ref(false) const logLevelSelectOpen = ref(false)
// 导出/导入相关 // 导出/导入相关
@@ -885,9 +883,7 @@ const systemConfig = ref<SystemConfig>({
// 基础配置 // 基础配置
default_user_quota_usd: 10.0, default_user_quota_usd: 10.0,
rate_limit_per_minute: 0, rate_limit_per_minute: 0,
// 用户注册
enable_registration: false, enable_registration: false,
require_email_verification: false,
// 独立余额 Key 过期管理 // 独立余额 Key 过期管理
auto_delete_expired_keys: false, auto_delete_expired_keys: false,
// 日志记录 // 日志记录
@@ -905,6 +901,42 @@ const systemConfig = ref<SystemConfig>({
audit_log_retention_days: 30, audit_log_retention_days: 30,
}) })
// 原始配置值(用于检测变动)
const originalConfig = ref<SystemConfig | null>(null)
// 检测各模块是否有变动
const hasBasicConfigChanges = computed(() => {
if (!originalConfig.value) return false
return (
systemConfig.value.default_user_quota_usd !== originalConfig.value.default_user_quota_usd ||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys
)
})
const hasLogConfigChanges = computed(() => {
if (!originalConfig.value) return false
return (
systemConfig.value.request_log_level !== originalConfig.value.request_log_level ||
systemConfig.value.max_request_body_size !== originalConfig.value.max_request_body_size ||
systemConfig.value.max_response_body_size !== originalConfig.value.max_response_body_size ||
JSON.stringify(systemConfig.value.sensitive_headers) !== JSON.stringify(originalConfig.value.sensitive_headers)
)
})
const hasCleanupConfigChanges = computed(() => {
if (!originalConfig.value) return false
return (
systemConfig.value.detail_log_retention_days !== originalConfig.value.detail_log_retention_days ||
systemConfig.value.compressed_log_retention_days !== originalConfig.value.compressed_log_retention_days ||
systemConfig.value.header_retention_days !== originalConfig.value.header_retention_days ||
systemConfig.value.log_retention_days !== originalConfig.value.log_retention_days ||
systemConfig.value.cleanup_batch_size !== originalConfig.value.cleanup_batch_size ||
systemConfig.value.audit_log_retention_days !== originalConfig.value.audit_log_retention_days
)
})
// 计算属性KB 和 字节 之间的转换 // 计算属性KB 和 字节 之间的转换
const maxRequestBodySizeKB = computed({ const maxRequestBodySizeKB = computed({
get: () => Math.round(systemConfig.value.max_request_body_size / 1024), get: () => Math.round(systemConfig.value.max_request_body_size / 1024),
@@ -934,7 +966,7 @@ const sensitiveHeadersStr = computed({
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadSystemConfig(), loadSystemConfig(),
loadSystemVersion() loadSystemVersion(),
]) ])
}) })
@@ -953,9 +985,7 @@ async function loadSystemConfig() {
// 基础配置 // 基础配置
'default_user_quota_usd', 'default_user_quota_usd',
'rate_limit_per_minute', 'rate_limit_per_minute',
// 用户注册
'enable_registration', 'enable_registration',
'require_email_verification',
// 独立余额 Key 过期管理 // 独立余额 Key 过期管理
'auto_delete_expired_keys', 'auto_delete_expired_keys',
// 日志记录 // 日志记录
@@ -983,17 +1013,18 @@ async function loadSystemConfig() {
// 配置不存在时使用默认值,无需处理 // 配置不存在时使用默认值,无需处理
} }
} }
// 保存原始值用于变动检测
originalConfig.value = JSON.parse(JSON.stringify(systemConfig.value))
} catch (err) { } catch (err) {
error('加载系统配置失败') error('加载系统配置失败')
log.error('加载系统配置失败:', err) log.error('加载系统配置失败:', err)
} }
} }
async function saveSystemConfig() { async function saveBasicConfig() {
loading.value = true basicConfigLoading.value = true
try { try {
const configItems = [ const configItems = [
// 基础配置
{ {
key: 'default_user_quota_usd', key: 'default_user_quota_usd',
value: systemConfig.value.default_user_quota_usd, value: systemConfig.value.default_user_quota_usd,
@@ -1004,24 +1035,43 @@ async function saveSystemConfig() {
value: systemConfig.value.rate_limit_per_minute, value: systemConfig.value.rate_limit_per_minute,
description: '每分钟请求限制' description: '每分钟请求限制'
}, },
// 用户注册
{ {
key: 'enable_registration', key: 'enable_registration',
value: systemConfig.value.enable_registration, value: systemConfig.value.enable_registration,
description: '是否开放用户注册' description: '是否开放用户注册'
}, },
{
key: 'require_email_verification',
value: systemConfig.value.require_email_verification,
description: '是否需要邮箱验证'
},
// 独立余额 Key 过期管理
{ {
key: 'auto_delete_expired_keys', key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys, value: systemConfig.value.auto_delete_expired_keys,
description: '是否自动删除过期的API Key' description: '是否自动删除过期的API Key'
}, },
// 日志记录 ]
await Promise.all(
configItems.map(item =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
)
// 更新原始值
if (originalConfig.value) {
originalConfig.value.default_user_quota_usd = systemConfig.value.default_user_quota_usd
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
originalConfig.value.enable_registration = systemConfig.value.enable_registration
originalConfig.value.auto_delete_expired_keys = systemConfig.value.auto_delete_expired_keys
}
success('基础配置已保存')
} catch (err) {
error('保存配置失败')
log.error('保存基础配置失败:', err)
} finally {
basicConfigLoading.value = false
}
}
async function saveLogConfig() {
logConfigLoading.value = true
try {
const configItems = [
{ {
key: 'request_log_level', key: 'request_log_level',
value: systemConfig.value.request_log_level, value: systemConfig.value.request_log_level,
@@ -1042,12 +1092,51 @@ async function saveSystemConfig() {
value: systemConfig.value.sensitive_headers, value: systemConfig.value.sensitive_headers,
description: '敏感请求头列表' description: '敏感请求头列表'
}, },
// 日志清理 ]
{
key: 'enable_auto_cleanup', await Promise.all(
value: systemConfig.value.enable_auto_cleanup, configItems.map(item =>
description: '是否启用自动清理任务' adminApi.updateSystemConfig(item.key, item.value, item.description)
}, )
)
// 更新原始值
if (originalConfig.value) {
originalConfig.value.request_log_level = systemConfig.value.request_log_level
originalConfig.value.max_request_body_size = systemConfig.value.max_request_body_size
originalConfig.value.max_response_body_size = systemConfig.value.max_response_body_size
originalConfig.value.sensitive_headers = [...systemConfig.value.sensitive_headers]
}
success('日志配置已保存')
} catch (err) {
error('保存配置失败')
log.error('保存日志配置失败:', err)
} finally {
logConfigLoading.value = false
}
}
async function handleAutoCleanupToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_auto_cleanup
systemConfig.value.enable_auto_cleanup = enabled
try {
await adminApi.updateSystemConfig(
'enable_auto_cleanup',
enabled,
'是否启用自动清理任务'
)
success(enabled ? '已启用自动清理' : '已禁用自动清理')
} catch (err) {
error('保存配置失败')
log.error('保存自动清理配置失败:', err)
// 回滚状态
systemConfig.value.enable_auto_cleanup = previousValue
}
}
async function saveCleanupConfig() {
cleanupConfigLoading.value = true
try {
const configItems = [
{ {
key: 'detail_log_retention_days', key: 'detail_log_retention_days',
value: systemConfig.value.detail_log_retention_days, value: systemConfig.value.detail_log_retention_days,
@@ -1080,17 +1169,26 @@ async function saveSystemConfig() {
}, },
] ]
const promises = configItems.map(item => await Promise.all(
configItems.map(item =>
adminApi.updateSystemConfig(item.key, item.value, item.description) adminApi.updateSystemConfig(item.key, item.value, item.description)
) )
)
await Promise.all(promises) // 更新原始值
success('系统配置已保存') if (originalConfig.value) {
originalConfig.value.detail_log_retention_days = systemConfig.value.detail_log_retention_days
originalConfig.value.compressed_log_retention_days = systemConfig.value.compressed_log_retention_days
originalConfig.value.header_retention_days = systemConfig.value.header_retention_days
originalConfig.value.log_retention_days = systemConfig.value.log_retention_days
originalConfig.value.cleanup_batch_size = systemConfig.value.cleanup_batch_size
originalConfig.value.audit_log_retention_days = systemConfig.value.audit_log_retention_days
}
success('日志清理配置已保存')
} catch (err) { } catch (err) {
error('保存配置失败') error('保存配置失败')
log.error('保存配置失败:', err) log.error('保存日志清理配置失败:', err)
} finally { } finally {
loading.value = false cleanupConfigLoading.value = false
} }
} }

View File

@@ -0,0 +1,119 @@
<template>
<div class="min-h-screen flex items-center justify-center px-6">
<Card class="w-full max-w-md p-6 space-y-2">
<h1 class="text-lg font-semibold text-foreground">
正在处理认证...
</h1>
<p class="text-sm text-muted-foreground">
{{ hint }}
</p>
</Card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Card from '@/components/ui/card.vue'
import apiClient from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { success, error: showError } = useToast()
const hint = ref('请稍候...')
function consumeRedirectPath(): string | null {
const redirectPath = sessionStorage.getItem('redirectPath')
if (redirectPath) {
sessionStorage.removeItem('redirectPath')
return redirectPath
}
return null
}
function clearUrlState() {
// 清理 fragment避免刷新时重复处理
// 同时清理 queryoauth_bound / error_code / error_detail
const newUrl = window.location.pathname
window.history.replaceState({}, document.title, newUrl)
}
function errorMessageFromCode(code: string): string {
const map: Record<string, string> = {
authorization_denied: '你已取消授权',
provider_disabled: '该 OAuth Provider 已被禁用',
provider_unavailable: 'OAuth Provider 不可用',
invalid_callback: '回调参数无效',
invalid_state: '登录状态已失效,请重试',
token_exchange_failed: '令牌兑换失败',
userinfo_fetch_failed: '获取用户信息失败',
email_exists_local: '该邮箱已存在,请先登录后再绑定 OAuth',
email_is_ldap: '该邮箱属于 LDAP 账号,请使用 LDAP 登录',
email_is_oauth: '该邮箱已关联其他 OAuth 账号,请使用原账号登录',
registration_disabled: '系统未开放注册,无法创建新账号',
oauth_already_bound: '该第三方账号已被其他用户绑定',
already_bound_provider: '你已绑定该 Provider',
last_oauth_binding: '解绑失败:至少需要保留一个 OAuth 绑定',
last_login_method: '解绑失败:解绑后将无法登录',
ldap_no_oauth: 'LDAP 用户不支持 OAuth 绑定',
}
return map[code] || '认证失败,请重试'
}
onMounted(async () => {
// 1) 绑定成功提示
const oauthBound = route.query.oauth_bound
if (typeof oauthBound === 'string' && oauthBound) {
success(`已绑定 ${oauthBound}`)
clearUrlState()
const redirectPath = consumeRedirectPath()
await router.replace(redirectPath || '/dashboard/settings')
return
}
// 2) 错误提示
const errorCode = route.query.error_code
if (typeof errorCode === 'string' && errorCode) {
showError(errorMessageFromCode(errorCode))
clearUrlState()
const redirectPath = consumeRedirectPath()
await router.replace(redirectPath || '/')
return
}
// 3) 登录成功:解析 fragment token
const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : window.location.hash
const params = new URLSearchParams(hash)
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
clearUrlState()
if (!accessToken) {
showError('未获取到访问令牌')
await router.replace('/')
return
}
hint.value = '正在写入登录态...'
apiClient.setToken(accessToken)
if (refreshToken) {
localStorage.setItem('refresh_token', refreshToken)
}
authStore.syncToken()
hint.value = '正在获取用户信息...'
await authStore.fetchCurrentUser()
success('登录成功')
const redirectPath = consumeRedirectPath()
const target = redirectPath || (authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard')
await router.replace(target)
})
</script>

View File

@@ -9,13 +9,23 @@
<div class="lg:col-span-2 space-y-6"> <div class="lg:col-span-2 space-y-6">
<!-- 基本信息 --> <!-- 基本信息 -->
<Card class="p-6"> <Card class="p-6">
<h3 class="text-lg font-medium text-foreground mb-4">
基本信息
</h3>
<form <form
class="space-y-4" class="space-y-4"
@submit.prevent="updateProfile" @submit.prevent="updateProfile"
> >
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-foreground">
基本信息
</h3>
<Button
type="submit"
:disabled="savingProfile || !hasProfileChanges"
class="shadow-none hover:shadow-none"
>
{{ savingProfile ? '保存中...' : '保存' }}
</Button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<Label for="username">用户名</Label> <Label for="username">用户名</Label>
@@ -26,11 +36,11 @@
/> />
</div> </div>
<div> <div>
<Label for="email">邮箱</Label> <Label for="avatar">头像 URL</Label>
<Input <Input
id="email" id="avatar"
v-model="profileForm.email" v-model="preferencesForm.avatar_url"
type="email" type="url"
class="mt-1" class="mt-1"
/> />
</div> </div>
@@ -46,42 +56,53 @@
/> />
</div> </div>
<!-- 邮箱字段当系统配置了邮箱服务或用户已有邮箱时显示 -->
<div
v-if="emailConfigured || profileForm.email"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div> <div>
<Label for="avatar">头像 URL</Label> <Label for="email">邮箱</Label>
<Input <Input
id="avatar" id="email"
v-model="preferencesForm.avatar_url" v-model="profileForm.email"
type="url" type="email"
class="mt-1" class="mt-1"
:disabled="!emailConfigured"
/> />
<p class="mt-1 text-sm text-muted-foreground"> <p
输入头像图片的 URL 地址 v-if="!emailConfigured && profileForm.email"
class="mt-1 text-xs text-muted-foreground"
>
邮箱服务未配置暂不可修改
</p> </p>
</div> </div>
</div>
<Button
type="submit"
:disabled="savingProfile"
class="shadow-none hover:shadow-none"
>
{{ savingProfile ? '保存中...' : '保存修改' }}
</Button>
</form> </form>
</Card> </Card>
<!-- 修改密码 (LDAP 用户不显示) --> <!-- 密码设置LDAP 用户不显示 -->
<Card <Card
v-if="profile?.auth_source !== 'ldap'" v-if="profile?.auth_source !== 'ldap'"
class="p-6" class="p-6"
> >
<h3 class="text-lg font-medium text-foreground mb-4">
修改密码
</h3>
<form <form
class="space-y-4" class="space-y-4"
@submit.prevent="changePassword" @submit.prevent="changePassword"
> >
<div> <div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-foreground">
{{ profile?.has_password ? '修改密码' : '设置密码' }}
</h3>
<Button
type="submit"
:disabled="changingPassword || !hasPasswordChanges"
class="shadow-none hover:shadow-none"
>
{{ changingPassword ? '保存中...' : '保存' }}
</Button>
</div>
<div v-if="profile?.has_password">
<Label for="old-password">当前密码</Label> <Label for="old-password">当前密码</Label>
<Input <Input
id="old-password" id="old-password"
@@ -91,7 +112,7 @@
/> />
</div> </div>
<div> <div>
<Label for="new-password">新密码</Label> <Label for="new-password">{{ profile?.has_password ? '新密码' : '密码' }}</Label>
<Input <Input
id="new-password" id="new-password"
v-model="passwordForm.new_password" v-model="passwordForm.new_password"
@@ -100,7 +121,7 @@
/> />
</div> </div>
<div> <div>
<Label for="confirm-password">确认密码</Label> <Label for="confirm-password">确认{{ profile?.has_password ? '新' : '' }}密码</Label>
<Input <Input
id="confirm-password" id="confirm-password"
v-model="passwordForm.confirm_password" v-model="passwordForm.confirm_password"
@@ -108,16 +129,95 @@
class="mt-1" class="mt-1"
/> />
</div> </div>
<Button
type="submit"
:disabled="changingPassword"
class="shadow-none hover:shadow-none"
>
{{ changingPassword ? '修改中...' : '修改密码' }}
</Button>
</form> </form>
</Card> </Card>
<!-- OAuth 绑定 -->
<Card class="p-6">
<h3 class="text-lg font-medium text-foreground mb-4">
OAuth 绑定
</h3>
<div
v-if="profile?.auth_source === 'ldap'"
class="text-sm text-muted-foreground"
>
LDAP 用户不支持 OAuth 绑定
</div>
<div
v-else-if="oauthUnavailable"
class="text-sm text-muted-foreground"
>
OAuth 模块未启用或暂不可用
</div>
<div
v-else
class="space-y-4"
>
<!-- 合并已绑定和可绑定为卡片网格 -->
<div
v-if="oauthLinks.length === 0 && bindableProviders.length === 0"
class="text-sm text-muted-foreground"
>
暂无可用的 OAuth Provider
</div>
<div
v-else
class="grid grid-cols-1 sm:grid-cols-2 gap-3"
>
<!-- 已绑定的 Provider -->
<div
v-for="link in oauthLinks"
:key="link.provider_type"
class="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">
{{ link.display_name }}
</div>
<div class="text-xs text-muted-foreground truncate">
{{ link.provider_username || link.provider_email || '已绑定' }}
</div>
</div>
<Button
variant="outline"
size="sm"
:disabled="oauthActionLoading"
@click="handleUnbind(link.provider_type)"
>
解绑
</Button>
</div>
<!-- 可绑定的 Provider -->
<div
v-for="p in bindableProviders"
:key="p.provider_type"
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-border p-4 hover:border-primary/50 transition-colors"
>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">
{{ p.display_name }}
</div>
<div class="text-xs text-muted-foreground">
未绑定
</div>
</div>
<Button
variant="outline"
size="sm"
:disabled="oauthActionLoading"
@click="handleBind(p.provider_type)"
>
绑定
</Button>
</div>
</div>
</div>
</Card>
<!-- 偏好设置 --> <!-- 偏好设置 -->
<Card class="p-6"> <Card class="p-6">
<h3 class="text-lg font-medium text-foreground mb-4"> <h3 class="text-lg font-medium text-foreground mb-4">
@@ -192,7 +292,11 @@
通知设置 通知设置
</h4> </h4>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-border/40 last:border-0"> <!-- 邮件通知仅当系统配置了邮箱服务时显示 -->
<div
v-if="emailConfigured"
class="flex items-center justify-between py-2 border-b border-border/40 last:border-0"
>
<div class="flex-1"> <div class="flex-1">
<Label <Label
for="email-notifications" for="email-notifications"
@@ -322,9 +426,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { meApi, type Profile } from '@/api/me' import { meApi, type Profile } from '@/api/me'
import { authApi } from '@/api/auth'
import { oauthApi, type OAuthLinkInfo, type OAuthProviderInfo } from '@/api/oauth'
import { useDarkMode, type ThemeMode } from '@/composables/useDarkMode' import { useDarkMode, type ThemeMode } from '@/composables/useDarkMode'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
@@ -340,9 +447,12 @@ import SelectItem from '@/components/ui/select-item.vue'
import Switch from '@/components/ui/switch.vue' import Switch from '@/components/ui/switch.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { formatCurrency } from '@/utils/format' import { formatCurrency } from '@/utils/format'
import { getApiUrl } from '@/utils/url'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { getErrorMessage } from '@/types/api-error'
const authStore = useAuthStore() const authStore = useAuthStore()
const route = useRoute()
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const { setThemeMode } = useDarkMode() const { setThemeMode } = useDarkMode()
@@ -377,6 +487,38 @@ const changingPassword = ref(false)
const themeSelectOpen = ref(false) const themeSelectOpen = ref(false)
const languageSelectOpen = ref(false) const languageSelectOpen = ref(false)
const oauthUnavailable = ref(false)
const oauthActionLoading = ref(false)
const oauthLinks = ref<OAuthLinkInfo[]>([])
const bindableProviders = ref<OAuthProviderInfo[]>([])
const emailConfigured = ref(false) // 系统是否配置了邮箱服务
// 原始值,用于检测是否有修改
const originalProfileForm = ref({ email: '', username: '' })
const originalPreferencesForm = ref({ avatar_url: '', bio: '' })
// 检测基本信息是否有修改
const hasProfileChanges = computed(() => {
return (
profileForm.value.username !== originalProfileForm.value.username ||
profileForm.value.email !== originalProfileForm.value.email ||
preferencesForm.value.avatar_url !== originalPreferencesForm.value.avatar_url ||
preferencesForm.value.bio !== originalPreferencesForm.value.bio
)
})
// 检测密码表单是否有内容
const hasPasswordChanges = computed(() => {
const hasPassword = profile.value?.has_password
if (hasPassword) {
// 已有密码:需要填写旧密码和新密码
return !!(passwordForm.value.old_password && passwordForm.value.new_password && passwordForm.value.confirm_password)
} else {
// 设置密码:只需要填写新密码
return !!(passwordForm.value.new_password && passwordForm.value.confirm_password)
}
})
function handleThemeChange(value: string) { function handleThemeChange(value: string) {
preferencesForm.value.theme = value preferencesForm.value.theme = value
themeSelectOpen.value = false themeSelectOpen.value = false
@@ -395,21 +537,86 @@ function handleLanguageChange(value: string) {
onMounted(async () => { onMounted(async () => {
await loadProfile() await loadProfile()
await loadPreferences() await loadPreferences()
await loadOAuthBindings()
await loadEmailConfigured()
}) })
async function loadEmailConfigured() {
try {
const settings = await authApi.getRegistrationSettings()
emailConfigured.value = !!settings.email_configured
} catch {
emailConfigured.value = false
}
}
async function loadProfile() { async function loadProfile() {
try { try {
profile.value = await meApi.getProfile() profile.value = await meApi.getProfile()
profileForm.value = { profileForm.value = {
email: profile.value.email, email: profile.value.email || '',
username: profile.value.username username: profile.value.username
} }
// 保存原始值
originalProfileForm.value = { ...profileForm.value }
} catch (error) { } catch (error) {
log.error('加载个人信息失败:', error) log.error('加载个人信息失败:', error)
showError('加载个人信息失败') showError('加载个人信息失败')
} }
} }
async function loadOAuthBindings() {
oauthUnavailable.value = false
oauthLinks.value = []
bindableProviders.value = []
// profile 加载失败时跳过
if (!profile.value) {
oauthUnavailable.value = true
return
}
// LDAP 用户不支持绑定
if (profile.value.auth_source === 'ldap') {
return
}
try {
const [links, providers] = await Promise.all([
oauthApi.getMyLinks(),
oauthApi.getBindableProviders(),
])
oauthLinks.value = links
bindableProviders.value = providers
} catch (err: any) {
if (err?.response?.status === 503) {
oauthUnavailable.value = true
return
}
log.error('加载 OAuth 绑定信息失败:', err)
oauthUnavailable.value = true
}
}
function handleBind(providerType: string) {
// 保存返回路径OAuth callback 会读取)
sessionStorage.setItem('redirectPath', route.fullPath)
window.location.href = getApiUrl(`/api/user/oauth/${providerType}/bind`)
}
async function handleUnbind(providerType: string) {
oauthActionLoading.value = true
try {
await oauthApi.unbind(providerType)
success('解绑成功')
await loadOAuthBindings()
} catch (err) {
showError(getErrorMessage(err, '解绑失败'))
} finally {
oauthActionLoading.value = false
}
}
async function loadPreferences() { async function loadPreferences() {
try { try {
const prefs = await meApi.getPreferences() const prefs = await meApi.getPreferences()
@@ -432,6 +639,12 @@ async function loadPreferences() {
} }
} }
// 保存原始值
originalPreferencesForm.value = {
avatar_url: preferencesForm.value.avatar_url,
bio: preferencesForm.value.bio
}
// 如果本地主题和服务端不一致,同步到服务端(静默更新,不提示用户) // 如果本地主题和服务端不一致,同步到服务端(静默更新,不提示用户)
const serverTheme = prefs.theme || 'light' const serverTheme = prefs.theme || 'light'
if (localTheme !== serverTheme) { if (localTheme !== serverTheme) {
@@ -463,12 +676,18 @@ async function updateProfile() {
} }
}) })
// 更新原始值
originalProfileForm.value = { ...profileForm.value }
originalPreferencesForm.value = {
avatar_url: preferencesForm.value.avatar_url,
bio: preferencesForm.value.bio
}
success('个人信息已更新') success('个人信息已更新')
await loadProfile()
authStore.fetchCurrentUser() authStore.fetchCurrentUser()
} catch (error) { } catch (err) {
log.error('更新个人信息失败:', error) log.error('更新个人信息失败:', err)
showError('更新个人信息失败') showError(getErrorMessage(err), '更新个人信息失败')
} finally { } finally {
savingProfile.value = false savingProfile.value = false
} }
@@ -476,30 +695,37 @@ async function updateProfile() {
async function changePassword() { async function changePassword() {
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) { if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
showError('两次输入的密码不一致') showError('两次输入的密码不一致', '密码错误')
return return
} }
if (passwordForm.value.new_password.length < 6) { if (passwordForm.value.new_password.length < 6) {
showError('密码长度至少6位') showError('密码长度至少6位', '密码错误')
return return
} }
const isSettingPassword = !profile.value?.has_password
changingPassword.value = true changingPassword.value = true
try { try {
await meApi.changePassword({ await meApi.changePassword({
old_password: passwordForm.value.old_password, old_password: isSettingPassword ? undefined : passwordForm.value.old_password,
new_password: passwordForm.value.new_password new_password: passwordForm.value.new_password
}) })
success('密码修改成功') success(isSettingPassword ? '密码设置成功' : '密码修改成功')
passwordForm.value = { passwordForm.value = {
old_password: '', old_password: '',
new_password: '', new_password: '',
confirm_password: '' confirm_password: ''
} }
} catch (error) { // 刷新 profile 以更新 has_password 状态
log.error('修改密码失败:', error) if (isSettingPassword) {
showError('修改密码失败,请检查当前密码是否正确') await loadProfile()
}
} catch (err) {
log.error('修改密码失败:', err)
const title = isSettingPassword ? '密码设置失败' : '密码修改失败'
const defaultMsg = isSettingPassword ? '请稍后重试' : '请检查当前密码是否正确'
showError(getErrorMessage(err, defaultMsg), title)
} finally { } finally {
changingPassword.value = false changingPassword.value = false
} }

View File

@@ -60,11 +60,6 @@ export default defineConfig(({ mode }) => ({
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },
'/auth/': {
target: 'http://localhost:8084', // 本地开发端口
changeOrigin: true,
secure: false,
},
'/v1/': { '/v1/': {
target: 'http://localhost:8084', // 本地开发端口 target: 'http://localhost:8084', // 本地开发端口
changeOrigin: true, changeOrigin: true,

View File

@@ -27,6 +27,8 @@ class ModuleStatusResponse(BaseModel):
available: bool available: bool
enabled: bool enabled: bool
active: bool active: bool
config_validated: bool
config_error: Optional[str]
display_name: str display_name: str
description: str description: str
category: str category: str
@@ -43,6 +45,8 @@ class ModuleStatusResponse(BaseModel):
available=status.available, available=status.available,
enabled=status.enabled, enabled=status.enabled,
active=status.active, active=status.active,
config_validated=status.config_validated,
config_error=status.config_error,
display_name=status.display_name, display_name=status.display_name,
description=status.description, description=status.description,
category=status.category.value, category=status.category.value,
@@ -180,6 +184,12 @@ class AdminSetModuleEnabledAdapter(AdminApiAdapter):
except Exception: except Exception:
raise InvalidRequestException("请求体格式错误,需要 enabled 字段") raise InvalidRequestException("请求体格式错误,需要 enabled 字段")
# 如果是启用模块,必须先通过配置验证
if req.enabled:
config_validated, config_error = registry.validate_config(self.module_name, context.db)
if not config_validated:
raise InvalidRequestException(f"模块配置未验证通过: {config_error}")
# 设置启用状态 # 设置启用状态
registry.set_enabled(self.module_name, req.enabled, context.db) registry.set_enabled(self.module_name, req.enabled, context.db)

View File

@@ -3,7 +3,6 @@ Provider Query API 端点
用于查询提供商的模型列表等信息 用于查询提供商的模型列表等信息
""" """
import asyncio
from typing import Optional from typing import Optional
import httpx import httpx
@@ -11,14 +10,17 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from src.api.handlers.base.chat_adapter_base import get_adapter_class
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
from src.config.constants import TimeoutDefaults from src.config.constants import TimeoutDefaults
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.headers import get_extra_headers_from_endpoint from src.core.headers import get_extra_headers_from_endpoint
from src.core.logger import logger from src.core.logger import logger
from src.database.database import get_db from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User from src.models.database import Provider, ProviderEndpoint, User
from src.services.model.upstream_fetcher import (
_get_adapter_for_format,
build_all_format_configs,
fetch_models_from_endpoints,
)
from src.utils.auth_utils import get_current_user from src.utils.auth_utils import get_current_user
@@ -50,21 +52,6 @@ class TestModelRequest(BaseModel):
# ============ API Endpoints ============ # ============ API Endpoints ============
def _get_adapter_for_format(api_format: str):
"""根据 API 格式获取对应的 Adapter 类"""
# 先检查 Chat Adapter 注册表
adapter_class = get_adapter_class(api_format)
if adapter_class:
return adapter_class
# 再检查 CLI Adapter 注册表
cli_adapter_class = get_cli_adapter_class(api_format)
if cli_adapter_class:
return cli_adapter_class
return None
@router.post("/models") @router.post("/models")
async def query_available_models( async def query_available_models(
request: ModelsQueryRequest, request: ModelsQueryRequest,
@@ -75,11 +62,7 @@ async def query_available_models(
查询提供商可用模型 查询提供商可用模型
优先从缓存获取(缓存由定时任务刷新),缓存未命中时实时调用上游 API。 优先从缓存获取(缓存由定时任务刷新),缓存未命中时实时调用上游 API。
从所有 API 格式尝试获取模型,然后聚合去重。
遍历所有活跃端点,根据端点的 API 格式选择正确的 Adapter 进行请求:
- OPENAI/OPENAI_CLI: 使用 OpenAIChatAdapter.fetch_models
- CLAUDE/CLAUDE_CLI: 使用 ClaudeChatAdapter.fetch_models
- GEMINI/GEMINI_CLI: 使用 GeminiChatAdapter.fetch_models
Args: Args:
request: 查询请求 request: 查询请求
@@ -107,9 +90,6 @@ async def query_available_models(
raise HTTPException(status_code=404, detail="Provider not found") raise HTTPException(status_code=404, detail="Provider not found")
# 如果指定了 api_key_id 且不是强制刷新,优先从缓存获取 # 如果指定了 api_key_id 且不是强制刷新,优先从缓存获取
# 注:不指定 api_key_id 时Provider 级别查询)不使用缓存,因为:
# 1. Provider 级别查询会遍历多个 Key结果不稳定
# 2. 缓存按 Key 粒度存储,与定时任务的刷新逻辑一致
if request.api_key_id and not request.force_refresh: if request.api_key_id and not request.force_refresh:
cached_models = await get_upstream_models_from_cache( cached_models = await get_upstream_models_from_cache(
request.provider_id, request.api_key_id request.provider_id, request.api_key_id
@@ -126,24 +106,32 @@ async def query_available_models(
# 缓存未命中或强制刷新,实时获取 # 缓存未命中或强制刷新,实时获取
# 收集所有活跃端点的配置
endpoint_configs: list[dict] = []
# 构建 api_format -> endpoint 映射 # 构建 api_format -> endpoint 映射
format_to_endpoint: dict[str, ProviderEndpoint] = {} format_to_endpoint: dict[str, ProviderEndpoint] = {}
for endpoint in provider.endpoints: for endpoint in provider.endpoints:
if endpoint.is_active: if endpoint.is_active:
format_to_endpoint[endpoint.api_format] = endpoint format_to_endpoint[endpoint.api_format] = endpoint
if not format_to_endpoint:
raise HTTPException(status_code=400, detail="No active endpoints found for this provider")
# 获取 API Key
if request.api_key_id: if request.api_key_id:
# 指定了特定的 API Key(从 provider.api_keys 查找) # 指定了特定的 API Key
api_key = next( api_key = next(
(key for key in provider.api_keys if key.id == request.api_key_id), (key for key in provider.api_keys if key.id == request.api_key_id),
None None
) )
if not api_key: if not api_key:
raise HTTPException(status_code=404, detail="API Key not found") raise HTTPException(status_code=404, detail="API Key not found")
else:
# 使用第一个可用的 Key
api_key = next(
(key for key in provider.api_keys if key.is_active),
None
)
if not api_key:
raise HTTPException(status_code=400, detail="No active API Key found for this provider")
try: try:
api_key_value = crypto_service.decrypt(api_key.api_key) api_key_value = crypto_service.decrypt(api_key.api_key)
@@ -151,102 +139,9 @@ async def query_available_models(
logger.error(f"Failed to decrypt API key: {e}") logger.error(f"Failed to decrypt API key: {e}")
raise HTTPException(status_code=500, detail="Failed to decrypt API key") raise HTTPException(status_code=500, detail="Failed to decrypt API key")
# 根据 Key 的 api_formats 找对应的 Endpoint # 使用公共函数构建所有格式的端点配置并获取模型
key_formats = api_key.api_formats or [] endpoint_configs = build_all_format_configs(api_key_value, format_to_endpoint) # type: ignore[arg-type]
for fmt in key_formats: all_models, errors, has_success = await fetch_models_from_endpoints(endpoint_configs)
endpoint = format_to_endpoint.get(fmt)
if endpoint:
endpoint_configs.append({
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": fmt,
"extra_headers": get_extra_headers_from_endpoint(endpoint),
})
if not endpoint_configs:
raise HTTPException(
status_code=400,
detail="No matching endpoint found for this API Key's formats"
)
else:
# 遍历所有活跃端点,为每个端点找一个支持该格式的 Key
for endpoint in provider.endpoints:
if not endpoint.is_active:
continue
# 找第一个支持该格式的可用 Key
for api_key in provider.api_keys:
if not api_key.is_active:
continue
key_formats = api_key.api_formats or []
if endpoint.api_format not in key_formats:
continue
try:
api_key_value = crypto_service.decrypt(api_key.api_key)
except Exception as e:
logger.error(f"Failed to decrypt API key: {e}")
continue
endpoint_configs.append({
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": get_extra_headers_from_endpoint(endpoint),
})
break # 只取第一个可用的 Key
if not endpoint_configs:
raise HTTPException(status_code=400, detail="No active API Key found for this provider")
# 并发请求所有端点的模型列表
all_models: list = []
errors: list[str] = []
async def fetch_endpoint_models(
client: httpx.AsyncClient, config: dict
) -> tuple[list, Optional[str]]:
base_url = config["base_url"]
if not base_url:
return [], None
base_url = base_url.rstrip("/")
api_format = config["api_format"]
api_key_value = config["api_key"]
extra_headers = config.get("extra_headers")
try:
# 获取对应的 Adapter 类并调用 fetch_models
adapter_class = _get_adapter_for_format(api_format)
if not adapter_class:
return [], f"Unknown API format: {api_format}"
models, error = await adapter_class.fetch_models(
client, base_url, api_key_value, extra_headers
)
# 确保所有模型都有 api_format 字段
for m in models:
if "api_format" not in m:
m["api_format"] = api_format
return models, error
except Exception as e:
logger.error(f"Error fetching models from {api_format} endpoint: {e}")
return [], f"{api_format}: {str(e)}"
# 限制并发请求数量,避免触发上游速率限制
MAX_CONCURRENT_REQUESTS = 5
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
async def fetch_with_semaphore(
client: httpx.AsyncClient, config: dict
) -> tuple[list, Optional[str]]:
async with semaphore:
return await fetch_endpoint_models(client, config)
async with httpx.AsyncClient(timeout=30.0) as client:
results = await asyncio.gather(
*[fetch_with_semaphore(client, c) for c in endpoint_configs]
)
for models, error in results:
all_models.extend(models)
if error:
errors.append(error)
# 按 model id + api_format 去重(保留第一个) # 按 model id + api_format 去重(保留第一个)
seen_keys: set[str] = set() seen_keys: set[str] = set()

View File

@@ -1486,8 +1486,17 @@ class AdminImportUsersAdapter(AdminApiAdapter):
stats["users"]["skipped"] += 1 stats["users"]["skipped"] += 1
continue continue
# 导入必须有邮箱email 是导入的主键)
import_email = user_data.get("email")
if not import_email:
stats["errors"].append(
f"跳过无邮箱用户: {user_data.get('username', '未知')}"
)
stats["users"]["skipped"] += 1
continue
existing_user = ( existing_user = (
db.query(User).filter(User.email == user_data["email"]).first() db.query(User).filter(User.email == import_email).first()
) )
if existing_user: if existing_user:
@@ -1496,7 +1505,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
stats["users"]["skipped"] += 1 stats["users"]["skipped"] += 1
elif merge_mode == "error": elif merge_mode == "error":
raise InvalidRequestException( raise InvalidRequestException(
f"用户 '{user_data['email']}' 已存在" f"用户 '{import_email}' 已存在"
) )
elif merge_mode == "overwrite": elif merge_mode == "overwrite":
# 更新现有用户 # 更新现有用户
@@ -1527,8 +1536,9 @@ class AdminImportUsersAdapter(AdminApiAdapter):
new_user = User( new_user = User(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
email=user_data["email"], email=import_email,
username=user_data.get("username", user_data["email"].split("@")[0]), email_verified=user_data.get("email_verified", True),
username=user_data.get("username") or import_email.split("@")[0],
password_hash=user_data.get("password_hash", ""), password_hash=user_data.get("password_hash", ""),
role=role, role=role,
allowed_providers=user_data.get("allowed_providers"), allowed_providers=user_data.get("allowed_providers"),

View File

@@ -266,8 +266,21 @@ class AnnouncementOptionalAuthAdapter(ApiAdapter):
if not user_id: if not user_id:
return None return None
user = ( user = (
context.db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first() context.db.query(User)
.filter(
User.id == user_id,
User.is_active.is_(True),
User.is_deleted.is_(False),
) )
.first()
)
if not user:
return None
if not AuthService.token_identity_matches_user(payload, user):
return None
return user return user
except Exception: except Exception:
return None return None

View File

@@ -299,13 +299,12 @@ class AuthLoginAdapter(AuthPublicAdapter):
access_token = AuthService.create_access_token( access_token = AuthService.create_access_token(
data={ data={
"user_id": user.id, "user_id": user.id,
"email": user.email,
"role": user.role.value, "role": user.role.value,
"created_at": user.created_at.isoformat() if user.created_at else None, "created_at": user.created_at.isoformat() if user.created_at else None,
} }
) )
refresh_token = AuthService.create_refresh_token( refresh_token = AuthService.create_refresh_token(
data={"user_id": user.id, "email": user.email} data={"user_id": user.id, "created_at": user.created_at.isoformat() if user.created_at else None}
) )
response = LoginResponse( response = LoginResponse(
access_token=access_token, access_token=access_token,
@@ -345,19 +344,23 @@ class AuthRefreshAdapter(AuthPublicAdapter):
) )
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户已禁用") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户已禁用")
if user.is_deleted:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户不存在或已禁用")
if not AuthService.token_identity_matches_user(token_payload, user):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的刷新令牌")
new_access_token = AuthService.create_access_token( new_access_token = AuthService.create_access_token(
data={ data={
"user_id": user.id, "user_id": user.id,
"email": user.email,
"role": user.role.value, "role": user.role.value,
"created_at": user.created_at.isoformat() if user.created_at else None, "created_at": user.created_at.isoformat() if user.created_at else None,
} }
) )
new_refresh_token = AuthService.create_refresh_token( new_refresh_token = AuthService.create_refresh_token(
data={"user_id": user.id, "email": user.email} data={"user_id": user.id, "created_at": user.created_at.isoformat() if user.created_at else None}
) )
logger.info(f"令牌刷新成功: {user.email}") logger.info(f"令牌刷新成功: user_id={user.id}")
return RefreshTokenResponse( return RefreshTokenResponse(
access_token=new_access_token, access_token=new_access_token,
refresh_token=new_refresh_token, refresh_token=new_refresh_token,
@@ -378,10 +381,16 @@ class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False) enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False)
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False) require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
email_configured = EmailSenderService.is_smtp_configured(db)
# 如果邮箱服务未配置,强制 require_email_verification 为 False
if not email_configured:
require_verification = False
return RegistrationSettingsResponse( return RegistrationSettingsResponse(
enable_registration=bool(enable_registration), enable_registration=bool(enable_registration),
require_email_verification=bool(require_verification), require_email_verification=bool(require_verification),
email_configured=email_configured,
).model_dump() ).model_dump()
@@ -430,25 +439,50 @@ class AuthRegisterAdapter(AuthPublicAdapter):
AuditService.log_event( AuditService.log_event(
db=db, db=db,
event_type=AuditEventType.UNAUTHORIZED_ACCESS, event_type=AuditEventType.UNAUTHORIZED_ACCESS,
description=f"Registration attempt rejected - registration disabled: {register_request.email}", description=f"Registration attempt rejected - registration disabled: {register_request.username}",
ip_address=client_ip, ip_address=client_ip,
user_agent=user_agent, user_agent=user_agent,
metadata={"email": register_request.email, "reason": "registration_disabled"}, metadata={"username": register_request.username, "reason": "registration_disabled"},
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
# 检查邮箱后缀是否允许 email = register_request.email
suffix_allowed, suffix_error = validate_email_suffix(db, register_request.email) email_configured = EmailSenderService.is_smtp_configured(db)
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
# 如果邮箱服务未配置,强制不要求邮箱验证
if not email_configured:
require_verification = False
# 如果系统要求邮箱验证,则必须提供邮箱
if require_verification:
if not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="系统要求邮箱验证,请填写邮箱",
)
# 检查邮箱是否已验证
is_verified = await EmailVerificationService.is_email_verified(email)
if not is_verified:
logger.warning(f"注册失败:邮箱未验证: {email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请先完成邮箱验证。请发送验证码并验证后再注册。",
)
# 如果提供了邮箱,进行后缀验证
if email:
suffix_allowed, suffix_error = validate_email_suffix(db, email)
if not suffix_allowed: if not suffix_allowed:
logger.warning(f"注册失败:邮箱后缀不允许: {register_request.email}") logger.warning(f"注册失败:邮箱后缀不允许: {email}")
AuditService.log_event( AuditService.log_event(
db=db, db=db,
event_type=AuditEventType.UNAUTHORIZED_ACCESS, event_type=AuditEventType.UNAUTHORIZED_ACCESS,
description=f"Registration attempt rejected - email suffix not allowed: {register_request.email}", description=f"Registration attempt rejected - email suffix not allowed: {email}",
ip_address=client_ip, ip_address=client_ip,
user_agent=user_agent, user_agent=user_agent,
metadata={"email": register_request.email, "reason": "email_suffix_not_allowed"}, metadata={"email": email, "reason": "email_suffix_not_allowed"},
) )
db.commit() db.commit()
raise HTTPException( raise HTTPException(
@@ -456,35 +490,27 @@ class AuthRegisterAdapter(AuthPublicAdapter):
detail=suffix_error, detail=suffix_error,
) )
# 检查是否需要邮箱验证
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
if require_verification:
# 检查邮箱是否已验证
is_verified = await EmailVerificationService.is_email_verified(register_request.email)
if not is_verified:
logger.warning(f"注册失败:邮箱未验证: {register_request.email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请先完成邮箱验证。请发送验证码并验证后再注册。",
)
try: try:
# 读取系统配置的默认配额 # 读取系统配置的默认配额
default_quota = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0) default_quota = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
# email_verified 逻辑:
# - 要求邮箱验证且已通过验证True
# - 提供了邮箱但不要求验证False用户可后续自行验证
# - 未提供邮箱False
user = UserService.create_user( user = UserService.create_user(
db=db, db=db,
email=register_request.email, email=email, # 可以为 None
username=register_request.username, username=register_request.username,
password=register_request.password, password=register_request.password,
role=UserRole.USER, role=UserRole.USER,
quota_usd=default_quota, quota_usd=default_quota,
email_verified=bool(require_verification and email),
) )
AuditService.log_event( AuditService.log_event(
db=db, db=db,
event_type=AuditEventType.USER_CREATED, event_type=AuditEventType.USER_CREATED,
description=f"User registered: {user.email}", description=f"User registered: {user.username}" + (f" ({user.email})" if user.email else ""),
user_id=user.id, user_id=user.id,
ip_address=client_ip, ip_address=client_ip,
user_agent=user_agent, user_agent=user_agent,
@@ -494,9 +520,9 @@ class AuthRegisterAdapter(AuthPublicAdapter):
db.commit() db.commit()
# 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果) # 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果)
if require_verification: if require_verification and email:
try: try:
await EmailVerificationService.clear_verification(register_request.email) await EmailVerificationService.clear_verification(email)
except Exception as e: except Exception as e:
logger.warning(f"清理验证状态失败: {e}") logger.warning(f"清理验证状态失败: {e}")
@@ -510,10 +536,10 @@ class AuthRegisterAdapter(AuthPublicAdapter):
AuditService.log_event( AuditService.log_event(
db=db, db=db,
event_type=AuditEventType.UNAUTHORIZED_ACCESS, event_type=AuditEventType.UNAUTHORIZED_ACCESS,
description=f"Registration failed: {register_request.email} - {exc}", description=f"Registration failed: {register_request.username} - {exc}",
ip_address=client_ip, ip_address=client_ip,
user_agent=user_agent, user_agent=user_agent,
metadata={"email": register_request.email, "error": str(exc)}, metadata={"username": register_request.username, "error": str(exc)},
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))

View File

@@ -238,9 +238,12 @@ class ApiRequestPipeline:
# 直接查询数据库,确保返回的是当前 Session 绑定的对象 # 直接查询数据库,确保返回的是当前 Session 绑定的对象
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active: if not user or not user.is_active or user.is_deleted:
raise HTTPException(status_code=403, detail="用户不存在或已禁用") raise HTTPException(status_code=403, detail="用户不存在或已禁用")
if not self.auth_service.token_identity_matches_user(payload, user):
raise HTTPException(status_code=403, detail="无效的管理员令牌")
# 检查管理员权限 # 检查管理员权限
if user.role != UserRole.ADMIN: if user.role != UserRole.ADMIN:
logger.warning(f"非管理员尝试通过 JWT 访问管理端点: {user.email}") logger.warning(f"非管理员尝试通过 JWT 访问管理端点: {user.email}")
@@ -291,9 +294,12 @@ class ApiRequestPipeline:
raise HTTPException(status_code=401, detail="无效的用户令牌") raise HTTPException(status_code=401, detail="无效的用户令牌")
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active: if not user or not user.is_active or user.is_deleted:
raise HTTPException(status_code=403, detail="用户不存在或已禁用") raise HTTPException(status_code=403, detail="用户不存在或已禁用")
if not self.auth_service.token_identity_matches_user(payload, user):
raise HTTPException(status_code=403, detail="无效的用户令牌")
request.state.user_id = user.id request.state.user_id = user.id
return user, None return user, None

15
src/api/oauth/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
"""OAuth API 路由聚合。"""
from fastapi import APIRouter
from src.api.oauth.admin import router as admin_router
from src.api.oauth.public import router as public_router
from src.api.oauth.user import router as user_router
router = APIRouter()
router.include_router(public_router)
router.include_router(user_router)
router.include_router(admin_router)
__all__ = ["router"]

264
src/api/oauth/admin.py Normal file
View File

@@ -0,0 +1,264 @@
"""OAuth 管理端点(管理员)。"""
from __future__ import annotations
from typing import Any, Dict, List, 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.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
from src.database import get_db
from src.models.database import OAuthProvider
from src.services.auth.oauth.registry import get_oauth_provider_registry
from src.services.auth.oauth.service import OAuthService
router = APIRouter(prefix="/api/admin/oauth", tags=["Admin - OAuth"])
pipeline = ApiRequestPipeline()
class SupportedOAuthType(BaseModel):
provider_type: str
display_name: str
default_authorization_url: str
default_token_url: str
default_userinfo_url: str
default_scopes: List[str]
class OAuthProviderUpsertRequest(BaseModel):
display_name: str = Field(..., min_length=1, max_length=100)
client_id: str = Field(..., min_length=1, max_length=255)
client_secret: Optional[str] = Field(None, max_length=2048)
authorization_url_override: Optional[str] = Field(None, max_length=500)
token_url_override: Optional[str] = Field(None, max_length=500)
userinfo_url_override: Optional[str] = Field(None, max_length=500)
scopes: Optional[List[str]] = None
redirect_uri: str = Field(..., min_length=1, max_length=500)
frontend_callback_url: str = Field(..., min_length=1, max_length=500)
attribute_mapping: Optional[Dict[str, Any]] = None
extra_config: Optional[Dict[str, Any]] = None
is_enabled: bool = False
force: bool = False
class OAuthProviderAdminResponse(BaseModel):
provider_type: str
display_name: str
client_id: str
has_secret: bool
authorization_url_override: Optional[str] = None
token_url_override: Optional[str] = None
userinfo_url_override: Optional[str] = None
scopes: Optional[List[str]] = None
redirect_uri: str
frontend_callback_url: str
attribute_mapping: Optional[Dict[str, Any]] = None
extra_config: Optional[Dict[str, Any]] = None
is_enabled: bool
class OAuthProviderTestResponse(BaseModel):
authorization_url_reachable: bool
token_url_reachable: bool
secret_status: str
details: str = ""
class OAuthProviderTestRequest(BaseModel):
"""测试请求,使用表单数据而非数据库配置"""
client_id: str = Field(..., min_length=1)
client_secret: Optional[str] = None
authorization_url_override: Optional[str] = None
token_url_override: Optional[str] = None
redirect_uri: str = Field(..., min_length=1)
@router.get("/supported-types", response_model=List[SupportedOAuthType])
async def get_supported_types(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = GetSupportedTypesAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/providers", response_model=List[OAuthProviderAdminResponse])
async def list_provider_configs(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = ListOAuthProviderConfigsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/providers/{provider_type}", response_model=OAuthProviderAdminResponse)
async def get_provider_config(provider_type: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = GetOAuthProviderConfigAdapter(provider_type=provider_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/providers/{provider_type}", response_model=OAuthProviderAdminResponse)
async def upsert_provider_config(provider_type: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = UpsertOAuthProviderConfigAdapter(provider_type=provider_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/providers/{provider_type}")
async def delete_provider_config(provider_type: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = DeleteOAuthProviderConfigAdapter(provider_type=provider_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/providers/{provider_type}/test", response_model=OAuthProviderTestResponse)
async def test_provider_config(provider_type: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = TestOAuthProviderConfigAdapter(provider_type=provider_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
class GetSupportedTypesAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
registry = get_oauth_provider_registry()
types = registry.get_supported_types()
return [
SupportedOAuthType(
provider_type=t.provider_type,
display_name=t.display_name,
default_authorization_url=t.default_authorization_url,
default_token_url=t.default_token_url,
default_userinfo_url=t.default_userinfo_url,
default_scopes=list(t.default_scopes),
).model_dump()
for t in types
]
class ListOAuthProviderConfigsAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
rows = context.db.query(OAuthProvider).order_by(OAuthProvider.provider_type.asc()).all()
return [
OAuthProviderAdminResponse(
provider_type=str(row.provider_type or ""),
display_name=str(row.display_name or ""),
client_id=str(row.client_id or ""),
has_secret=bool(row.client_secret_encrypted),
authorization_url_override=row.authorization_url_override,
token_url_override=row.token_url_override,
userinfo_url_override=row.userinfo_url_override,
scopes=row.scopes,
redirect_uri=str(row.redirect_uri or ""),
frontend_callback_url=str(row.frontend_callback_url or ""),
attribute_mapping=row.attribute_mapping,
extra_config=row.extra_config,
is_enabled=bool(row.is_enabled),
).model_dump()
for row in rows
]
class GetOAuthProviderConfigAdapter(AdminApiAdapter):
def __init__(self, provider_type: str):
self.provider_type = provider_type
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
row = context.db.query(OAuthProvider).filter(OAuthProvider.provider_type == self.provider_type).first()
if not row:
raise InvalidRequestException("Provider 配置不存在")
return OAuthProviderAdminResponse(
provider_type=str(row.provider_type or ""),
display_name=str(row.display_name or ""),
client_id=str(row.client_id or ""),
has_secret=bool(row.client_secret_encrypted),
authorization_url_override=row.authorization_url_override,
token_url_override=row.token_url_override,
userinfo_url_override=row.userinfo_url_override,
scopes=row.scopes,
redirect_uri=str(row.redirect_uri or ""),
frontend_callback_url=str(row.frontend_callback_url or ""),
attribute_mapping=row.attribute_mapping,
extra_config=row.extra_config,
is_enabled=bool(row.is_enabled),
).model_dump()
class UpsertOAuthProviderConfigAdapter(AdminApiAdapter):
def __init__(self, provider_type: str):
self.provider_type = provider_type
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
payload = context.ensure_json_body()
try:
req = OAuthProviderUpsertRequest.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
row = await OAuthService.upsert_provider_config(
db=context.db,
provider_type=self.provider_type,
data=req,
)
return OAuthProviderAdminResponse(
provider_type=str(row.provider_type or ""),
display_name=str(row.display_name or ""),
client_id=str(row.client_id or ""),
has_secret=bool(row.client_secret_encrypted),
authorization_url_override=row.authorization_url_override,
token_url_override=row.token_url_override,
userinfo_url_override=row.userinfo_url_override,
scopes=row.scopes,
redirect_uri=str(row.redirect_uri or ""),
frontend_callback_url=str(row.frontend_callback_url or ""),
attribute_mapping=row.attribute_mapping,
extra_config=row.extra_config,
is_enabled=bool(row.is_enabled),
).model_dump()
class DeleteOAuthProviderConfigAdapter(AdminApiAdapter):
def __init__(self, provider_type: str):
self.provider_type = provider_type
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
await OAuthService.delete_provider_config(context.db, self.provider_type)
return {"message": "删除成功"}
class TestOAuthProviderConfigAdapter(AdminApiAdapter):
def __init__(self, provider_type: str):
self.provider_type = provider_type
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
payload = context.ensure_json_body()
try:
req = OAuthProviderTestRequest.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
# 如果没有提供 client_secret尝试从数据库获取已保存的
client_secret = req.client_secret
if not client_secret:
existing = context.db.query(OAuthProvider).filter(
OAuthProvider.provider_type == self.provider_type
).first()
if existing and existing.client_secret_encrypted:
client_secret = existing.get_client_secret()
result = await OAuthService.test_provider_config_with_data(
provider_type=self.provider_type,
client_id=req.client_id,
client_secret=client_secret,
authorization_url_override=req.authorization_url_override,
token_url_override=req.token_url_override,
redirect_uri=req.redirect_uri,
)
return OAuthProviderTestResponse(**result).model_dump()

59
src/api/oauth/public.py Normal file
View File

@@ -0,0 +1,59 @@
"""OAuth 公开端点(无需登录)。"""
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from starlette.responses import RedirectResponse
from src.database import get_db
from src.services.auth.oauth.service import OAuthService
router = APIRouter(prefix="/api/oauth", tags=["OAuth"])
@router.get("/providers")
async def list_oauth_providers(db: Session = Depends(get_db)) -> dict[str, Any]:
"""
获取可用 OAuth Providers 列表。
模块未启用时返回空列表(前端友好)。
"""
providers = await OAuthService.list_public_providers(db)
return {"providers": providers}
@router.get("/{provider_type}/authorize")
async def oauth_authorize(provider_type: str, db: Session = Depends(get_db)) -> RedirectResponse:
"""
发起 OAuth 登录login flow
"""
url = await OAuthService.build_login_authorize_url(db, provider_type)
return RedirectResponse(url=url, status_code=status.HTTP_302_FOUND)
@router.get("/{provider_type}/callback")
async def oauth_callback(
provider_type: str,
db: Session = Depends(get_db),
code: Optional[str] = Query(None),
state: Optional[str] = Query(None),
error: Optional[str] = Query(None),
error_description: Optional[str] = Query(None),
) -> RedirectResponse:
"""
OAuth 回调端点。
成功/失败都会重定向到前端回调页。
"""
redirect_url = await OAuthService.handle_callback(
db=db,
provider_type=provider_type,
state=state or "",
code=code,
error=error,
error_description=error_description,
)
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)

84
src/api/oauth/user.py Normal file
View File

@@ -0,0 +1,84 @@
"""OAuth 用户端点(需登录)。"""
from __future__ import annotations
from typing import Any, cast
from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.orm import Session
from starlette.responses import RedirectResponse
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.database import get_db
from src.services.auth.oauth.service import OAuthService
router = APIRouter(prefix="/api/user/oauth", tags=["User - OAuth"])
pipeline = ApiRequestPipeline()
@router.get("/bindable-providers")
async def list_bindable_providers(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
adapter = ListBindableProvidersAdapter()
result = await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
return cast(dict[str, Any], result)
@router.get("/links")
async def list_my_oauth_links(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
adapter = ListMyOAuthLinksAdapter()
result = await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
return cast(dict[str, Any], result)
@router.get("/{provider_type}/bind")
async def bind_oauth_provider(
provider_type: str, request: Request, db: Session = Depends(get_db)
) -> RedirectResponse:
adapter = BindOAuthProviderAdapter(provider_type=provider_type)
result = await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
return cast(RedirectResponse, result)
@router.delete("/{provider_type}")
async def unbind_oauth_provider(
provider_type: str, request: Request, db: Session = Depends(get_db)
) -> dict[str, Any]:
adapter = UnbindOAuthProviderAdapter(provider_type=provider_type)
result = await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
return cast(dict[str, Any], result)
class ListBindableProvidersAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: # type: ignore[override]
assert context.user is not None
providers = await OAuthService.list_bindable_providers(context.db, context.user)
return {"providers": providers}
class ListMyOAuthLinksAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: # type: ignore[override]
assert context.user is not None
links = await OAuthService.list_user_links(context.db, context.user)
return {"links": links}
class BindOAuthProviderAdapter(AuthenticatedApiAdapter):
def __init__(self, provider_type: str):
self.provider_type = provider_type
async def handle(self, context: ApiRequestContext) -> RedirectResponse: # type: ignore[override]
assert context.user is not None
url = await OAuthService.build_bind_authorize_url(context.db, context.user, self.provider_type)
return RedirectResponse(url=url, status_code=status.HTTP_302_FOUND)
class UnbindOAuthProviderAdapter(AuthenticatedApiAdapter):
def __init__(self, provider_type: str):
self.provider_type = provider_type
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: # type: ignore[override]
assert context.user is not None
await OAuthService.unbind_provider(context.db, context.user, self.provider_type)
return {"message": "解绑成功"}

View File

@@ -446,16 +446,31 @@ class ChangePasswordAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
# LDAP 用户不能修改密码
from src.core.enums import AuthSource
if user.auth_source == AuthSource.LDAP:
raise ForbiddenException("LDAP 用户不能在此修改密码")
# 判断用户是否已有密码
has_password = bool(user.password_hash)
if has_password:
# 已有密码:需要验证旧密码
if not request.old_password:
raise InvalidRequestException("请输入当前密码")
if not user.verify_password(request.old_password): if not user.verify_password(request.old_password):
raise InvalidRequestException("旧密码错误") raise InvalidRequestException("旧密码错误")
# 无密码(如 OAuth 用户首次设置):无需旧密码
if len(request.new_password) < 6: if len(request.new_password) < 6:
raise InvalidRequestException("密码长度至少6位") raise InvalidRequestException("密码长度至少6位")
user.set_password(request.new_password) user.set_password(request.new_password)
user.updated_at = datetime.now(timezone.utc) user.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
logger.info(f"用户修改密码: {user.email}") action = "修改" if has_password else "设置"
return {"message": "密码修改成功"} logger.info(f"用户{action}密码: {user.email}")
return {"message": f"密码{action}成功"}
class ListMyApiKeysAdapter(AuthenticatedApiAdapter): class ListMyApiKeysAdapter(AuthenticatedApiAdapter):

View File

@@ -37,3 +37,4 @@ class AuthSource(str, Enum):
LOCAL = "local" # 本地认证 LOCAL = "local" # 本地认证
LDAP = "ldap" # LDAP 认证 LDAP = "ldap" # LDAP 认证
OAUTH = "oauth" # OAuth 认证(账号首创来源)

View File

@@ -81,6 +81,13 @@ FIELD_NAME_TRANSLATIONS = {
"is_pinned": "置顶状态", "is_pinned": "置顶状态",
"start_time": "开始时间", "start_time": "开始时间",
"end_time": "结束时间", "end_time": "结束时间",
# OAuth 相关字段
"client_id": "Client ID",
"client_secret": "Client Secret",
"redirect_uri": "回调地址",
"frontend_callback_url": "前端回调地址",
"display_name": "显示名称",
"scopes": "授权范围",
} }
@@ -340,6 +347,18 @@ class NotFoundException(ProxyException):
) )
class ConfirmationRequiredException(ProxyException):
"""需要用户确认的操作"""
def __init__(self, message: str, affected_count: int, action: str = "disable"):
super().__init__(
status_code=status.HTTP_409_CONFLICT,
error_type="confirmation_required",
message=message,
details={"affected_count": affected_count, "action": action},
)
class ForbiddenException(ProxyException): class ForbiddenException(ProxyException):
"""权限不足""" """权限不足"""

View File

@@ -6,10 +6,11 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional, Tuple
if TYPE_CHECKING: if TYPE_CHECKING:
from fastapi import APIRouter from fastapi import APIRouter
from sqlalchemy.orm import Session
class ModuleCategory(str, Enum): class ModuleCategory(str, Enum):
@@ -84,6 +85,9 @@ class ModuleDefinition:
# 自定义依赖检测(可选,用于检测 ldap3 等库是否安装) # 自定义依赖检测(可选,用于检测 ldap3 等库是否安装)
check_dependencies: Optional[Callable[[], bool]] = None check_dependencies: Optional[Callable[[], bool]] = None
# 配置验证(可选,启用模块时调用,返回 (success, error_message)
validate_config: Optional[Callable[["Session"], Tuple[bool, str]]] = None
@dataclass @dataclass
class ModuleStatus: class ModuleStatus:
@@ -97,6 +101,8 @@ class ModuleStatus:
available: bool # 部署级可用(环境变量 + 依赖库) available: bool # 部署级可用(环境变量 + 依赖库)
enabled: bool # 运行级启用(数据库配置) enabled: bool # 运行级启用(数据库配置)
active: bool # 最终激活状态 (available && enabled && dependencies_ok) active: bool # 最终激活状态 (available && enabled && dependencies_ok)
config_validated: bool # 配置验证通过(只有验证通过才允许启用)
config_error: Optional[str] # 配置验证失败的错误信息
# 显示信息 # 显示信息
display_name: str display_name: str

View File

@@ -175,6 +175,34 @@ class ModuleRegistry:
return True return True
# ========== 配置验证 ==========
def validate_config(self, name: str, db: "Session") -> tuple[bool, str]:
"""
验证模块配置是否有效
Args:
name: 模块名称
db: 数据库会话
Returns:
(validated, error_message) - validated 为 True 表示配置有效
"""
if name not in self._modules:
return False, "模块不存在"
module = self._modules[name]
# 没有配置验证函数的模块,默认配置有效
if not module.validate_config:
return True, ""
try:
return module.validate_config(db)
except Exception as e:
logger.warning(f"Module [{name}] config validation error: {e}")
return False, f"配置验证出错: {str(e)}"
# ========== 状态查询 ========== # ========== 状态查询 ==========
def get_module_status( def get_module_status(
@@ -195,11 +223,28 @@ class ModuleRegistry:
meta = module.metadata meta = module.metadata
available = self.is_available(name) available = self.is_available(name)
# 获取配置验证状态
config_validated = False
config_error: Optional[str] = None
if available:
config_validated, config_error = self.validate_config(name, db)
if config_validated:
config_error = None # 验证通过时清空错误信息
# 获取启用状态
enabled = self.is_enabled(name, db) if available else False
# 注意:配置验证失败时不自动禁用模块
# 自动禁用会在查询方法中产生写操作副作用,违反幂等性原则
# 配置验证状态通过 config_validated/config_error 字段返回,由调用方决定如何处理
return ModuleStatus( return ModuleStatus(
name=name, name=name,
available=available, available=available,
enabled=self.is_enabled(name, db) if available else False, enabled=enabled,
active=self.is_active(name, db) if available else False, active=self.is_active(name, db) if available else False,
config_validated=config_validated,
config_error=config_error,
display_name=meta.display_name, display_name=meta.display_name,
description=meta.description, description=meta.description,
category=meta.category, category=meta.category,

View File

@@ -364,6 +364,7 @@ def init_admin_user(db: Session) -> None:
# 创建管理员账户 # 创建管理员账户
admin = User( admin = User(
email=config.admin_email, email=config.admin_email,
email_verified=True,
username=config.admin_username, username=config.admin_username,
role=UserRole.ADMIN, role=UserRole.ADMIN,
is_active=True, is_active=True,

View File

@@ -55,7 +55,7 @@ class LoginResponse(BaseModel):
token_type: str = "bearer" token_type: str = "bearer"
expires_in: int = 86400 # Token有效期默认24小时 expires_in: int = 86400 # Token有效期默认24小时
user_id: str user_id: str
email: str email: Optional[str] = None
username: str username: str
role: str role: str
@@ -78,14 +78,19 @@ class RefreshTokenResponse(BaseModel):
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
"""注册请求""" """注册请求"""
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址") email: Optional[str] = Field(None, max_length=255, description="邮箱地址(可选)")
username: str = Field(..., min_length=2, max_length=50, description="用户名") username: str = Field(..., min_length=2, max_length=50, description="用户名")
password: str = Field(..., min_length=6, max_length=128, description="密码") password: str = Field(..., min_length=6, max_length=128, description="密码")
@classmethod
@field_validator("email") @field_validator("email")
@classmethod
def validate_email(cls, v): def validate_email(cls, v):
"""验证邮箱格式""" """验证邮箱格式(如果提供)"""
if v is None:
return None
v = v.strip()
if not v:
return None
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, v): if not re.match(email_pattern, v):
raise ValueError("邮箱格式无效") raise ValueError("邮箱格式无效")
@@ -121,7 +126,7 @@ class RegisterResponse(BaseModel):
"""注册响应""" """注册响应"""
user_id: str user_id: str
email: str email: Optional[str] = None
username: str username: str
message: str message: str
@@ -223,6 +228,7 @@ class RegistrationSettingsResponse(BaseModel):
enable_registration: bool enable_registration: bool
require_email_verification: bool require_email_verification: bool
email_configured: bool = Field(description="是否配置了邮箱服务")
# ========== 用户管理 ========== # ========== 用户管理 ==========
@@ -335,7 +341,7 @@ class UserResponse(BaseModel):
"""用户响应""" """用户响应"""
id: str id: str
email: str email: Optional[str] = None
username: str username: str
role: UserRole role: UserRole
allowed_providers: Optional[List[str]] = None # 允许使用的提供商 ID 列表 allowed_providers: Optional[List[str]] = None # 允许使用的提供商 ID 列表
@@ -699,7 +705,7 @@ class UpdatePreferencesRequest(BaseModel):
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
"""修改密码请求""" """修改密码请求"""
old_password: str old_password: Optional[str] = None # 可选:首次设置密码时不需要
new_password: str new_password: str

View File

@@ -42,9 +42,13 @@ class User(Base):
__tablename__ = "users" __tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
email = Column(String(255), unique=True, index=True, nullable=False) # OAuth 用户可能没有邮箱Postgres unique 允许多个 NULL
email = Column(String(255), unique=True, index=True, nullable=True)
# 注意:所有创建用户的入口必须显式写入 true/false禁止依赖默认值
email_verified = Column(Boolean, nullable=False)
username = Column(String(100), unique=True, index=True, nullable=False) username = Column(String(100), unique=True, index=True, nullable=False)
password_hash = Column(String(255), nullable=False) # OAuth 用户可能没有本地密码v1 仅做字段兼容)
password_hash = Column(String(255), nullable=True)
role = Column( role = Column(
Enum( Enum(
UserRole, UserRole,
@@ -509,6 +513,95 @@ class LDAPConfig(Base):
return crypto_service.decrypt(self.bind_password_encrypted) return crypto_service.decrypt(self.bind_password_encrypted)
class OAuthProvider(Base):
"""OAuth Provider 配置表(按 provider_type 唯一)"""
__tablename__ = "oauth_providers"
# 使用 provider_type 作为主键,便于通过 URL 参数直接定位配置
provider_type = Column(String(50), primary_key=True)
display_name = Column(String(100), nullable=False)
client_id = Column(String(255), nullable=False)
client_secret_encrypted = Column(Text, nullable=True) # 允许 NULL 表示尚未配置/已清除
# 可选覆盖端点(需在业务层做白名单校验)
authorization_url_override = Column(String(500), nullable=True)
token_url_override = Column(String(500), nullable=True)
userinfo_url_override = Column(String(500), nullable=True)
# 可选覆盖 scopesJSON 列表)
scopes = Column(JSON, nullable=True)
# 服务端控制 redirect_uri 与前端回调 URL
redirect_uri = Column(String(500), nullable=False)
frontend_callback_url = Column(String(500), nullable=False)
# Provider 特定配置/映射
attribute_mapping = Column(JSON, nullable=True)
extra_config = Column(JSON, nullable=True)
is_enabled = Column(Boolean, default=False, nullable=False)
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_client_secret(self, secret: str) -> None:
"""设置并加密 client_secret"""
from src.core.crypto import crypto_service
self.client_secret_encrypted = crypto_service.encrypt(secret)
def get_client_secret(self) -> str:
"""获取解密后的 client_secret未配置时返回空串"""
from src.core.crypto import crypto_service
if not self.client_secret_encrypted:
return ""
return crypto_service.decrypt(self.client_secret_encrypted)
class UserOAuthLink(Base):
"""用户与 OAuth Provider 的绑定关系"""
__tablename__ = "user_oauth_links"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
provider_type = Column(
String(50),
ForeignKey("oauth_providers.provider_type", ondelete="CASCADE"),
nullable=False,
index=True,
)
provider_user_id = Column(String(255), nullable=False)
provider_username = Column(String(255), nullable=True)
provider_email = Column(String(255), nullable=True)
extra_data = Column(JSON, nullable=True)
linked_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
last_login_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("provider_type", "provider_user_id", name="uq_oauth_provider_user"),
UniqueConstraint("user_id", "provider_type", name="uq_user_oauth_provider"),
)
class Provider(Base): class Provider(Base):
"""提供商配置表""" """提供商配置表"""

View File

@@ -10,10 +10,12 @@ from src.core.modules.base import ModuleDefinition
# 导入所有模块定义 # 导入所有模块定义
from src.modules.ldap import ldap_module from src.modules.ldap import ldap_module
from src.modules.oauth import oauth_module
# 所有模块列表 # 所有模块列表
ALL_MODULES: List[ModuleDefinition] = [ ALL_MODULES: List[ModuleDefinition] = [
ldap_module, ldap_module,
oauth_module,
] ]
__all__ = ["ALL_MODULES"] __all__ = ["ALL_MODULES"]

View File

@@ -4,6 +4,8 @@ LDAP 认证模块
提供 LDAP/Active Directory 用户认证支持 提供 LDAP/Active Directory 用户认证支持
""" """
from typing import TYPE_CHECKING, Tuple
from src.core.modules.base import ( from src.core.modules.base import (
ModuleCategory, ModuleCategory,
ModuleDefinition, ModuleDefinition,
@@ -11,6 +13,9 @@ from src.core.modules.base import (
ModuleMetadata, ModuleMetadata,
) )
if TYPE_CHECKING:
from sqlalchemy.orm import Session
def _get_router(): def _get_router():
"""延迟导入路由(避免启动时加载重依赖)""" """延迟导入路由(避免启动时加载重依赖)"""
@@ -26,6 +31,46 @@ async def _health_check() -> ModuleHealth:
return ModuleHealth.UNKNOWN return ModuleHealth.UNKNOWN
def _validate_config(db: "Session") -> Tuple[bool, str]:
"""
验证 LDAP 配置是否可以启用模块
检查项:
1. 配置是否存在
2. 必填字段是否完整
3. 绑定密码是否可解密
注意:不在此处执行连接测试,因为 validate_config 会在每次查询模块状态时调用,
同步阻塞等待 LDAP 服务器响应会严重影响性能。连接测试应在专门的测试接口中进行。
"""
from src.core.crypto import crypto_service
from src.models.database import LDAPConfig
config = db.query(LDAPConfig).first()
if not config:
return False, "请先配置 LDAP 连接信息"
# 检查必填字段
if not config.server_url:
return False, "请配置 LDAP 服务器地址"
if not config.bind_dn:
return False, "请配置绑定 DN"
if not config.base_dn:
return False, "请配置搜索基准 DN"
if not config.bind_password_encrypted:
return False, "请配置绑定密码"
# 尝试解密密码(仅验证可解密,不执行连接测试)
try:
bind_password = crypto_service.decrypt(config.bind_password_encrypted)
if not bind_password:
return False, "绑定密码为空,请重新设置"
except Exception:
return False, "绑定密码解密失败,请重新设置"
return True, ""
# LDAP 模块定义 # LDAP 模块定义
ldap_module = ModuleDefinition( ldap_module = ModuleDefinition(
metadata=ModuleMetadata( metadata=ModuleMetadata(
@@ -47,4 +92,5 @@ ldap_module = ModuleDefinition(
), ),
router_factory=_get_router, router_factory=_get_router,
health_check=_health_check, health_check=_health_check,
validate_config=_validate_config,
) )

View File

@@ -0,0 +1,88 @@
"""
OAuth 认证模块
提供可配置的 OAuth 登录/绑定能力。
"""
from typing import TYPE_CHECKING, Tuple
from src.core.modules.base import (
ModuleCategory,
ModuleDefinition,
ModuleHealth,
ModuleMetadata,
)
if TYPE_CHECKING:
from sqlalchemy.orm import Session
def _get_router():
"""延迟导入路由(避免启动时加载重依赖/副作用)。"""
# 延迟 discover避免 alembic/mypy 等场景导入时触发 entry_points 解析
from src.services.auth.oauth.registry import get_oauth_provider_registry
get_oauth_provider_registry().discover_providers()
from src.api.oauth import router
return router
async def _health_check() -> ModuleHealth:
# v1不做外部网络探测避免启动时阻塞
return ModuleHealth.UNKNOWN
def _validate_config(db: "Session") -> Tuple[bool, str]:
"""
验证 OAuth 配置是否可以启用模块
检查项:
1. 至少有一个已启用的 Provider 配置
2. 已启用的 Provider 必须有 client_id 和 client_secret
"""
from src.models.database import OAuthProvider
# 查找所有已启用的 Provider
enabled_providers = (
db.query(OAuthProvider)
.filter(OAuthProvider.is_enabled.is_(True))
.all()
)
if not enabled_providers:
return False, "请先配置并启用至少一个 OAuth Provider"
# 检查每个已启用的 Provider 配置完整性
for provider in enabled_providers:
if not provider.client_id:
return False, f"Provider [{provider.display_name}] 未配置 Client ID"
if not provider.client_secret_encrypted:
return False, f"Provider [{provider.display_name}] 未配置 Client Secret"
if not provider.redirect_uri:
return False, f"Provider [{provider.display_name}] 未配置回调地址"
return True, ""
oauth_module = ModuleDefinition(
metadata=ModuleMetadata(
name="oauth",
display_name="OAuth 登录",
description="支持通过第三方 OAuth Provider 登录/绑定账号",
category=ModuleCategory.AUTH,
env_key="OAUTH_AVAILABLE",
default_available=True,
required_packages=["httpx", "redis"],
api_prefix="/api/oauth",
admin_route="/admin/oauth",
admin_menu_icon="Key",
admin_menu_group="system",
admin_menu_order=55,
),
router_factory=_get_router,
health_check=_health_check,
validate_config=_validate_config,
)

View File

@@ -74,6 +74,13 @@ class JwtAuthPlugin(AuthPlugin):
if not user.is_active: if not user.is_active:
logger.warning(f"JWT认证失败 - 用户已禁用: {user.email}") logger.warning(f"JWT认证失败 - 用户已禁用: {user.email}")
return None return None
if user.is_deleted:
logger.warning(f"JWT认证失败 - 用户已删除: {user.email}")
return None
if not AuthService.token_identity_matches_user(payload, user):
logger.warning("JWT认证失败 - Token身份校验失败")
return None
# 创建认证上下文 # 创建认证上下文
auth_context = AuthContext( auth_context = AuthContext(

View File

@@ -0,0 +1,6 @@
"""OAuth 认证相关服务。"""
from .service import OAuthService
__all__ = ["OAuthService"]

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Optional
from urllib.parse import urlencode, urlparse, urlunparse
import httpx
from src.services.auth.oauth.models import OAuthToken, OAuthUserInfo
if TYPE_CHECKING:
from src.models.database import OAuthProvider
class OAuthProviderBase(ABC):
"""
OAuth Provider 基类(稳定扩展点)。
v1 收敛点:仅实现 OAuth2 授权码流程所需的最小接口。
"""
provider_type: str
display_name: str
# 允许的 host 白名单(用于端点覆盖校验,支持子域名)
allowed_domains: tuple[str, ...] = ()
authorization_url: str
token_url: str
userinfo_url: str
default_scopes: tuple[str, ...] = ()
def get_effective_authorization_url(self, config: "OAuthProvider") -> str:
return config.authorization_url_override or self.authorization_url
def get_effective_token_url(self, config: "OAuthProvider") -> str:
return config.token_url_override or self.token_url
def get_effective_userinfo_url(self, config: "OAuthProvider") -> str:
return config.userinfo_url_override or self.userinfo_url
def get_effective_scopes(self, config: "OAuthProvider") -> str:
scopes = config.scopes or list(self.default_scopes)
return " ".join(scopes)
def get_authorization_url(self, config: "OAuthProvider", state: str) -> str:
"""
构造 provider 授权 URL。
redirect_uri 必须由服务端控制,不从客户端传入。
"""
base = self.get_effective_authorization_url(config)
# 避免覆盖原有 query若 provider 默认 url 带 query保留
parsed = urlparse(base)
query: dict[str, str] = {}
if parsed.query:
# 保留已有 query 参数
for kv in parsed.query.split("&"):
if not kv:
continue
if "=" in kv:
k, v = kv.split("=", 1)
query[k] = v
else:
query[kv] = ""
client_id = config.client_id
redirect_uri = config.redirect_uri
if not client_id or not redirect_uri:
raise ValueError("OAuthProvider 配置不完整client_id/redirect_uri 不能为空")
query.update(
{
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": self.get_effective_scopes(config),
"state": state,
}
)
return urlunparse(parsed._replace(query=urlencode(query)))
@abstractmethod
async def exchange_code(self, config: "OAuthProvider", code: str) -> OAuthToken:
"""使用授权码兑换 token。"""
@abstractmethod
async def get_user_info(self, config: "OAuthProvider", access_token: str) -> OAuthUserInfo:
"""获取用户信息。"""
async def _http_post_form(
self,
url: str,
data: dict[str, str],
*,
timeout_seconds: float = 5.0,
headers: Optional[dict[str, str]] = None,
) -> httpx.Response:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds)) as client:
return await client.post(url, data=data, headers=headers)
async def _http_get(
self,
url: str,
*,
timeout_seconds: float = 5.0,
headers: Optional[dict[str, str]] = None,
) -> httpx.Response:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds)) as client:
return await client.get(url, headers=headers)

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
@dataclass(frozen=True)
class OAuthToken:
access_token: str
token_type: str = "bearer"
refresh_token: Optional[str] = None
expires_in: Optional[int] = None
id_token: Optional[str] = None
scope: Optional[str] = None
raw: Optional[dict[str, Any]] = None
@dataclass(frozen=True)
class OAuthUserInfo:
id: str
username: Optional[str] = None
email: Optional[str] = None
email_verified: Optional[bool] = None
raw: Optional[dict[str, Any]] = None
class OAuthFlowError(Exception):
"""用于 OAuth 流程的可控错误(会映射到 error_code"""
def __init__(self, error_code: str, detail: str = ""):
super().__init__(error_code)
self.error_code = error_code
self.detail = detail

View File

@@ -0,0 +1,6 @@
"""内置 OAuth providersv1"""
from .linuxdo import LinuxDoOAuthProvider
__all__ = ["LinuxDoOAuthProvider"]

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from src.core.logger import logger
from src.services.auth.oauth.base import OAuthProviderBase
from src.services.auth.oauth.models import OAuthFlowError, OAuthToken, OAuthUserInfo
if TYPE_CHECKING:
from src.models.database import OAuthProvider
class LinuxDoOAuthProvider(OAuthProviderBase):
"""
LinuxDo OAuth Provider。
基于论坛信任等级trust_level 0-4的 OAuth2 认证,
用于通过用户等级进行额度配给和频率限制。
参考https://linux.do/t/topic/329408
返回的用户信息示例:
{
"id": 1,
"username": "neo",
"name": "Neo",
"active": true,
"trust_level": 4,
"email": "u1@linux.do",
"avatar_url": "https://linux.do/xxxx",
"silenced": false
}
"""
provider_type = "linuxdo"
display_name = "Linux Do"
allowed_domains = ("linux.do", "connect.linux.do", "connect.linuxdo.org")
# 默认端点
authorization_url = "https://connect.linux.do/oauth2/authorize"
token_url = "https://connect.linux.do/oauth2/token"
userinfo_url = "https://connect.linux.do/api/user"
# LinuxDo 不需要 scope
default_scopes = ()
async def exchange_code(self, config: "OAuthProvider", code: str) -> OAuthToken:
url = self.get_effective_token_url(config)
client_secret = config.get_client_secret()
if not client_secret:
raise OAuthFlowError("provider_unavailable", "client_secret 未配置")
redirect_uri = config.redirect_uri
client_id = config.client_id
if not redirect_uri or not client_id:
raise OAuthFlowError("provider_unavailable", "redirect_uri/client_id 未配置")
resp = await self._http_post_form(
url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
},
)
if resp.status_code >= 400:
logger.warning("LinuxDo token 兑换失败: status={}", resp.status_code)
raise OAuthFlowError("token_exchange_failed", f"status={resp.status_code}")
data = resp.json()
access_token = data.get("access_token")
if not access_token:
raise OAuthFlowError("token_exchange_failed", "missing access_token")
return OAuthToken(
access_token=str(access_token),
token_type=str(data.get("token_type") or "bearer"),
refresh_token=(str(data["refresh_token"]) if data.get("refresh_token") else None),
expires_in=(int(data["expires_in"]) if data.get("expires_in") is not None else None),
id_token=(str(data["id_token"]) if data.get("id_token") else None),
scope=(str(data["scope"]) if data.get("scope") else None),
raw=data,
)
async def get_user_info(self, config: "OAuthProvider", access_token: str) -> OAuthUserInfo:
url = self.get_effective_userinfo_url(config)
resp = await self._http_get(url, headers={"Authorization": f"Bearer {access_token}"})
if resp.status_code >= 400:
logger.warning("LinuxDo userinfo 获取失败: status={}", resp.status_code)
raise OAuthFlowError("userinfo_fetch_failed", f"status={resp.status_code}")
data: dict[str, Any] = resp.json()
# LinuxDo 返回的 id 是数字类型
provider_user_id = data.get("id")
if provider_user_id is None:
raise OAuthFlowError("userinfo_fetch_failed", "missing user id")
return OAuthUserInfo(
id=str(provider_user_id),
username=data.get("username"),
email=str(data["email"]).lower() if data.get("email") else None,
email_verified=None, # LinuxDo 不返回此字段
raw=data, # 包含 trust_level, active, silenced, avatar_url, name 等
)

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional
from src.core.logger import logger
from src.services.auth.oauth.base import OAuthProviderBase
@dataclass(frozen=True)
class SupportedOAuthType:
provider_type: str
display_name: str
# 默认端点(用于前端 placeholder 展示)
default_authorization_url: str
default_token_url: str
default_userinfo_url: str
default_scopes: tuple[str, ...]
class OAuthProviderRegistry:
"""Provider 注册表(支持延迟 discover"""
def __init__(self) -> None:
self._providers: Dict[str, OAuthProviderBase] = {}
self._discovered: bool = False
def discover_providers(self) -> None:
"""发现并注册 providers幂等"""
if self._discovered:
return
self._discovered = True
# 1) 内置 providersv1至少保证 linuxdo 可用)
try:
from src.services.auth.oauth.providers.linuxdo import LinuxDoOAuthProvider
self.register(LinuxDoOAuthProvider())
except Exception as exc:
logger.warning("OAuth 内置 provider 加载失败: {}", exc)
# 2) entry_points 插件(可选)
try:
from importlib.metadata import entry_points
eps = entry_points()
# Python 3.10+ 支持 select旧接口返回 dict
if hasattr(eps, "select"):
candidates = list(eps.select(group="aether.oauth_providers")) # type: ignore[attr-defined]
else:
candidates = list(eps.get("aether.oauth_providers", [])) # type: ignore[call-arg]
for ep in candidates:
try:
loaded = ep.load()
provider = loaded() if isinstance(loaded, type) else loaded
if not isinstance(provider, OAuthProviderBase):
logger.warning(
"OAuth provider entry_point 无效: {} (type={})", ep.name, type(provider)
)
continue
self.register(provider)
except Exception as e:
logger.warning("OAuth provider entry_point 加载失败: {}: {}", ep.name, e)
except Exception as exc:
# entry_points 不可用不影响主流程
logger.debug("OAuth entry_points discover skipped: {}", exc)
def register(self, provider: OAuthProviderBase) -> None:
self._providers[provider.provider_type] = provider
def get_provider(self, provider_type: str) -> Optional[OAuthProviderBase]:
return self._providers.get(provider_type)
def get_supported_types(self) -> List[SupportedOAuthType]:
return [
SupportedOAuthType(
provider_type=p.provider_type,
display_name=p.display_name,
default_authorization_url=p.authorization_url,
default_token_url=p.token_url,
default_userinfo_url=p.userinfo_url,
default_scopes=p.default_scopes,
)
for p in sorted(self._providers.values(), key=lambda x: x.provider_type)
]
_registry: Optional[OAuthProviderRegistry] = None
def get_oauth_provider_registry() -> OAuthProviderRegistry:
global _registry
if _registry is None:
_registry = OAuthProviderRegistry()
return _registry

View File

@@ -0,0 +1,945 @@
from __future__ import annotations
import re
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
import httpx
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.clients.redis_client import get_redis_client
from src.core.enums import AuthSource, UserRole
from src.core.exceptions import ConfirmationRequiredException, InvalidRequestException
from src.core.logger import logger
from src.core.modules import get_module_registry
from src.models.database import OAuthProvider, User, UserOAuthLink
from src.services.auth.ldap import LDAPService
from src.services.auth.oauth.models import OAuthFlowError, OAuthUserInfo
from src.services.auth.oauth.registry import get_oauth_provider_registry
from src.services.auth.oauth.state import consume_oauth_state, create_oauth_state
from src.services.auth.service import AuthService
from src.services.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService
from src.services.auth.oauth.base import OAuthProviderBase
class OAuthService:
"""OAuth 核心业务服务v1"""
@staticmethod
def _require_module_active(db: Session) -> None:
registry = get_module_registry()
if not registry.is_active("oauth", db):
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth 模块未启用")
@staticmethod
def _get_provider_impl(provider_type: str) -> Optional[OAuthProviderBase]:
registry = get_oauth_provider_registry()
registry.discover_providers()
provider = registry.get_provider(provider_type)
return provider
@staticmethod
def _get_provider_config(db: Session, provider_type: str) -> OAuthProvider:
row = db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first()
if not row:
raise InvalidRequestException("Provider 配置不存在")
return row
@staticmethod
def _get_enabled_provider_config(db: Session, provider_type: str) -> OAuthProvider:
row = OAuthService._get_provider_config(db, provider_type)
if not row.is_enabled:
raise OAuthFlowError("provider_disabled", "provider 未启用")
return row
@staticmethod
def _build_frontend_error_redirect(
frontend_callback_url: str, *, error_code: str, error_detail: str = ""
) -> str:
parsed = urlparse(frontend_callback_url)
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
query["error_code"] = error_code
if error_detail:
query["error_detail"] = (error_detail[:200]).strip()
return urlunparse(parsed._replace(query=urlencode(query), fragment=""))
@staticmethod
def _build_frontend_bind_success_redirect(frontend_callback_url: str, display_name: str) -> str:
parsed = urlparse(frontend_callback_url)
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
query["oauth_bound"] = display_name
return urlunparse(parsed._replace(query=urlencode(query), fragment=""))
@staticmethod
def _build_frontend_login_success_redirect(
frontend_callback_url: str, *, access_token: str, refresh_token: str
) -> str:
parsed = urlparse(frontend_callback_url)
fragment = urlencode(
{
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"expires_in": 86400,
}
)
# fragment 不会被发送回后端,适配当前 localStorage 登录态方案
return urlunparse(parsed._replace(fragment=fragment))
@staticmethod
async def list_public_providers(db: Session) -> list[dict[str, str]]:
registry = get_module_registry()
if not registry.is_active("oauth", db):
return []
supported = get_oauth_provider_registry()
supported.discover_providers()
rows = (
db.query(OAuthProvider)
.filter(OAuthProvider.is_enabled.is_(True))
.order_by(OAuthProvider.provider_type.asc())
.all()
)
result: list[dict[str, str]] = []
for row in rows:
provider_type_value = row.provider_type
if not provider_type_value:
continue
provider_type_str = str(provider_type_value)
if supported.get_provider(provider_type_str) is None:
continue
display_name = row.display_name or provider_type_str
result.append({"provider_type": provider_type_str, "display_name": str(display_name)})
return result
@staticmethod
async def build_login_authorize_url(db: Session, provider_type: str) -> str:
OAuthService._require_module_active(db)
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
raise HTTPException(status_code=404, detail="不支持的 OAuth provider")
try:
config = OAuthService._get_enabled_provider_config(db, provider_type)
except OAuthFlowError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=exc.error_code
)
redis = await get_redis_client(require_redis=True)
if redis is None:
raise HTTPException(status_code=503, detail="Redis 不可用")
state = await create_oauth_state(redis, provider_type=provider_type, action="login", user_id=None)
return provider.get_authorization_url(config, state)
@staticmethod
async def build_bind_authorize_url(db: Session, user: User, provider_type: str) -> str:
OAuthService._require_module_active(db)
if user.auth_source == AuthSource.LDAP:
raise HTTPException(status_code=403, detail="LDAP 用户不允许绑定 OAuth")
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
raise HTTPException(status_code=404, detail="不支持的 OAuth provider")
try:
config = OAuthService._get_enabled_provider_config(db, provider_type)
except OAuthFlowError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=exc.error_code
)
redis = await get_redis_client(require_redis=True)
if redis is None:
raise HTTPException(status_code=503, detail="Redis 不可用")
state = await create_oauth_state(redis, provider_type=provider_type, action="bind", user_id=user.id)
return provider.get_authorization_url(config, state)
@staticmethod
def _sanitize_username(raw: Optional[str]) -> str:
if not raw or not raw.strip():
return f"user_{uuid.uuid4().hex[:8]}"
cleaned = re.sub(r"[^a-zA-Z0-9_-]", "_", raw.strip())
cleaned = re.sub(r"_+", "_", cleaned).strip("_")
if cleaned and cleaned[0].isdigit():
cleaned = f"u_{cleaned}"
# 预留后缀空间,避免后续重试超长
max_len = 90
if len(cleaned) > max_len:
cleaned = cleaned[:max_len]
return cleaned or f"user_{uuid.uuid4().hex[:8]}"
@staticmethod
def _generate_unique_username(db: Session, base: str, max_retries: int = 3) -> str:
base = OAuthService._sanitize_username(base)
candidates = [base]
for i in range(max_retries - 1):
suffix_len = 4 if i == 0 else 8
candidates.append(f"{base}_{uuid.uuid4().hex[:suffix_len]}")
for cand in candidates:
exists = db.query(User).filter(User.username == cand).first()
if not exists:
return cand
raise ValueError("无法生成唯一用户名")
@staticmethod
def _validate_email_suffix(db: Session, email: str) -> bool:
mode = SystemConfigService.get_config(db, "email_suffix_mode", default="none")
if mode == "none":
return True
suffix_list = SystemConfigService.get_config(db, "email_suffix_list", default=[])
if isinstance(suffix_list, str):
suffix_list = [s.strip().lower() for s in suffix_list.split(",") if s.strip()]
if not suffix_list:
return True
if "@" not in email:
return False
email_suffix = email.split("@", 1)[1].lower()
if mode == "whitelist":
return email_suffix in suffix_list
if mode == "blacklist":
return email_suffix not in suffix_list
return True
@staticmethod
def _get_constraint_name(err: IntegrityError) -> Optional[str]:
orig = getattr(err, "orig", None)
diag = getattr(orig, "diag", None)
name = getattr(diag, "constraint_name", None)
return str(name) if name else None
@staticmethod
async def handle_callback(
*,
db: Session,
provider_type: str,
state: str,
code: Optional[str],
error: Optional[str],
error_description: Optional[str],
) -> str:
OAuthService._require_module_active(db)
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
raise HTTPException(status_code=404, detail="不支持的 OAuth provider")
config = OAuthService._get_provider_config(db, provider_type)
frontend_callback_url = config.frontend_callback_url
if not frontend_callback_url:
# 无法重定向到前端时,直接返回 500配置错误
raise HTTPException(status_code=500, detail="frontend_callback_url 未配置")
frontend_callback_url = str(frontend_callback_url)
# display_name 用于 bind 成功 toast兜底为 provider_type
display_name = str(config.display_name or provider_type)
# provider 被禁用时仍引导回前端(给出明确提示)
if not config.is_enabled:
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="provider_disabled"
)
# provider 侧 error
if error:
code_map = "authorization_denied" if error == "access_denied" else "provider_error"
return OAuthService._build_frontend_error_redirect(
frontend_callback_url,
error_code=code_map,
error_detail=error_description or error,
)
if not code or not state:
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_callback"
)
# 一次性消费 state
try:
redis = await get_redis_client(require_redis=True)
if redis is None:
raise RuntimeError("redis unavailable")
state_data = await consume_oauth_state(redis, state)
except Exception as exc:
logger.warning("OAuth state 消费失败: {}", exc)
state_data = None
if not state_data:
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_state"
)
if state_data.provider_type != provider_type:
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_state"
)
if state_data.action not in ("login", "bind"):
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_state"
)
if state_data.action == "bind" and not state_data.user_id:
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="invalid_bind_state"
)
try:
token = await provider.exchange_code(config, code)
oauth_user = await provider.get_user_info(config, token.access_token)
except OAuthFlowError as exc:
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code=exc.error_code, error_detail=exc.detail
)
except Exception as exc:
logger.warning("OAuth callback 处理失败: {}", exc)
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code="provider_error"
)
if state_data.action == "bind":
try:
await OAuthService._handle_bind(db, user_id=state_data.user_id or "", config=config, oauth_user=oauth_user)
except OAuthFlowError as exc:
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code=exc.error_code, error_detail=exc.detail
)
return OAuthService._build_frontend_bind_success_redirect(
frontend_callback_url, display_name
)
# login
try:
user = await OAuthService._handle_login(db, config=config, oauth_user=oauth_user)
except OAuthFlowError as exc:
return OAuthService._build_frontend_error_redirect(
frontend_callback_url, error_code=exc.error_code, error_detail=exc.detail
)
assert user.id is not None
assert user.role is not None
access_token = AuthService.create_access_token(
data={
"user_id": user.id,
"role": user.role.value,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
)
refresh_token = AuthService.create_refresh_token(
data={"user_id": user.id, "created_at": user.created_at.isoformat() if user.created_at else None}
)
return OAuthService._build_frontend_login_success_redirect(
frontend_callback_url, access_token=access_token, refresh_token=refresh_token
)
@staticmethod
async def _handle_login(db: Session, *, config: OAuthProvider, oauth_user: OAuthUserInfo) -> User:
now = datetime.now(timezone.utc)
# 1) 已绑定账号:直接登录
existing_link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == config.provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing_link:
linked_user = db.query(User).filter(User.id == existing_link.user_id).first()
if not linked_user or not linked_user.is_active or linked_user.is_deleted:
raise OAuthFlowError("account_disabled", "用户不存在或已禁用")
linked_user.last_login_at = now
existing_link.last_login_at = now
db.commit()
assert linked_user.id is not None
await UserCacheService.invalidate_user_cache(linked_user.id, linked_user.email)
return linked_user
# 2) 未绑定账号:可能需要新建用户(受注册开关控制)
enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False)
if not enable_registration:
raise OAuthFlowError("registration_disabled")
email = oauth_user.email
if email:
if not OAuthService._validate_email_suffix(db, email):
raise OAuthFlowError("email_suffix_denied")
existing_user = db.query(User).filter(User.email == email).first()
# 已删除用户不阻塞新建(邮箱可复用)
if existing_user and not existing_user.is_deleted:
if existing_user.auth_source == AuthSource.LOCAL:
raise OAuthFlowError("email_exists_local")
if existing_user.auth_source == AuthSource.LDAP:
raise OAuthFlowError("email_is_ldap")
raise OAuthFlowError("email_is_oauth")
base_username = oauth_user.username or (email.split("@", 1)[0] if email else None) or f"user_{uuid.uuid4().hex[:8]}"
default_quota = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
# 生成唯一用户名 + 创建用户(简单重试)
user: Optional[User] = None
last_error: Optional[Exception] = None
for _ in range(3):
try:
username = OAuthService._generate_unique_username(db, base_username)
user = User(
email=email,
email_verified=bool(oauth_user.email_verified) if email else False,
username=username,
password_hash=None,
auth_source=AuthSource.OAUTH,
role=UserRole.USER,
is_active=True,
last_login_at=now,
quota_usd=default_quota,
)
db.add(user)
db.commit()
db.refresh(user)
last_error = None
break
except IntegrityError as e:
db.rollback()
last_error = e
except Exception as e:
db.rollback()
last_error = e
if last_error is not None or user is None:
raise OAuthFlowError("provider_error", "user_create_failed")
# 创建绑定关系
assert user.id is not None
try:
link = UserOAuthLink(
user_id=user.id,
provider_type=config.provider_type,
provider_user_id=oauth_user.id,
provider_username=oauth_user.username,
provider_email=email,
extra_data=oauth_user.raw,
linked_at=now,
last_login_at=now,
)
db.add(link)
db.commit()
except IntegrityError as e:
db.rollback()
constraint = OAuthService._get_constraint_name(e)
if constraint == "uq_oauth_provider_user":
# 并发:该第三方账号已先被绑定,尝试读取并登录
existing_link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == config.provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing_link:
existing_user = db.query(User).filter(User.id == existing_link.user_id).first()
if existing_user and existing_user.is_active and not existing_user.is_deleted:
existing_user.last_login_at = now
existing_link.last_login_at = now
db.commit()
assert existing_user.id is not None
await UserCacheService.invalidate_user_cache(existing_user.id, existing_user.email)
return existing_user
raise OAuthFlowError("oauth_already_bound")
raise OAuthFlowError("provider_error", "link_create_failed")
assert user.id is not None
await UserCacheService.invalidate_user_cache(user.id, user.email)
return user
@staticmethod
async def _handle_bind(
db: Session, *, user_id: str, config: OAuthProvider, oauth_user: OAuthUserInfo
) -> UserOAuthLink:
now = datetime.now(timezone.utc)
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active or user.is_deleted:
raise OAuthFlowError("user_not_found")
if user.auth_source == AuthSource.LDAP:
raise OAuthFlowError("ldap_no_oauth")
link = UserOAuthLink(
user_id=user.id,
provider_type=config.provider_type,
provider_user_id=oauth_user.id,
provider_username=oauth_user.username,
provider_email=oauth_user.email,
extra_data=oauth_user.raw,
linked_at=now,
)
try:
db.add(link)
db.commit()
db.refresh(link)
return link
except IntegrityError as e:
db.rollback()
constraint = OAuthService._get_constraint_name(e)
if constraint == "uq_oauth_provider_user":
existing = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == config.provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing and existing.user_id == user.id:
return existing
raise OAuthFlowError("oauth_already_bound")
if constraint == "uq_user_oauth_provider":
raise OAuthFlowError("already_bound_provider")
raise OAuthFlowError("provider_error", "bind_failed")
@staticmethod
async def list_bindable_providers(db: Session, user: User) -> list[dict[str, str]]:
OAuthService._require_module_active(db)
if user.auth_source == AuthSource.LDAP:
return []
supported = get_oauth_provider_registry()
supported.discover_providers()
enabled_rows = (
db.query(OAuthProvider)
.filter(OAuthProvider.is_enabled.is_(True))
.order_by(OAuthProvider.provider_type.asc())
.all()
)
linked_types = {
provider_type
for (provider_type,) in db.query(UserOAuthLink.provider_type)
.filter(UserOAuthLink.user_id == user.id)
.all()
}
result: list[dict[str, str]] = []
for row in enabled_rows:
provider_type_value = row.provider_type
if not provider_type_value:
continue
provider_type_str = str(provider_type_value)
if provider_type_str in linked_types:
continue
if supported.get_provider(provider_type_str) is None:
continue
display_name = row.display_name or provider_type_str
result.append({"provider_type": provider_type_str, "display_name": str(display_name)})
return result
@staticmethod
async def list_user_links(db: Session, user: User) -> list[dict[str, Any]]:
OAuthService._require_module_active(db)
rows = (
db.query(UserOAuthLink, OAuthProvider)
.join(OAuthProvider, UserOAuthLink.provider_type == OAuthProvider.provider_type)
.filter(UserOAuthLink.user_id == user.id)
.order_by(UserOAuthLink.linked_at.desc())
.all()
)
result: list[dict[str, Any]] = []
for link, provider in rows:
result.append(
{
"provider_type": link.provider_type,
"display_name": provider.display_name,
"provider_username": link.provider_username,
"provider_email": link.provider_email,
"linked_at": link.linked_at.isoformat() if link.linked_at else None,
"last_login_at": link.last_login_at.isoformat() if link.last_login_at else None,
"provider_enabled": bool(provider.is_enabled),
}
)
return result
@staticmethod
def _check_provider_disable_safety(db: Session, provider_type: str) -> list[str]:
"""
v1 简化版防锁号检查:
- 只检查活跃用户is_active && !is_deleted
- OAUTH 用户:禁用后必须仍有其它启用的 OAuth provider 绑定
- LOCAL 用户ldap_exclusive=true 且非 admin 时,同上
"""
ldap_exclusive = LDAPService.is_ldap_exclusive(db)
users = (
db.query(User.id, User.auth_source, User.role)
.join(UserOAuthLink, User.id == UserOAuthLink.user_id)
.filter(
User.is_active.is_(True),
User.is_deleted.is_(False),
UserOAuthLink.provider_type == provider_type,
)
.all()
)
affected: list[str] = []
for user_id, auth_source, role in users:
other_enabled_count = (
db.query(func.count(UserOAuthLink.id))
.join(OAuthProvider, UserOAuthLink.provider_type == OAuthProvider.provider_type)
.filter(
UserOAuthLink.user_id == user_id,
UserOAuthLink.provider_type != provider_type,
OAuthProvider.is_enabled.is_(True),
)
.scalar()
or 0
)
locked = False
if auth_source == AuthSource.OAUTH:
if other_enabled_count == 0:
locked = True
elif auth_source == AuthSource.LOCAL and ldap_exclusive:
is_admin = role == UserRole.ADMIN
if not is_admin and other_enabled_count == 0:
locked = True
if locked:
affected.append(str(user_id))
return affected
@staticmethod
async def upsert_provider_config(db: Session, provider_type: str, data: Any) -> OAuthProvider:
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
raise InvalidRequestException("不支持的 provider_type")
OAuthService._validate_provider_config(provider, data)
row = db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first()
creating = row is None
if not row:
row = OAuthProvider(provider_type=provider_type)
db.add(row)
# 禁用前防锁号检查(仅在从 enabled -> disabled 时触发)
if row.is_enabled and data.is_enabled is False:
affected = OAuthService._check_provider_disable_safety(db, provider_type)
if affected and not getattr(data, "force", False):
raise ConfirmationRequiredException(
message=f"禁用该 Provider 会导致 {len(affected)} 个用户无法登录",
affected_count=len(affected),
action="disable_oauth_provider",
)
row.display_name = data.display_name
row.client_id = data.client_id
row.authorization_url_override = data.authorization_url_override
row.token_url_override = data.token_url_override
row.userinfo_url_override = data.userinfo_url_override
row.scopes = data.scopes
row.redirect_uri = data.redirect_uri
row.frontend_callback_url = data.frontend_callback_url
row.attribute_mapping = data.attribute_mapping
row.extra_config = data.extra_config
row.is_enabled = data.is_enabled
# client_secret 处理逻辑:
# - None 或空字符串:保持不变
# - "__CLEAR__":清空 secret
# - 其他值:设置新 secret
if data.client_secret is not None:
secret_value = data.client_secret.strip()
if secret_value == "__CLEAR__":
row.client_secret_encrypted = None
elif secret_value:
row.set_client_secret(secret_value)
try:
db.commit()
db.refresh(row)
except Exception:
db.rollback()
raise
if creating:
logger.info("OAuth provider 配置已创建: {}", provider_type)
else:
logger.info("OAuth provider 配置已更新: {}", provider_type)
return row
@staticmethod
async def delete_provider_config(db: Session, provider_type: str) -> None:
row = db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first()
if not row:
raise InvalidRequestException("Provider 配置不存在")
if row.is_enabled:
affected = OAuthService._check_provider_disable_safety(db, provider_type)
if affected:
raise InvalidRequestException(
f"删除该 Provider 会导致部分用户无法登录(数量: {len(affected)}),已阻止操作"
)
db.delete(row)
db.commit()
@staticmethod
def _validate_provider_config(provider: OAuthProviderBase, data: Any) -> None:
# frontend_callback_url 校验:必须绝对 URLpath 以 /auth/callback 结尾(允许 basePath
OAuthService._validate_frontend_callback_url(data.frontend_callback_url)
# redirect_uri允许本地 http其余建议 httpsv1仅做基本校验
OAuthService._validate_redirect_uri(data.redirect_uri)
# 覆盖端点:必须 https 且 hostname 命中 provider 白名单
for field_name in ("authorization_url_override", "token_url_override", "userinfo_url_override"):
value = getattr(data, field_name)
if value:
OAuthService._validate_url_override(provider, value)
@staticmethod
def _validate_frontend_callback_url(url: str) -> None:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
raise InvalidRequestException("frontend_callback_url 必须是绝对 URL")
if parsed.scheme not in {"http", "https"}:
raise InvalidRequestException("frontend_callback_url scheme 必须是 http/https")
path = (parsed.path or "").rstrip("/")
if not path.endswith("/auth/callback"):
raise InvalidRequestException("frontend_callback_url 路径必须以 /auth/callback 结尾")
@staticmethod
def _validate_redirect_uri(url: str) -> None:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
raise InvalidRequestException("redirect_uri 必须是绝对 URL")
if parsed.scheme not in {"http", "https"}:
raise InvalidRequestException("redirect_uri scheme 必须是 http/https")
@staticmethod
def _validate_url_override(provider: OAuthProviderBase, url: str) -> None:
parsed = urlparse(url)
if parsed.scheme != "https" or not parsed.netloc:
raise InvalidRequestException("端点覆盖必须是 https 绝对 URL")
host = (parsed.hostname or "").lower().rstrip(".")
allowed = False
for domain in provider.allowed_domains:
d = domain.lower().rstrip(".")
if host == d or host.endswith(f".{d}"):
allowed = True
break
if not allowed:
raise InvalidRequestException("端点覆盖不在允许的域名白名单中")
@staticmethod
async def test_provider_config(db: Session, provider_type: str) -> dict[str, Any]:
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
return {
"authorization_url_reachable": False,
"token_url_reachable": False,
"secret_status": "unknown",
"details": "provider 未安装/不可用",
}
cfg = OAuthService._get_provider_config(db, provider_type)
auth_url = provider.get_effective_authorization_url(cfg)
token_url = provider.get_effective_token_url(cfg)
async def _reachable(url: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False) as client:
await client.get(url)
return True
except Exception:
return False
authorization_url_reachable = await _reachable(auth_url)
token_url_reachable = await _reachable(token_url)
secret_status = "unknown"
details = ""
if cfg.client_secret_encrypted:
# 使用无效 code 做一次 token 请求(仅做粗略判定)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
resp = await client.post(
token_url,
data={
"grant_type": "authorization_code",
"code": "invalid",
"redirect_uri": cfg.redirect_uri,
"client_id": cfg.client_id,
"client_secret": cfg.get_client_secret(),
},
)
try:
body = resp.json()
except Exception:
body = {}
err = str(body.get("error") or "").lower()
if err in {"invalid_client", "unauthorized_client"}:
secret_status = "invalid"
elif err in {"invalid_grant", "invalid_code"}:
secret_status = "likely_valid"
else:
secret_status = "unknown"
details = f"status={resp.status_code}"
except Exception as exc:
secret_status = "unknown"
details = str(exc)
return {
"authorization_url_reachable": bool(authorization_url_reachable),
"token_url_reachable": bool(token_url_reachable),
"secret_status": secret_status,
"details": details,
}
@staticmethod
async def test_provider_config_with_data(
provider_type: str,
client_id: str,
client_secret: Optional[str],
authorization_url_override: Optional[str],
token_url_override: Optional[str],
redirect_uri: str,
) -> dict[str, Any]:
"""使用传入的表单数据测试配置,而非从数据库读取"""
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
return {
"authorization_url_reachable": False,
"token_url_reachable": False,
"secret_status": "unknown",
"details": "provider 未安装/不可用",
}
# 使用传入的 override URL 或 provider 默认值
auth_url = authorization_url_override or provider.authorization_url
token_url = token_url_override or provider.token_url
async def _reachable(url: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False) as client:
await client.get(url)
return True
except Exception:
return False
authorization_url_reachable = await _reachable(auth_url)
token_url_reachable = await _reachable(token_url)
secret_status = "unknown"
details = ""
if client_secret:
# 使用无效 code 做一次 token 请求(仅做粗略判定)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
resp = await client.post(
token_url,
data={
"grant_type": "authorization_code",
"code": "invalid",
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
},
)
try:
body = resp.json()
except Exception:
body = {}
err = str(body.get("error") or "").lower()
if err in {"invalid_client", "unauthorized_client"}:
secret_status = "invalid"
elif err in {"invalid_grant", "invalid_code"}:
secret_status = "likely_valid"
else:
secret_status = "unknown"
details = f"status={resp.status_code}"
except Exception as exc:
secret_status = "unknown"
details = str(exc)
else:
secret_status = "not_provided"
return {
"authorization_url_reachable": bool(authorization_url_reachable),
"token_url_reachable": bool(token_url_reachable),
"secret_status": secret_status,
"details": details,
}
@staticmethod
async def unbind_provider(db: Session, user: User, provider_type: str) -> None:
OAuthService._require_module_active(db)
if user.auth_source == AuthSource.LDAP:
raise HTTPException(status_code=403, detail="LDAP 用户不允许解绑 OAuth")
link = (
db.query(UserOAuthLink)
.filter(UserOAuthLink.user_id == user.id, UserOAuthLink.provider_type == provider_type)
.first()
)
if not link:
raise InvalidRequestException("未绑定该 Provider")
total_links = db.query(func.count(UserOAuthLink.id)).filter(UserOAuthLink.user_id == user.id).scalar() or 0
if user.auth_source == AuthSource.OAUTH and total_links <= 1:
raise InvalidRequestException("OAUTH 用户必须至少保留一个 OAuth 绑定")
# 本地用户无密码时,解绑最后一个 OAuth 会导致无法登录
if user.auth_source == AuthSource.LOCAL and not user.password_hash and total_links <= 1:
raise InvalidRequestException("请先设置密码后再解绑")
if LDAPService.is_ldap_exclusive(db) and user.auth_source == AuthSource.LOCAL and user.role != UserRole.ADMIN:
# ldap_exclusive=true 时,普通本地用户解绑最后一个 OAuth 会锁死(密码登录被禁用)
if total_links <= 1:
raise InvalidRequestException("当前处于 LDAP 专属模式,解绑后将无法登录")
db.delete(link)
db.commit()

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import json
import secrets
import time
from dataclasses import dataclass
from typing import Any, Awaitable, Optional, cast
from redis.asyncio import Redis
OAUTH_STATE_TTL_SECONDS = 600
OAUTH_STATE_KEY_PREFIX = "oauth_state:"
CONSUME_STATE_SCRIPT = r"""
local value = redis.call("GET", KEYS[1])
if value then
redis.call("DEL", KEYS[1])
end
return value
"""
@dataclass(frozen=True)
class OAuthStateData:
nonce: str
provider_type: str
action: str # "login" | "bind"
user_id: Optional[str]
created_at: int
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "OAuthStateData":
return cls(
nonce=str(data.get("nonce") or ""),
provider_type=str(data.get("provider_type") or ""),
action=str(data.get("action") or ""),
user_id=data.get("user_id"),
created_at=int(data.get("created_at") or 0),
)
def _state_key(nonce: str) -> str:
return f"{OAUTH_STATE_KEY_PREFIX}{nonce}"
async def create_oauth_state(
redis: Redis, *, provider_type: str, action: str, user_id: Optional[str] = None
) -> str:
nonce = secrets.token_urlsafe(24)
data = {
"nonce": nonce,
"provider_type": provider_type,
"action": action,
"user_id": user_id,
"created_at": int(time.time()),
}
await redis.setex(_state_key(nonce), OAUTH_STATE_TTL_SECONDS, json.dumps(data))
return nonce
async def consume_oauth_state(redis: Redis, nonce: str) -> Optional[OAuthStateData]:
if not nonce:
return None
key = _state_key(nonce)
# redis-py 的类型标注在 sync/async 之间会出现 Union这里明确按 async 处理。
raw = await cast(Awaitable[Optional[str]], redis.eval(CONSUME_STATE_SCRIPT, 1, key))
if not raw:
return None
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return None
return OAuthStateData.from_dict(parsed)

View File

@@ -91,6 +91,43 @@ REFRESH_TOKEN_EXPIRATION_DAYS = 7
class AuthService: class AuthService:
"""认证服务""" """认证服务"""
@staticmethod
def token_identity_matches_user(payload: Dict[str, Any], user: User) -> bool:
"""
校验 token 的身份字段是否与用户一致。
兼容策略:
- email旧 token 可能包含;新 token 允许不包含(支持无邮箱用户)
- created_at用于替代 email 作为"防止身份混淆"的校验字段;旧 token 可能没有
时区处理说明:
- 本项目所有 created_at 统一使用 UTC 时区存储PostgreSQL TIMESTAMPTZ
- 对于 naive datetime无时区信息假定为 UTC
- 若历史数据使用了非 UTC 本地时区的 naive datetime可能导致校验失败
"""
token_email = payload.get("email")
if token_email is not None and user.email is not None and user.email != token_email:
return False
token_created_at = payload.get("created_at")
if not token_created_at or not user.created_at:
return True
try:
token_created = datetime.fromisoformat(str(token_created_at).replace("Z", "+00:00"))
except ValueError:
return False
# 统一时区:若是 naive datetime按 UTC 处理
# 注意:本项目约定所有时间戳使用 UTC若旧数据不符合此约定可能导致校验失败
user_created = user.created_at
if user_created.tzinfo is None:
user_created = user_created.replace(tzinfo=timezone.utc)
if token_created.tzinfo is None:
token_created = token_created.replace(tzinfo=timezone.utc)
return abs((user_created - token_created).total_seconds()) <= 1
@staticmethod @staticmethod
def create_access_token(data: dict) -> str: def create_access_token(data: dict) -> str:
"""创建JWT访问令牌""" """创建JWT访问令牌"""
@@ -194,6 +231,9 @@ class AuthService:
if not user: if not user:
# 已有本地账号但来源不匹配等情况 # 已有本地账号但来源不匹配等情况
return None return None
if user.is_deleted:
logger.warning(f"登录失败 - 用户已删除: {email}")
return None
if not user.is_active: if not user.is_active:
logger.warning(f"登录失败 - 用户已禁用: {email}") logger.warning(f"登录失败 - 用户已禁用: {email}")
return None return None
@@ -211,6 +251,10 @@ class AuthService:
logger.warning(f"登录失败 - 用户不存在: {email}") logger.warning(f"登录失败 - 用户不存在: {email}")
return None return None
if user.is_deleted:
logger.warning(f"登录失败 - 用户已删除: {email}")
return None
# 检查 LDAP exclusive 模式:仅允许本地管理员登录(紧急恢复通道) # 检查 LDAP exclusive 模式:仅允许本地管理员登录(紧急恢复通道)
if LDAPService.is_ldap_exclusive(db): if LDAPService.is_ldap_exclusive(db):
if user.role != UserRole.ADMIN or user.auth_source != AuthSource.LOCAL: if user.role != UserRole.ADMIN or user.auth_source != AuthSource.LOCAL:
@@ -275,6 +319,10 @@ class AuthService:
user = db.query(User).filter(User.email == email).with_for_update().first() user = db.query(User).filter(User.email == email).with_for_update().first()
if user: if user:
if user.is_deleted:
logger.warning(f"LDAP 登录失败 - 用户已删除: {email}")
return None
if user.auth_source != AuthSource.LDAP: if user.auth_source != AuthSource.LDAP:
# 避免覆盖已有本地账户(不同来源时拒绝登录) # 避免覆盖已有本地账户(不同来源时拒绝登录)
logger.warning( logger.warning(
@@ -293,6 +341,7 @@ class AuthService:
logger.warning(f"LDAP 登录拒绝 - 新邮箱已被占用: {email}") logger.warning(f"LDAP 登录拒绝 - 新邮箱已被占用: {email}")
return None return None
user.email = email user.email = email
user.email_verified = True
# 同步 LDAP 标识(首次填充或 LDAP 侧发生变化) # 同步 LDAP 标识(首次填充或 LDAP 侧发生变化)
if ldap_dn and user.ldap_dn != ldap_dn: if ldap_dn and user.ldap_dn != ldap_dn:
@@ -325,8 +374,9 @@ class AuthService:
# 创建新用户 # 创建新用户
user = User( user = User(
email=email, email=email,
email_verified=True,
username=username, username=username,
password_hash="", # LDAP 用户无本地密码 password_hash=None, # LDAP 用户无本地密码
auth_source=AuthSource.LDAP, auth_source=AuthSource.LDAP,
ldap_dn=ldap_dn, ldap_dn=ldap_dn,
ldap_username=ldap_username, ldap_username=ldap_username,
@@ -416,6 +466,9 @@ class AuthService:
if not user.is_active: if not user.is_active:
logger.warning(f"API认证失败 - 用户已禁用: {user.email}") logger.warning(f"API认证失败 - 用户已禁用: {user.email}")
return None return None
if user.is_deleted:
logger.warning(f"API认证失败 - 用户已删除: {user.email}")
return None
# 更新最后使用时间(使用节流策略,减少数据库写入) # 更新最后使用时间(使用节流策略,减少数据库写入)
if _should_update_last_used(key_record.id): if _should_update_last_used(key_record.id):
@@ -637,6 +690,9 @@ class AuthService:
if not user or not user.is_active: if not user or not user.is_active:
logger.warning("Management Token 认证失败 - 用户不存在或已禁用") logger.warning("Management Token 认证失败 - 用户不存在或已禁用")
return None return None
if user.is_deleted:
logger.warning("Management Token 认证失败 - 用户不存在或已禁用")
return None
# 使用 SQL 原子操作更新使用统计 # 使用 SQL 原子操作更新使用统计
from sqlalchemy import func from sqlalchemy import func

View File

@@ -126,9 +126,11 @@ class UserCacheService:
return { return {
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,
"email_verified": user.email_verified,
"username": user.username, "username": user.username,
"role": user.role.value if user.role else None, "role": user.role.value if user.role else None,
"is_active": user.is_active, "is_active": user.is_active,
"auth_source": user.auth_source.value if user.auth_source else None,
"quota_usd": float(user.quota_usd) if user.quota_usd is not None else None, "quota_usd": float(user.quota_usd) if user.quota_usd is not None else None,
"used_usd": float(user.used_usd), "used_usd": float(user.used_usd),
"created_at": user.created_at.isoformat() if user.created_at else None, "created_at": user.created_at.isoformat() if user.created_at else None,
@@ -146,11 +148,13 @@ class UserCacheService:
""" """
from datetime import datetime from datetime import datetime
from src.core.enums import AuthSource
from src.models.database import UserRole from src.models.database import UserRole
user = User( user = User(
id=user_dict["id"], id=user_dict["id"],
email=user_dict["email"], email=user_dict.get("email"),
email_verified=user_dict.get("email_verified", False),
username=user_dict["username"], username=user_dict["username"],
is_active=user_dict["is_active"], is_active=user_dict["is_active"],
used_usd=user_dict["used_usd"], used_usd=user_dict["used_usd"],
@@ -160,6 +164,9 @@ class UserCacheService:
if user_dict.get("role"): if user_dict.get("role"):
user.role = UserRole(user_dict["role"]) user.role = UserRole(user_dict["role"])
if user_dict.get("auth_source"):
user.auth_source = AuthSource(user_dict["auth_source"])
if user_dict.get("quota_usd") is not None: if user_dict.get("quota_usd") is not None:
user.quota_usd = user_dict["quota_usd"] user.quota_usd = user_dict["quota_usd"]

View File

@@ -98,6 +98,21 @@ class EmailSenderService:
return True, None return True, None
@staticmethod
def is_smtp_configured(db: Session) -> bool:
"""
检查 SMTP 是否已配置(用于前端显示判断)
Args:
db: 数据库会话
Returns:
是否已配置有效的 SMTP
"""
config = EmailSenderService._get_smtp_config(db)
valid, _ = EmailSenderService._validate_smtp_config(config)
return valid
@staticmethod @staticmethod
async def send_verification_code( async def send_verification_code(
db: Session, to_email: str, code: str, expire_minutes: int = 30 db: Session, to_email: str, code: str, expire_minutes: int = 30

View File

@@ -15,15 +15,17 @@ import os
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
import httpx
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from src.core.cache_service import CacheService from src.core.cache_service import CacheService
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.headers import get_extra_headers_from_endpoint
from src.core.logger import logger from src.core.logger import logger
from src.database import create_session from src.database import create_session
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import Provider, ProviderAPIKey
from src.services.model.upstream_fetcher import (
build_all_format_configs,
fetch_models_from_endpoints,
)
from src.services.system.scheduler import get_scheduler from src.services.system.scheduler import get_scheduler
# 从环境变量读取间隔,默认 1440 分钟1 天),限制在 60-10080 分钟之间 # 从环境变量读取间隔,默认 1440 分钟1 天),限制在 60-10080 分钟之间
@@ -66,21 +68,6 @@ async def set_upstream_models_to_cache(
logger.debug(f"上游模型已缓存: {cache_key}, 数量={len(models)}") logger.debug(f"上游模型已缓存: {cache_key}, 数量={len(models)}")
def _get_adapter_for_format(api_format: str) -> Optional[type]:
"""根据 API 格式获取对应的 Adapter 类"""
# 延迟导入避免循环依赖
from src.api.handlers.base.chat_adapter_base import get_adapter_class
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
adapter_class = get_adapter_class(api_format)
if adapter_class:
return adapter_class
cli_adapter_class = get_cli_adapter_class(api_format)
if cli_adapter_class:
return cli_adapter_class
return None
class ModelFetchScheduler: class ModelFetchScheduler:
"""模型自动获取调度器""" """模型自动获取调度器"""
@@ -277,7 +264,7 @@ class ModelFetchScheduler:
return "error" return "error"
# 构建 api_format -> endpoint 映射 # 构建 api_format -> endpoint 映射
format_to_endpoint: dict[str, ProviderEndpoint] = {} format_to_endpoint: dict[str, object] = {}
for endpoint in provider.endpoints: # type: ignore[attr-defined] for endpoint in provider.endpoints: # type: ignore[attr-defined]
if endpoint.is_active: if endpoint.is_active:
format_to_endpoint[endpoint.api_format] = endpoint format_to_endpoint[endpoint.api_format] = endpoint
@@ -288,29 +275,11 @@ class ModelFetchScheduler:
key.last_models_fetch_at = now key.last_models_fetch_at = now
return "error" return "error"
# 收集端点配置 # 使用公共函数构建所有格式的端点配置
endpoint_configs: list[dict] = [] endpoint_configs = build_all_format_configs(api_key_value, format_to_endpoint) # type: ignore[arg-type]
key_formats = key.api_formats or []
for fmt in key_formats:
endpoint = format_to_endpoint.get(fmt)
if endpoint:
endpoint_configs.append(
{
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": fmt,
"extra_headers": get_extra_headers_from_endpoint(endpoint),
}
)
if not endpoint_configs:
logger.warning(f"Provider {provider.name} 没有匹配 Key {key.id} 格式的端点配置")
key.last_models_fetch_error = "No matching endpoints for key formats"
key.last_models_fetch_at = now
return "error"
# 并发获取模型 # 并发获取模型
all_models, errors, has_success = await self._fetch_models_from_endpoints(endpoint_configs) all_models, errors, has_success = await fetch_models_from_endpoints(endpoint_configs)
# 记录获取结果 # 记录获取结果
error_msg = "; ".join(errors) if errors else None error_msg = "; ".join(errors) if errors else None
@@ -401,62 +370,6 @@ class ModelFetchScheduler:
logger.debug(f"Key {key.id} 模型列表无变化") logger.debug(f"Key {key.id} 模型列表无变化")
return False return False
async def _fetch_models_from_endpoints(
self, endpoint_configs: list[dict]
) -> tuple[list[dict], list[str], bool]:
"""从多个端点并发获取模型,返回 (模型列表, 错误列表, 是否有成功)"""
all_models: list[dict] = []
errors: list[str] = []
has_success = False
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
async def fetch_one(
client: httpx.AsyncClient, config: dict
) -> tuple[list, Optional[str], bool]:
base_url = config["base_url"]
if not base_url:
return [], None, False
base_url = base_url.rstrip("/")
api_format = config["api_format"]
api_key_value = config["api_key"]
extra_headers = config.get("extra_headers")
try:
adapter_class = _get_adapter_for_format(api_format)
if not adapter_class:
return [], f"Unknown API format: {api_format}", False
async with semaphore:
models, error = await adapter_class.fetch_models( # type: ignore[attr-defined]
client, base_url, api_key_value, extra_headers
)
for m in models:
if "api_format" not in m:
m["api_format"] = api_format
# 即使返回空列表,只要没有错误也算成功
success = error is None
return models, error, success
except httpx.TimeoutException:
logger.warning(f"获取 {api_format} 模型超时")
return [], f"{api_format}: timeout", False
except Exception as e:
# 只记录异常类型,避免泄露敏感信息
logger.exception(f"获取 {api_format} 模型出错")
return [], f"{api_format}: {type(e).__name__}", False
async with httpx.AsyncClient(timeout=30.0) as client:
results = await asyncio.gather(*[fetch_one(client, c) for c in endpoint_configs])
for models, error, success in results:
all_models.extend(models)
if error:
errors.append(error)
if success:
has_success = True
return all_models, errors, has_success
# 单例模式 # 单例模式
_model_fetch_scheduler: Optional[ModelFetchScheduler] = None _model_fetch_scheduler: Optional[ModelFetchScheduler] = None

View File

@@ -0,0 +1,160 @@
"""
上游模型获取公共模块
提供从上游 API 获取模型列表的公共函数,供以下场景使用:
- 定时任务自动获取fetch_scheduler.py
- 管理后台手动查询provider_query.py
"""
import asyncio
from typing import Dict, Optional
import httpx
from src.core.enums import APIFormat
from src.core.headers import get_extra_headers_from_endpoint
from src.core.logger import logger
from src.models.database import ProviderEndpoint
# 并发请求限制
MAX_CONCURRENT_REQUESTS = 5
def _get_adapter_for_format(api_format: str) -> Optional[type]:
"""根据 API 格式获取对应的 Adapter 类"""
from src.api.handlers.base.chat_adapter_base import get_adapter_class
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
adapter_class = get_adapter_class(api_format)
if adapter_class:
return adapter_class
cli_adapter_class = get_cli_adapter_class(api_format)
if cli_adapter_class:
return cli_adapter_class
return None
def build_all_format_configs(
api_key_value: str,
format_to_endpoint: Dict[str, ProviderEndpoint],
) -> list[dict]:
"""
构建所有 API 格式的端点配置
从所有 APIFormat 枚举值构建配置,如果该格式有专门的端点配置则使用,
否则使用基础端点的 base_url 尝试。
Args:
api_key_value: 解密后的 API Key
format_to_endpoint: API 格式到端点的映射
Returns:
端点配置列表,每个配置包含 api_key, base_url, api_format, extra_headers
"""
if not format_to_endpoint:
return []
# 获取任意一个端点的 base_url 作为基础(用于尝试所有格式)
# 优先使用 OPENAI 格式的端点,因为它最通用
base_endpoint = (
format_to_endpoint.get("OPENAI")
or format_to_endpoint.get("CLAUDE")
or format_to_endpoint.get("GEMINI")
or next(iter(format_to_endpoint.values()))
)
base_url = base_endpoint.base_url
extra_headers = get_extra_headers_from_endpoint(base_endpoint)
# 从所有 API 格式都尝试获取模型,然后聚合去重
endpoint_configs: list[dict] = []
for fmt in APIFormat:
fmt_value = fmt.value
# 如果该格式有专门的端点配置,使用其 base_url 和 headers
if fmt_value in format_to_endpoint:
ep = format_to_endpoint[fmt_value]
endpoint_configs.append(
{
"api_key": api_key_value,
"base_url": ep.base_url,
"api_format": fmt_value,
"extra_headers": get_extra_headers_from_endpoint(ep),
}
)
else:
# 没有专门配置,使用基础端点的 base_url 尝试
endpoint_configs.append(
{
"api_key": api_key_value,
"base_url": base_url,
"api_format": fmt_value,
"extra_headers": extra_headers,
}
)
return endpoint_configs
async def fetch_models_from_endpoints(
endpoint_configs: list[dict],
timeout: float = 30.0,
) -> tuple[list[dict], list[str], bool]:
"""
从多个端点并发获取模型
Args:
endpoint_configs: 端点配置列表,每个配置包含 api_key, base_url, api_format, extra_headers
timeout: 请求超时时间(秒)
Returns:
(模型列表, 错误列表, 是否有成功)
"""
all_models: list[dict] = []
errors: list[str] = []
has_success = False
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
async def fetch_one(
client: httpx.AsyncClient, config: dict
) -> tuple[list, Optional[str], bool]:
base_url = config["base_url"]
if not base_url:
return [], None, False
base_url = base_url.rstrip("/")
api_format = config["api_format"]
api_key_value = config["api_key"]
extra_headers = config.get("extra_headers")
try:
adapter_class = _get_adapter_for_format(api_format)
if not adapter_class:
return [], f"Unknown API format: {api_format}", False
async with semaphore:
models, error = await adapter_class.fetch_models( # type: ignore[attr-defined]
client, base_url, api_key_value, extra_headers
)
for m in models:
if "api_format" not in m:
m["api_format"] = api_format
# 即使返回空列表,只要没有错误也算成功
success = error is None
return models, error, success
except httpx.TimeoutException:
logger.warning(f"获取 {api_format} 模型超时")
return [], f"{api_format}: timeout", False
except Exception:
logger.exception(f"获取 {api_format} 模型出错")
return [], f"{api_format}: error", False
async with httpx.AsyncClient(timeout=timeout) as client:
results = await asyncio.gather(*[fetch_one(client, c) for c in endpoint_configs])
for models, error, success in results:
all_models.extend(models)
if error:
errors.append(error)
if success:
has_success = True
return all_models, errors, has_success

View File

@@ -109,6 +109,8 @@ class PreferenceService:
"is_active": user.is_active, "is_active": user.is_active,
"created_at": user.created_at, "created_at": user.created_at,
"last_login_at": user.last_login_at, "last_login_at": user.last_login_at,
"auth_source": user.auth_source.value if user.auth_source else "local",
"has_password": bool(user.password_hash),
"preferences": { "preferences": {
"avatar_url": preferences.avatar_url, "avatar_url": preferences.avatar_url,
"bio": preferences.bio, "bio": preferences.bio,

View File

@@ -25,18 +25,23 @@ class UserService:
@retry_on_database_error(max_retries=3) @retry_on_database_error(max_retries=3)
def create_user( def create_user(
db: Session, db: Session,
email: str, email: Optional[str],
username: str, username: str,
password: str, password: str,
role: UserRole = UserRole.USER, role: UserRole = UserRole.USER,
quota_usd: Optional[float] = 10.0, quota_usd: Optional[float] = 10.0,
email_verified: bool = False,
) -> User: ) -> User:
"""创建新用户quota_usd 为 None 表示无限制""" """创建新用户quota_usd 为 None 表示无限制email 为 None 表示无邮箱"""
# 验证邮箱格式 # 验证邮箱格式(仅当提供邮箱时)
if email is not None:
valid, error_msg = EmailValidator.validate(email) valid, error_msg = EmailValidator.validate(email)
if not valid: if not valid:
raise ValueError(error_msg) raise ValueError(error_msg)
# 检查邮箱是否已存在
if db.query(User).filter(User.email == email).first():
raise ValueError(f"邮箱已存在: {email}")
# 验证用户名格式 # 验证用户名格式
valid, error_msg = UsernameValidator.validate(username) valid, error_msg = UsernameValidator.validate(username)
@@ -48,16 +53,13 @@ class UserService:
if not valid: if not valid:
raise ValueError(error_msg) raise ValueError(error_msg)
# 检查邮箱是否已存在
if db.query(User).filter(User.email == email).first():
raise ValueError(f"邮箱已存在: {email}")
# 检查用户名是否已存在 # 检查用户名是否已存在
if db.query(User).filter(User.username == username).first(): if db.query(User).filter(User.username == username).first():
raise ValueError(f"用户名已存在: {username}") raise ValueError(f"用户名已存在: {username}")
user = User( user = User(
email=email, email=email,
email_verified=email_verified if email else False,
username=username, username=username,
role=role, role=role,
quota_usd=quota_usd, quota_usd=quota_usd,
@@ -69,7 +71,8 @@ class UserService:
db.commit() # 立即提交事务,释放数据库锁 db.commit() # 立即提交事务,释放数据库锁
db.refresh(user) db.refresh(user)
logger.info(f"创建新用户: {email} (ID: {user.id}, 角色: {role.value})") log_identifier = email if email else username
logger.info(f"创建新用户: {log_identifier} (ID: {user.id}, 角色: {role.value})")
return user return user
@staticmethod @staticmethod

View File

@@ -59,16 +59,12 @@ async def get_current_user(
raise ForbiddenException("无效的Token") raise ForbiddenException("无效的Token")
user_id = payload.get("user_id") user_id = payload.get("user_id")
token_email = payload.get("email")
token_created_at = payload.get("created_at")
if not user_id: if not user_id:
logger.error(f"Token缺少user_id字段: payload={payload}") logger.error("Token缺少user_id字段: payload={}", payload)
raise ForbiddenException("无效的认证凭据") raise ForbiddenException("无效的认证凭据")
if not token_email: # 兼容旧 tokenemail 字段可能存在;新 token 不再包含 email支持无邮箱用户
logger.error(f"Token缺少email字段: payload={payload}")
raise ForbiddenException("无效的认证凭据")
# 仅在DEBUG模式下记录详细信息 # 仅在DEBUG模式下记录详细信息
token_fp = hashlib.sha256(token.encode()).hexdigest()[:12] token_fp = hashlib.sha256(token.encode()).hexdigest()[:12]
@@ -76,7 +72,7 @@ async def get_current_user(
# 确保user_id是字符串格式UUID # 确保user_id是字符串格式UUID
if not isinstance(user_id, str): if not isinstance(user_id, str):
logger.error(f"Token中user_id格式错误: {type(user_id)} - {user_id}") logger.error("Token中user_id格式错误: {} - {}", type(user_id), user_id)
raise ForbiddenException("认证信息格式错误,请重新登录") raise ForbiddenException("认证信息格式错误,请重新登录")
# 使用新的数据库会话获取用户,避免会话状态问题 # 使用新的数据库会话获取用户,避免会话状态问题
@@ -85,46 +81,35 @@ async def get_current_user(
user = UserService.get_user(db, user_id) user = UserService.get_user(db, user_id)
except Exception as db_error: except Exception as db_error:
logger.error(f"数据库查询失败: user_id={user_id}, error={db_error}") logger.error("数据库查询失败: user_id={}, error={}", user_id, db_error)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="数据库查询失败,请稍后重试", detail="数据库查询失败,请稍后重试",
) )
if not user: if not user:
logger.error(f"用户不存在: user_id={user_id}") logger.error("用户不存在: user_id={}", user_id)
raise ForbiddenException("用户不存在或已禁用") raise ForbiddenException("用户不存在或已禁用")
if not user.is_active: if not user.is_active:
logger.error(f"用户已禁用: user_id={user_id}") logger.error("用户已禁用: user_id={}", user_id)
raise ForbiddenException("用户不存在或已禁用") raise ForbiddenException("用户不存在或已禁用")
# 验证邮箱是否匹配防止用户ID重用导致的身份混淆 if user.is_deleted:
if user.email != token_email: logger.error("用户已删除: user_id={}", user_id)
logger.error(f"Token邮箱不匹配: Token中的邮箱={token_email}, 数据库中的邮箱={user.email}") raise ForbiddenException("用户不存在或已禁用")
if not AuthService.token_identity_matches_user(payload, user):
logger.error("Token身份校验失败: user_id={}, token_fp={}", user_id, token_fp)
raise ForbiddenException("身份验证失败") raise ForbiddenException("身份验证失败")
# 验证用户创建时间是否匹配防止ID重用 logger.debug("成功获取用户: user_id={}, email={}", user_id, user.email)
if token_created_at and user.created_at:
try:
from datetime import datetime
token_created = datetime.fromisoformat(token_created_at.replace("Z", "+00:00"))
# 允许1秒的时间差异考虑到时间精度问题
time_diff = abs((user.created_at - token_created).total_seconds())
if time_diff > 1:
logger.error(f"Token创建时间不匹配: Token时间={token_created_at}, 用户创建时间={user.created_at}")
raise ForbiddenException("身份验证失败")
except ValueError as e:
logger.warning(f"Token时间格式解析失败: {e}")
logger.debug(f"成功获取用户: user_id={user_id}, email={user.email}")
return user return user
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"认证失败,未预期的错误: {e}") logger.error("认证失败,未预期的错误: {}", e)
# 返回500而不是401避免触发前端的退出逻辑 # 返回500而不是401避免触发前端的退出逻辑
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="认证服务暂时不可用" status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="认证服务暂时不可用"
@@ -166,6 +151,12 @@ async def get_current_user_from_header(
if not user.is_active: if not user.is_active:
raise ForbiddenException("用户已被禁用") raise ForbiddenException("用户已被禁用")
if user.is_deleted:
raise ForbiddenException("用户不存在或已禁用")
if not AuthService.token_identity_matches_user(payload, user):
raise ForbiddenException("身份验证失败")
return user return user
except HTTPException: except HTTPException:
# 保持原始的HTTPException (包括401) # 保持原始的HTTPException (包括401)

View File

@@ -341,11 +341,15 @@ class TestPipelineAdminAuth:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_admin_success(self, pipeline: ApiRequestPipeline) -> None: async def test_authenticate_admin_success(self, pipeline: ApiRequestPipeline) -> None:
"""测试管理员认证成功""" """测试管理员认证成功"""
created_at = datetime.now(timezone.utc)
mock_user = MagicMock() mock_user = MagicMock()
mock_user.id = "admin-123" mock_user.id = "admin-123"
mock_user.is_active = True mock_user.is_active = True
mock_user.is_deleted = False
mock_user.role = UserRole.ADMIN mock_user.role = UserRole.ADMIN
mock_user.email = "admin@example.com" mock_user.email = "admin@example.com"
mock_user.created_at = created_at
mock_request = MagicMock() mock_request = MagicMock()
mock_request.headers = {"authorization": "Bearer valid-token"} mock_request.headers = {"authorization": "Bearer valid-token"}
@@ -358,7 +362,7 @@ class TestPipelineAdminAuth:
pipeline.auth_service, pipeline.auth_service,
"verify_token", "verify_token",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value={"user_id": "admin-123"}, return_value={"user_id": "admin-123", "created_at": created_at.isoformat()},
): ):
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db) user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
@@ -369,11 +373,15 @@ class TestPipelineAdminAuth:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_admin_lowercase_bearer(self, pipeline: ApiRequestPipeline) -> None: async def test_authenticate_admin_lowercase_bearer(self, pipeline: ApiRequestPipeline) -> None:
"""测试 bearer (小写) 前缀也能正确解析""" """测试 bearer (小写) 前缀也能正确解析"""
created_at = datetime.now(timezone.utc)
mock_user = MagicMock() mock_user = MagicMock()
mock_user.id = "admin-123" mock_user.id = "admin-123"
mock_user.is_active = True mock_user.is_active = True
mock_user.is_deleted = False
mock_user.role = UserRole.ADMIN mock_user.role = UserRole.ADMIN
mock_user.email = "admin@example.com" mock_user.email = "admin@example.com"
mock_user.created_at = created_at
mock_request = MagicMock() mock_request = MagicMock()
mock_request.headers = {"authorization": "bearer valid-token"} mock_request.headers = {"authorization": "bearer valid-token"}
@@ -386,7 +394,7 @@ class TestPipelineAdminAuth:
pipeline.auth_service, pipeline.auth_service,
"verify_token", "verify_token",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value={"user_id": "admin-123"}, return_value={"user_id": "admin-123", "created_at": created_at.isoformat()},
) as mock_verify: ) as mock_verify:
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db) user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
@@ -405,10 +413,14 @@ class TestPipelineUserAuth:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_user_lowercase_bearer(self, pipeline: ApiRequestPipeline) -> None: async def test_authenticate_user_lowercase_bearer(self, pipeline: ApiRequestPipeline) -> None:
"""测试 bearer (小写) 前缀也能正确解析""" """测试 bearer (小写) 前缀也能正确解析"""
created_at = datetime.now(timezone.utc)
mock_user = MagicMock() mock_user = MagicMock()
mock_user.id = "user-123" mock_user.id = "user-123"
mock_user.is_active = True mock_user.is_active = True
mock_user.is_deleted = False
mock_user.email = "user@example.com" mock_user.email = "user@example.com"
mock_user.created_at = created_at
mock_request = MagicMock() mock_request = MagicMock()
mock_request.headers = {"authorization": "bearer valid-token"} mock_request.headers = {"authorization": "bearer valid-token"}
@@ -421,7 +433,7 @@ class TestPipelineUserAuth:
pipeline.auth_service, pipeline.auth_service,
"verify_token", "verify_token",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value={"user_id": "user-123"}, return_value={"user_id": "user-123", "created_at": created_at.isoformat()},
) as mock_verify: ) as mock_verify:
user, management_token = await pipeline._authenticate_user(mock_request, mock_db) user, management_token = await pipeline._authenticate_user(mock_request, mock_db)

View File

@@ -19,6 +19,8 @@ from src.services.auth.service import (
JWT_ALGORITHM, JWT_ALGORITHM,
JWT_EXPIRATION_HOURS, JWT_EXPIRATION_HOURS,
) )
from src.core.enums import AuthSource
from src.models.database import UserRole
class TestJWTTokenCreation: class TestJWTTokenCreation:
@@ -167,7 +169,10 @@ class TestUserAuthentication:
mock_user = MagicMock() mock_user = MagicMock()
mock_user.id = "user-123" mock_user.id = "user-123"
mock_user.email = "test@example.com" mock_user.email = "test@example.com"
mock_user.is_deleted = False
mock_user.is_active = True mock_user.is_active = True
mock_user.auth_source = AuthSource.LOCAL
mock_user.role = UserRole.USER
mock_user.verify_password.return_value = True mock_user.verify_password.return_value = True
mock_db = MagicMock() mock_db = MagicMock()
@@ -198,6 +203,10 @@ class TestUserAuthentication:
"""测试密码错误""" """测试密码错误"""
mock_user = MagicMock() mock_user = MagicMock()
mock_user.email = "test@example.com" mock_user.email = "test@example.com"
mock_user.is_deleted = False
mock_user.is_active = True
mock_user.auth_source = AuthSource.LOCAL
mock_user.role = UserRole.USER
mock_user.verify_password.return_value = False mock_user.verify_password.return_value = False
mock_db = MagicMock() mock_db = MagicMock()
@@ -212,7 +221,10 @@ class TestUserAuthentication:
"""测试用户已禁用""" """测试用户已禁用"""
mock_user = MagicMock() mock_user = MagicMock()
mock_user.email = "test@example.com" mock_user.email = "test@example.com"
mock_user.is_deleted = False
mock_user.is_active = False mock_user.is_active = False
mock_user.auth_source = AuthSource.LOCAL
mock_user.role = UserRole.USER
mock_user.verify_password.return_value = True mock_user.verify_password.return_value = True
mock_db = MagicMock() mock_db = MagicMock()
@@ -232,6 +244,7 @@ class TestAPIKeyAuthentication:
mock_user.id = "user-123" mock_user.id = "user-123"
mock_user.email = "test@example.com" mock_user.email = "test@example.com"
mock_user.is_active = True mock_user.is_active = True
mock_user.is_deleted = False
mock_api_key = MagicMock() mock_api_key = MagicMock()
mock_api_key.is_active = True mock_api_key.is_active = True