feat(auth): 添加 OIDC 第三方认证功能

- 新增 OIDC 认证配置项到 .env.template 文件
- 实现 OIDC 认证路由和处理逻辑,包括登录、回调和配置获取接口
- 创建 OIDC 配置模型和服务工具类,支持从环境变量加载配置
- 在前端登录页面添加 OIDC 登录选项,支持动态配置显示
- 添加 OIDC 认证相关配置文档说明
- 支持自动创建 OIDC 用户和部门管理功能
This commit is contained in:
DSYZayn 2026-04-01 00:42:25 +08:00
parent 62d763a1a0
commit f1997d3194
10 changed files with 1154 additions and 13 deletions

View File

@ -70,3 +70,57 @@ TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.co
# THREAD_PVC=yuxi-thread
# SKILLS_PVC=yuxi-skills # 当前代码会读取,但 Pod 挂载实际仍只使用 THREAD_PVC
# =============================================================================
# OIDC 认证配置
# =============================================================================
# 是否启用 OIDC 认证 (true/false)
# OIDC_ENABLED=false
# 认证源名称(显示在登录按钮上的文字,建议简短且具有辨识度, 默认: OIDC登录
# OIDC_PROVIDER_NAME="OIDC登录"
# OIDC Provider 的 Issuer URL (例如: https://auth.example.com)
# OIDC_ISSUER_URL=
# OIDC Client ID
# OIDC_CLIENT_ID=
# OIDC Client Secret
# OIDC_CLIENT_SECRET=
# OIDC 回调 URL (可选,默认自动构建为 /api/auth/oidc/callback, 不建议自定义)
# 需要确保此 URL 在 OIDC Provider 中已注册
# OIDC_REDIRECT_URI=
# 授权端点 (可选,自动从 discovery 获取)
# OIDC_AUTHORIZATION_ENDPOINT=
# Token 端点 (可选,自动从 discovery 获取)
# OIDC_TOKEN_ENDPOINT=
# UserInfo 端点 (可选,自动从 discovery 获取)
# OIDC_USERINFO_ENDPOINT=
# 登出端点 (可选,自动从 discovery 获取)
# OIDC_END_SESSION_ENDPOINT=
# 请求的 scope (默认: openid profile email)
# OIDC_SCOPES=openid profile email
# 是否自动创建用户 (true/false默认: true)
# OIDC_AUTO_CREATE_USER=true
# OIDC 用户的默认角色 (user/admin默认: user)
# OIDC_DEFAULT_ROLE=user
# OIDC 用户的默认部门名称 (默认: OIDC用户)
# OIDC_DEFAULT_DEPARTMENT=OIDC用户
# 用户名映射字段 (默认: preferred_username)
# OIDC_USERNAME_CLAIM=preferred_username
# 邮箱映射字段 (默认: email)
# OIDC_EMAIL_CLAIM=email
# 姓名映射字段 (默认: name)
# OIDC_NAME_CLAIM=name

View File

@ -4,6 +4,7 @@ from yuxi.utils import logger
from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -25,6 +26,14 @@ from server.utils.common_utils import log_operation
from yuxi.storage.minio import aupload_file_to_minio
from yuxi.utils.datetime_utils import utc_now_naive
# OIDC 认证相关导入
from server.routers.auth_router_oidc import (
get_oidc_config_handler,
oidc_callback_handler,
oidc_login_url_handler,
OIDCConfigResponse,
)
# 创建路由器
auth = APIRouter(prefix="/auth", tags=["authentication"])
@ -826,3 +835,29 @@ async def impersonate_user(
"department_id": target_user.department_id,
"department_name": department_name,
}
# =============================================================================
# === OIDC 认证分组 ===
# =============================================================================
@auth.get("/oidc/config", response_model=OIDCConfigResponse)
async def get_oidc_config():
"""获取 OIDC 配置(供前端使用)"""
return await get_oidc_config_handler()
@auth.get("/oidc/login-url")
async def get_oidc_login_url(redirect_path: str = "/"):
"""获取 OIDC 登录 URL"""
return await oidc_login_url_handler(redirect_path)
@auth.get("/oidc/callback", response_class=RedirectResponse)
async def oidc_callback(
code: str,
state: str,
db: AsyncSession = Depends(get_db)
):
"""处理 OIDC 回调 - 重定向到前端 Vue 路由"""
return await oidc_callback_handler(None, code, state, db)

View File

@ -0,0 +1,274 @@
"""OIDC 认证路由模块
此模块包含 OIDC 认证相关的路由需要被导入到主 auth_router.py 中使用
"""
from urllib.parse import urlencode
from fastapi import Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select
from yuxi.utils import logger
from yuxi.storage.postgres.models_business import User, Department
from yuxi.repositories.user_repository import UserRepository
from yuxi.repositories.department_repository import DepartmentRepository
from server.utils.auth_utils import AuthUtils
from server.utils.user_utils import generate_unique_user_id
from server.utils.oidc_config import oidc_config
from server.utils.oidc_utils import OIDCUtils
from server.utils.common_utils import log_operation
from yuxi.utils.datetime_utils import utc_now_naive
# 前端 OIDC 回调路由路径(与 web/src/router/index.js 中的路由保持一致)
FRONTEND_CALLBACK_PATH = "/auth/oidc/callback"
# 登录页路径(用于错误重定向)
FRONTEND_LOGIN_PATH = "/login"
# =============================================================================
# === OIDC 请求和响应模型 ===
# =============================================================================
class OIDCConfigResponse(BaseModel):
"""OIDC 配置响应"""
enabled: bool
login_url: str | None = None
provider_name: str | None = "OIDC登录"
class OIDCCallbackRequest(BaseModel):
"""OIDC 回调请求"""
code: str
state: str
class OIDCLoginResponse(BaseModel):
"""OIDC 登录响应"""
access_token: str
token_type: str
user_id: int
username: str
user_id_login: str
phone_number: str | None = None
avatar: str | None = None
role: str
department_id: int | None = None
department_name: str | None = None
# =============================================================================
# === OIDC 工具函数 ===
# =============================================================================
async def get_or_create_oidc_department(db) -> Department | None:
"""获取或创建 OIDC 用户的默认部门"""
dept_name = oidc_config.default_department
result = await db.execute(select(Department).filter(Department.name == dept_name))
dept = result.scalar_one_or_none()
if not dept:
# 创建 OIDC 用户部门
dept_repo = DepartmentRepository()
dept = await dept_repo.create({
"name": dept_name,
"description": f"{dept_name}部门",
})
logger.info(f"Created OIDC department: {dept_name}")
return dept
async def find_user_by_oidc_sub(db, sub: str) -> User | None:
"""通过 OIDC sub 查找用户"""
# OIDC 用户的 user_id 格式为: oidc:{sub}
oidc_user_id = f"oidc:{sub}"
result = await db.execute(select(User).filter(User.user_id == oidc_user_id, User.is_deleted == 0))
return result.scalar_one_or_none()
async def create_oidc_user(db, user_info: dict, department_id: int | None = None) -> User:
"""创建 OIDC 用户"""
user_repo = UserRepository()
sub = user_info["sub"]
username = user_info["name"] or user_info["username"]
email = user_info["email"]
# 生成唯一的 user_id
existing_user_ids = await user_repo.get_all_user_ids()
base_username = user_info["username"]
user_id = f"oidc:{sub}"
# 如果 oidc:{sub} 已存在,添加随机后缀
if user_id in existing_user_ids:
import uuid
user_id = f"oidc:{sub}:{uuid.uuid4().hex[:8]}"
# 生成随机密码OIDC 用户不需要密码登录)
import secrets
random_password = secrets.token_urlsafe(32)
password_hash = AuthUtils.hash_password(random_password)
# 创建用户
new_user = await user_repo.create({
"username": username,
"user_id": user_id,
"phone_number": None, # OIDC 用户没有手机号
"avatar": None,
"password_hash": password_hash,
"role": oidc_config.default_role,
"department_id": department_id,
"last_login": utc_now_naive(),
})
logger.info(f"Created OIDC user: {username} ({user_id})")
return new_user
async def update_oidc_user_login(db, user: User) -> None:
"""更新 OIDC 用户登录时间"""
user.last_login = utc_now_naive()
await db.commit()
def _redirect_to_callback(token_data: dict) -> RedirectResponse:
"""成功后重定向到前端 OIDC 回调页面,通过 URL 参数传递登录数据"""
params: dict = {
"token": token_data["access_token"],
"user_id": str(token_data["user_id"]),
"username": token_data["username"],
"user_id_login": token_data["user_id_login"],
"role": token_data["role"],
}
if token_data.get("phone_number"):
params["phone_number"] = token_data["phone_number"]
if token_data.get("avatar"):
params["avatar"] = token_data["avatar"]
if token_data.get("department_id") is not None: # 0 is a valid id, so check explicitly
params["department_id"] = str(token_data["department_id"])
if token_data.get("department_name"):
params["department_name"] = token_data["department_name"]
url = f"{FRONTEND_CALLBACK_PATH}?{urlencode(params)}"
return RedirectResponse(url=url, status_code=302)
def _redirect_to_login_with_error(error_message: str) -> RedirectResponse:
"""失败时重定向到登录页并携带错误信息"""
url = f"{FRONTEND_LOGIN_PATH}?{urlencode({'oidc_error': error_message})}"
return RedirectResponse(url=url, status_code=302)
# =============================================================================
# === OIDC 路由处理函数 ===
# =============================================================================
async def get_oidc_config_handler():
"""获取 OIDC 配置(供前端使用)"""
if not oidc_config.enabled or not oidc_config.is_configured():
return OIDCConfigResponse(enabled=False)
login_url = await OIDCUtils.build_authorization_url()
provider_name = oidc_config.provider_name
return OIDCConfigResponse(enabled=True, login_url=login_url, provider_name=provider_name)
async def oidc_callback_handler(request: Request, code: str, state: str, db):
"""处理 OIDC 回调 - 重定向到前端 Vue 路由"""
# 验证 state
state_data = OIDCUtils.verify_state(state)
if not state_data:
return _redirect_to_login_with_error("登录会话已过期,请返回登录页重试")
# 用授权码交换令牌
token_response = await OIDCUtils.exchange_code_for_token(code)
if not token_response:
return _redirect_to_login_with_error("无法获取访问令牌,请返回登录页重试")
access_token = token_response.get("access_token")
if not access_token:
return _redirect_to_login_with_error("无法获取访问令牌,请返回登录页重试")
# 获取用户信息
userinfo = await OIDCUtils.get_userinfo(access_token)
if not userinfo:
return _redirect_to_login_with_error("无法获取用户信息,请返回登录页重试")
# 提取用户信息
extracted_info = OIDCUtils.extract_user_info(userinfo)
sub = extracted_info["sub"]
if not sub:
return _redirect_to_login_with_error("无法获取用户标识,请返回登录页重试")
# 查找或创建用户
user = await find_user_by_oidc_sub(db, sub)
if user:
# 更新登录时间
await update_oidc_user_login(db, user)
logger.info(f"OIDC user logged in: {user.username}")
elif oidc_config.auto_create_user:
# 获取或创建 OIDC 部门
dept = await get_or_create_oidc_department(db)
department_id = dept.id if dept else None
# 创建新用户
user = await create_oidc_user(db, extracted_info, department_id)
else:
return _redirect_to_login_with_error("用户未注册,请联系管理员开通账号")
# 检查用户是否被删除
if user.is_deleted:
return _redirect_to_login_with_error("该账户已注销")
# 生成访问令牌
token_data = {"sub": str(user.id)}
jwt_token = AuthUtils.create_access_token(token_data)
# 记录登录操作
await log_operation(db, user.id, "OIDC 登录")
# 获取部门名称
department_name = None
if user.department_id:
result = await db.execute(select(Department.name).filter(Department.id == user.department_id))
department_name = result.scalar_one_or_none()
# 构建响应数据
response_data = {
"access_token": jwt_token,
"token_type": "bearer",
"user_id": user.id,
"username": user.username,
"user_id_login": user.user_id,
"phone_number": user.phone_number,
"avatar": user.avatar,
"role": user.role,
"department_id": user.department_id,
"department_name": department_name,
}
# 重定向到前端 OIDC 回调 Vue 页面
return _redirect_to_callback(response_data)
async def oidc_login_url_handler(redirect_path: str = "/"):
"""获取 OIDC 登录 URL"""
from fastapi import HTTPException, status
if not oidc_config.enabled or not oidc_config.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OIDC is not enabled or not configured"
)
login_url = await OIDCUtils.build_authorization_url(redirect_path)
if not login_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to build authorization URL"
)
return {"login_url": login_url}

View File

@ -0,0 +1,90 @@
"""OIDC 配置模块"""
import os
from pydantic import BaseModel, Field
class OIDCConfig(BaseModel):
"""OIDC 配置模型"""
# 是否启用 OIDC 认证
enabled: bool = Field(default=False, description="是否启用 OIDC 认证")
# OIDC Provider 配置
issuer_url: str = Field(default="", description="OIDC Provider 的 issuer URL")
client_id: str = Field(default="", description="OIDC Client ID")
client_secret: str = Field(default="", description="OIDC Client Secret")
# 回调 URL可选默认自动构建
redirect_uri: str = Field(default="", description="OIDC 回调 URL")
# 授权端点(可选,自动从 discovery 获取)
authorization_endpoint: str = Field(default="", description="授权端点 URL")
token_endpoint: str = Field(default="", description="Token 端点 URL")
userinfo_endpoint: str = Field(default="", description="UserInfo 端点 URL")
end_session_endpoint: str = Field(default="", description="登出端点 URL")
# 认证源名称
provider_name: str = Field(default="OIDC登录", description="认证源名称,显示在登录按钮上的文字")
# 请求的 scope
scopes: str = Field(default="openid profile email", description="请求的 scope")
# 是否自动创建用户
auto_create_user: bool = Field(default=True, description="是否自动创建用户")
# 默认角色
default_role: str = Field(default="user", description="OIDC 用户的默认角色")
# 默认部门名称
default_department: str = Field(default="OIDC用户", description="OIDC 用户的默认部门")
# 用户名映射字段
username_claim: str = Field(default="preferred_username", description="用户名映射字段")
# 邮箱映射字段
email_claim: str = Field(default="email", description="邮箱映射字段")
# 姓名映射字段
name_claim: str = Field(default="name", description="姓名映射字段")
@classmethod
def from_env(cls) -> "OIDCConfig":
"""从环境变量加载配置"""
enabled = os.environ.get("OIDC_ENABLED", "false").lower() == "true"
if not enabled:
return cls(enabled=False)
return cls(
enabled=enabled,
provider_name=os.environ.get("OIDC_PROVIDER_NAME", "OIDC登录"),
issuer_url=os.environ.get("OIDC_ISSUER_URL", ""),
client_id=os.environ.get("OIDC_CLIENT_ID", ""),
client_secret=os.environ.get("OIDC_CLIENT_SECRET", ""),
redirect_uri=os.environ.get("OIDC_REDIRECT_URI", ""),
authorization_endpoint=os.environ.get("OIDC_AUTHORIZATION_ENDPOINT", ""),
token_endpoint=os.environ.get("OIDC_TOKEN_ENDPOINT", ""),
userinfo_endpoint=os.environ.get("OIDC_USERINFO_ENDPOINT", ""),
end_session_endpoint=os.environ.get("OIDC_END_SESSION_ENDPOINT", ""),
scopes=os.environ.get("OIDC_SCOPES", "openid profile email"),
auto_create_user=os.environ.get("OIDC_AUTO_CREATE_USER", "true").lower() == "true",
default_role=os.environ.get("OIDC_DEFAULT_ROLE", "user"),
default_department=os.environ.get("OIDC_DEFAULT_DEPARTMENT", "OIDC用户"),
username_claim=os.environ.get("OIDC_USERNAME_CLAIM", "preferred_username"),
email_claim=os.environ.get("OIDC_EMAIL_CLAIM", "email"),
name_claim=os.environ.get("OIDC_NAME_CLAIM", "name"),
)
def is_configured(self) -> bool:
"""检查配置是否完整"""
if not self.enabled:
return False
return all([
self.issuer_url,
self.client_id,
self.client_secret,
])
# 全局配置实例
oidc_config = OIDCConfig.from_env()

View File

@ -0,0 +1,229 @@
"""OIDC 认证工具类"""
import secrets
import urllib.parse
from typing import Any, Optional
import httpx
from yuxi.utils import logger
from server.utils.oidc_config import oidc_config
class OIDCProviderMetadata:
"""OIDC Provider 元数据"""
def __init__(self):
self.authorization_endpoint: Optional[str] = None
self.token_endpoint: Optional[str] = None
self.userinfo_endpoint: Optional[str] = None
self.end_session_endpoint: Optional[str] = None
self._loaded = False
async def load(self, issuer_url: str) -> bool:
"""从 discovery 端点加载元数据"""
if self._loaded:
return True
try:
# 构建 discovery URL
discovery_url = f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url, timeout=30.0)
response.raise_for_status()
metadata = response.json()
self.authorization_endpoint = metadata.get("authorization_endpoint")
self.token_endpoint = metadata.get("token_endpoint")
self.userinfo_endpoint = metadata.get("userinfo_endpoint")
self.end_session_endpoint = metadata.get("end_session_endpoint")
self._loaded = True
logger.info(f"OIDC discovery loaded from {discovery_url}")
return True
except Exception as e:
logger.error(f"Failed to load OIDC discovery: {e}")
return False
class OIDCUtils:
"""OIDC 工具类"""
_metadata: Optional[OIDCProviderMetadata] = None
_state_store: dict[str, dict[str, Any]] = {} # 简单的 state 存储
@classmethod
async def get_metadata(cls) -> Optional[OIDCProviderMetadata]:
"""获取 OIDC Provider 元数据"""
if not oidc_config.enabled or not oidc_config.is_configured():
return None
if cls._metadata is None:
cls._metadata = OIDCProviderMetadata()
# 优先使用配置中的端点
if oidc_config.authorization_endpoint:
cls._metadata.authorization_endpoint = oidc_config.authorization_endpoint
cls._metadata.token_endpoint = oidc_config.token_endpoint
cls._metadata.userinfo_endpoint = oidc_config.userinfo_endpoint
cls._metadata.end_session_endpoint = oidc_config.end_session_endpoint
cls._metadata._loaded = True
else:
# 从 discovery 加载
success = await cls._metadata.load(oidc_config.issuer_url)
if not success:
return None
return cls._metadata
@classmethod
def generate_state(cls, redirect_path: str = "/") -> str:
"""生成 state 参数并存储"""
state = secrets.token_urlsafe(32)
cls._state_store[state] = {"redirect_path": redirect_path}
return state
@classmethod
def verify_state(cls, state: str) -> Optional[dict[str, Any]]:
"""验证 state 参数"""
return cls._state_store.pop(state, None)
@classmethod
def generate_nonce(cls) -> str:
"""生成 nonce 参数"""
return secrets.token_urlsafe(32)
@classmethod
async def build_authorization_url(cls, redirect_path: str = "/") -> Optional[str]:
"""构建授权 URL"""
metadata = await cls.get_metadata()
if not metadata or not metadata.authorization_endpoint:
return None
state = cls.generate_state(redirect_path)
nonce = cls.generate_nonce()
# 构建 redirect_uri
redirect_uri = oidc_config.redirect_uri
if not redirect_uri:
# 自动构建回调 URL
redirect_uri = "/api/auth/oidc/callback"
params = {
"client_id": oidc_config.client_id,
"response_type": "code",
"scope": oidc_config.scopes,
"redirect_uri": redirect_uri,
"state": state,
"nonce": nonce,
}
query_string = urllib.parse.urlencode(params)
return f"{metadata.authorization_endpoint}?{query_string}"
@classmethod
async def exchange_code_for_token(cls, code: str) -> Optional[dict[str, Any]]:
"""用授权码交换令牌"""
metadata = await cls.get_metadata()
if not metadata or not metadata.token_endpoint:
return None
redirect_uri = oidc_config.redirect_uri or "/api/auth/oidc/callback"
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": oidc_config.client_id,
"client_secret": oidc_config.client_secret,
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
metadata.token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to exchange code for token: {e}")
return None
@classmethod
async def get_userinfo(cls, access_token: str) -> Optional[dict[str, Any]]:
"""获取用户信息"""
metadata = await cls.get_metadata()
if not metadata or not metadata.userinfo_endpoint:
return None
try:
async with httpx.AsyncClient() as client:
response = await client.get(
metadata.userinfo_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get userinfo: {e}")
return None
@classmethod
async def build_logout_url(cls, id_token: Optional[str] = None) -> Optional[str]:
"""构建登出 URL"""
metadata = await cls.get_metadata()
if not metadata or not metadata.end_session_endpoint:
return None
params = {"client_id": oidc_config.client_id}
if id_token:
params["id_token_hint"] = id_token
if oidc_config.redirect_uri:
params["post_logout_redirect_uri"] = oidc_config.redirect_uri
query_string = urllib.parse.urlencode(params)
return f"{metadata.end_session_endpoint}?{query_string}"
@classmethod
def extract_user_info(cls, userinfo: dict[str, Any]) -> dict[str, Any]:
"""从 userinfo 中提取用户信息"""
# 获取 sub (subject) - OIDC 用户的唯一标识
sub = userinfo.get("sub", "")
# 获取用户名
username = userinfo.get(oidc_config.username_claim, "")
if not username:
username = userinfo.get("preferred_username", "")
if not username:
username = userinfo.get("email", "").split("@")[0]
if not username:
username = sub[:20] # 使用 sub 的前20位
# 获取邮箱
email = userinfo.get(oidc_config.email_claim, "")
if not email:
email = userinfo.get("email", "")
# 获取显示名称
name = userinfo.get(oidc_config.name_claim, "")
if not name:
name = userinfo.get("name", "")
if not name:
name = username
return {
"sub": sub,
"username": username,
"email": email,
"name": name,
"raw": userinfo,
}

View File

@ -0,0 +1,73 @@
# 第三方登录认证
Yuxi 支持以OIDC接入第三方登录认证方便企业用户集成现有的身份认证系统。
## 配置步骤
1. 前提条件:
在你的SSO系统中注册一个新的客户端应用获取以下信息
- 客户端IDClient ID
- 客户端密钥Client Secret
- ISSUER URL
回调地址Redirect URI<your_yuxi_host>/api/auth/oidc/callback
2. 配置Yuxi
在Yuxi的.env文件中添加以下配置项
```env
# 是否启用 OIDC 认证 (true/false)
# OIDC_ENABLED=false
# 认证源名称(显示在登录按钮上的文字,建议简短且具有辨识度, 默认: OIDC登录
# OIDC_PROVIDER_NAME="OIDC登录"
# OIDC Provider 的 Issuer URL (例如: https://auth.example.com)
# OIDC_ISSUER_URL=
# OIDC Client ID
# OIDC_CLIENT_ID=
# OIDC Client Secret
# OIDC_CLIENT_SECRET=
# OIDC 回调 URL (可选,默认自动构建为 /api/auth/oidc/callback, 不建议自定义)
# 需要确保此 URL 在 OIDC Provider 中已注册
# OIDC_REDIRECT_URI=
# 授权端点 (可选,自动从 discovery 获取)
# OIDC_AUTHORIZATION_ENDPOINT=
# Token 端点 (可选,自动从 discovery 获取)
# OIDC_TOKEN_ENDPOINT=
# UserInfo 端点 (可选,自动从 discovery 获取)
# OIDC_USERINFO_ENDPOINT=
# 登出端点 (可选,自动从 discovery 获取)
# OIDC_END_SESSION_ENDPOINT=
# 请求的 scope (默认: openid profile email)
# OIDC_SCOPES=openid profile email
# 是否自动创建用户 (true/false默认: true)
# OIDC_AUTO_CREATE_USER=true
# OIDC 用户的默认角色 (user/admin默认: user)
# OIDC_DEFAULT_ROLE=user
# OIDC 用户的默认部门名称 (默认: OIDC用户)
# OIDC_DEFAULT_DEPARTMENT=OIDC用户
# 用户名映射字段 (默认: preferred_username)
# OIDC_USERNAME_CLAIM=preferred_username
# 邮箱映射字段 (默认: email)
# OIDC_EMAIL_CLAIM=email
# 姓名映射字段 (默认: name)
# OIDC_NAME_CLAIM=name
```
3. 重启Yuxi服务使配置生效
```bash
docker restart api-dev web-dev
```

87
web/src/apis/auth_api.js Normal file
View File

@ -0,0 +1,87 @@
/**
* 认证相关 API
*/
/**
* 获取 OIDC 配置
* @returns {Promise<{enabled: boolean, provider_name?: string}>}
*/
async function getOIDCConfig() {
const response = await fetch('/api/auth/oidc/config')
if (!response.ok) {
throw new Error('获取 OIDC 配置失败')
}
return response.json()
}
/**
* 获取 OIDC 登录 URL
* @param {string} redirectPath - 登录后的重定向路径
* @returns {Promise<{login_url: string}>}
*/
async function getOIDCLoginUrl(redirectPath = '/') {
const params = new URLSearchParams({ redirect_path: redirectPath })
const response = await fetch(`/api/auth/oidc/login-url?${params}`)
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || '获取 OIDC 登录地址失败')
}
return response.json()
}
/**
* 处理 OIDC 回调
* @param {string} code - 授权码
* @param {string} state - state 参数
* @returns {Promise<{
* access_token: string,
* token_type: string,
* user_id: number,
* username: string,
* user_id_login: string,
* phone_number: string | null,
* avatar: string | null,
* role: string,
* department_id: number | null,
* department_name: string | null
* }>}
*/
async function handleOIDCCallback(code, state) {
const params = new URLSearchParams({ code, state })
const response = await fetch(`/api/auth/oidc/callback?${params}`)
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'OIDC 登录失败')
}
return response.json()
}
/**
* 执行 OIDC 登出
* @param {string} token - JWT token
* @returns {Promise<{logout_url?: string}>}
*/
async function oidcLogout(token) {
const response = await fetch('/api/auth/oidc/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'OIDC 登出失败')
}
return response.json()
}
export const authApi = {
getOIDCConfig,
getOIDCLoginUrl,
handleOIDCCallback,
oidcLogout,
}

View File

@ -26,6 +26,12 @@ const router = createRouter({
component: () => import('../views/LoginView.vue'),
meta: { requiresAuth: false }
},
{
path: '/auth/oidc/callback', // oidc登录回调页面
name: 'OIDCCallback',
component: () => import('@/views/OIDCCallbackView.vue'),
meta: { public: true }
},
{
path: '/agent',
name: 'AgentMain',

View File

@ -215,6 +215,33 @@
</a-button>
</a-form-item>
</a-form>
<!-- OIDC 登录选项 -->
<div v-if="oidcChecking || oidcEnabled" class="third-party-login">
<div class="divider">
<span>或使用以下方式登录</span>
</div>
<div class="login-icons">
<!-- 检查中显示骨架屏 -->
<div v-if="oidcChecking" class="login-skeleton">
<a-skeleton-button block size="large" :active="true" />
</div>
<!-- 检查完成后显示按钮 -->
<a-button
v-else
type="default"
size="large"
block
:loading="oidcLoading"
@click="handleOIDCLogin"
>
<template #icon>
<key-icon size="18" />
</template>
{{ oidcButtonText }}
</a-button>
</div>
</div>
</div>
<!-- 错误提示 -->
@ -243,18 +270,22 @@
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useInfoStore } from '@/stores/info'
import { useAgentStore } from '@/stores/agent'
import { message } from 'ant-design-vue'
import { healthApi } from '@/apis/system_api'
import { authApi } from '@/apis/auth_api'
import {
User as UserIcon,
Lock as LockIcon,
Key as KeyIcon,
AlertCircle as ExclamationCircleIcon
} from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const infoStore = useInfoStore()
const agentStore = useAgentStore()
@ -298,6 +329,12 @@ const serverStatus = ref('loading')
const serverError = ref('')
const healthChecking = ref(false)
// OIDC
const oidcEnabled = ref(false)
const oidcLoading = ref(false)
const oidcChecking = ref(true)
const oidcButtonText = ref('OIDC 登录')
//
const isLocked = ref(false)
const lockRemainingTime = ref(0)
@ -462,6 +499,53 @@ const handleLogin = async () => {
}
}
// OIDC
const handleOIDCLogin = async () => {
if (!ensureAgreementAccepted()) {
return
}
try {
oidcLoading.value = true
errorMessage.value = ''
// OIDC URL
const response = await authApi.getOIDCLoginUrl()
if (response.login_url) {
// 便
const redirectPath = sessionStorage.getItem('redirect') || router.currentRoute.value.query.redirect || '/'
sessionStorage.setItem('oidc_redirect', redirectPath)
// OIDC Provider
window.location.href = response.login_url
} else {
errorMessage.value = '获取 OIDC 登录地址失败'
}
} catch (error) {
console.error('OIDC 登录失败:', error)
errorMessage.value = error.message || 'OIDC 登录失败,请重试'
} finally {
oidcLoading.value = false
}
}
// OIDC
const checkOIDCConfig = async () => {
oidcChecking.value = true
try {
const config = await authApi.getOIDCConfig()
oidcEnabled.value = config.enabled
if (config.provider_name) {
oidcButtonText.value = config.provider_name
}
} catch (error) {
console.error('检查 OIDC 配置失败:', error)
oidcEnabled.value = false
} finally {
oidcChecking.value = false
}
}
//
const handleInitialize = async () => {
if (!ensureAgreementAccepted()) {
@ -535,11 +619,19 @@ onMounted(async () => {
return
}
// OIDC
if (route.query.oidc_error) {
errorMessage.value = String(route.query.oidc_error)
}
//
await checkServerHealth()
//
await checkFirstRunStatus()
// OIDC
checkOIDCConfig()
})
//
@ -763,25 +855,33 @@ onUnmounted(() => {
}
.login-icons {
display: flex;
justify-content: center;
gap: 20px;
.login-icon {
width: 36px;
height: 36px;
font-size: 18px;
color: var(--gray-500);
border-color: var(--gray-300);
:deep(.ant-btn) {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
gap: 8px;
border-color: var(--gray-300);
color: var(--gray-700);
&:hover {
color: var(--main-color);
border-color: var(--main-color);
color: var(--main-color);
background-color: var(--main-10);
transform: translateY(-2px);
}
.anticon,
svg {
color: var(--main-color);
}
}
}
/* 修复:添加骨架屏样式 */
.login-skeleton {
:deep(.ant-skeleton-button) {
width: 100% !important;
height: 44px;
border-radius: 8px;
}
}
}

View File

@ -0,0 +1,193 @@
<template>
<div class="oidc-callback-view">
<div class="callback-container">
<div v-if="loading" class="loading-section">
<a-spin size="large" />
<p class="loading-text">正在处理登录...</p>
</div>
<div v-else-if="error" class="error-section">
<a-result status="error" :title="errorTitle" :sub-title="errorMessage">
<template #extra>
<a-button type="primary" @click="goToLogin">
返回登录页
</a-button>
</template>
</a-result>
</div>
<div v-else class="success-section">
<a-result status="success" title="登录成功" sub-title="正在跳转...">
<template #icon>
<a-spin />
</template>
</a-result>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useAgentStore } from '@/stores/agent'
import { message } from 'ant-design-vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const agentStore = useAgentStore()
//
const loading = ref(true)
const error = ref(false)
const errorTitle = ref('登录失败')
const errorMessage = ref('处理登录请求时发生错误')
//
const goToLogin = () => {
router.push('/login')
}
// OIDC - URL token
const handleCallback = () => {
try {
// URL token
const token = route.query.token
const userId = route.query.user_id
const username = route.query.username
const userIdLogin = route.query.user_id_login
const phoneNumber = route.query.phone_number
const avatar = route.query.avatar
const role = route.query.role
const departmentId = route.query.department_id
const departmentName = route.query.department_name
//
if (!token || !userId || !username) {
loading.value = false
error.value = true
errorTitle.value = '参数错误'
errorMessage.value = '缺少必要的登录信息,请重新登录'
return
}
//
userStore.token = token
userStore.userId = parseInt(userId)
userStore.username = username
userStore.userIdLogin = userIdLogin || ''
userStore.phoneNumber = phoneNumber || ''
userStore.avatar = avatar || ''
userStore.userRole = role || 'user'
userStore.departmentId = departmentId ? parseInt(departmentId) : null
userStore.departmentName = departmentName || ''
// token localStorage
localStorage.setItem('user_token', token)
//
message.success('登录成功')
//
const redirectPath = sessionStorage.getItem('oidc_redirect') || '/'
sessionStorage.removeItem('oidc_redirect')
loading.value = false
//
setTimeout(async () => {
//
if (redirectPath === '/') {
try {
await agentStore.initialize()
router.push('/agent')
} catch (err) {
console.error('获取智能体信息失败:', err)
router.push('/agent')
}
} else {
router.push(redirectPath)
}
}, 500)
} catch (err) {
console.error('OIDC 回调处理失败:', err)
loading.value = false
error.value = true
errorTitle.value = '登录失败'
errorMessage.value = err.message || '处理登录请求时发生错误,请重试'
}
}
//
onMounted(() => {
//
if (userStore.isLoggedIn) {
router.push('/')
return
}
handleCallback()
})
</script>
<style lang="less" scoped>
.oidc-callback-view {
min-height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--gray-10);
background-image: radial-gradient(var(--gray-200) 1px, transparent 1px);
background-size: 24px 24px;
}
.callback-container {
width: 100%;
max-width: 500px;
padding: 40px;
background: var(--gray-0);
border-radius: 16px;
box-shadow: 0 4px 20px var(--shadow-1);
}
.loading-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
.loading-text {
font-size: 16px;
color: var(--gray-600);
margin: 0;
}
}
.error-section,
.success-section {
:deep(.ant-result) {
padding: 0;
.ant-result-title {
font-size: 20px;
color: var(--gray-800);
}
.ant-result-subtitle {
font-size: 14px;
color: var(--gray-500);
}
}
}
@media (max-width: 576px) {
.callback-container {
margin: 20px;
padding: 30px 20px;
}
}
</style>