feat(auth): 添加 OIDC 第三方认证功能
- 新增 OIDC 认证配置项到 .env.template 文件 - 实现 OIDC 认证路由和处理逻辑,包括登录、回调和配置获取接口 - 创建 OIDC 配置模型和服务工具类,支持从环境变量加载配置 - 在前端登录页面添加 OIDC 登录选项,支持动态配置显示 - 添加 OIDC 认证相关配置文档说明 - 支持自动创建 OIDC 用户和部门管理功能
This commit is contained in:
parent
62d763a1a0
commit
f1997d3194
@ -70,3 +70,57 @@ TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.co
|
|||||||
# THREAD_PVC=yuxi-thread
|
# THREAD_PVC=yuxi-thread
|
||||||
# SKILLS_PVC=yuxi-skills # 当前代码会读取,但 Pod 挂载实际仍只使用 THREAD_PVC
|
# 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
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from yuxi.utils import logger
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.storage.minio import aupload_file_to_minio
|
||||||
from yuxi.utils.datetime_utils import utc_now_naive
|
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"])
|
auth = APIRouter(prefix="/auth", tags=["authentication"])
|
||||||
|
|
||||||
@ -826,3 +835,29 @@ async def impersonate_user(
|
|||||||
"department_id": target_user.department_id,
|
"department_id": target_user.department_id,
|
||||||
"department_name": department_name,
|
"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)
|
||||||
|
|||||||
274
backend/server/routers/auth_router_oidc.py
Normal file
274
backend/server/routers/auth_router_oidc.py
Normal 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}
|
||||||
90
backend/server/utils/oidc_config.py
Normal file
90
backend/server/utils/oidc_config.py
Normal 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()
|
||||||
229
backend/server/utils/oidc_utils.py
Normal file
229
backend/server/utils/oidc_utils.py
Normal 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,
|
||||||
|
}
|
||||||
73
docs/advanced/third-party-auth.md
Normal file
73
docs/advanced/third-party-auth.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# 第三方登录认证
|
||||||
|
Yuxi 支持以OIDC接入第三方登录认证,方便企业用户集成现有的身份认证系统。
|
||||||
|
|
||||||
|
## 配置步骤
|
||||||
|
1. 前提条件:
|
||||||
|
在你的SSO系统中注册一个新的客户端应用,获取以下信息:
|
||||||
|
- 客户端ID(Client 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
87
web/src/apis/auth_api.js
Normal 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,
|
||||||
|
}
|
||||||
@ -26,6 +26,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/LoginView.vue'),
|
component: () => import('../views/LoginView.vue'),
|
||||||
meta: { requiresAuth: false }
|
meta: { requiresAuth: false }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/oidc/callback', // oidc登录回调页面
|
||||||
|
name: 'OIDCCallback',
|
||||||
|
component: () => import('@/views/OIDCCallbackView.vue'),
|
||||||
|
meta: { public: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/agent',
|
path: '/agent',
|
||||||
name: 'AgentMain',
|
name: 'AgentMain',
|
||||||
|
|||||||
@ -215,6 +215,33 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 错误提示 -->
|
<!-- 错误提示 -->
|
||||||
@ -243,18 +270,22 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
|
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 { useUserStore } from '@/stores/user'
|
||||||
import { useInfoStore } from '@/stores/info'
|
import { useInfoStore } from '@/stores/info'
|
||||||
import { useAgentStore } from '@/stores/agent'
|
import { useAgentStore } from '@/stores/agent'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { healthApi } from '@/apis/system_api'
|
import { healthApi } from '@/apis/system_api'
|
||||||
|
import { authApi } from '@/apis/auth_api'
|
||||||
import {
|
import {
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Lock as LockIcon,
|
Lock as LockIcon,
|
||||||
|
Key as KeyIcon,
|
||||||
AlertCircle as ExclamationCircleIcon
|
AlertCircle as ExclamationCircleIcon
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const infoStore = useInfoStore()
|
const infoStore = useInfoStore()
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
@ -298,6 +329,12 @@ const serverStatus = ref('loading')
|
|||||||
const serverError = ref('')
|
const serverError = ref('')
|
||||||
const healthChecking = ref(false)
|
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 isLocked = ref(false)
|
||||||
const lockRemainingTime = ref(0)
|
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 () => {
|
const handleInitialize = async () => {
|
||||||
if (!ensureAgreementAccepted()) {
|
if (!ensureAgreementAccepted()) {
|
||||||
@ -535,11 +619,19 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示 OIDC 认证失败的错误信息(由后端重定向携带)
|
||||||
|
if (route.query.oidc_error) {
|
||||||
|
errorMessage.value = String(route.query.oidc_error)
|
||||||
|
}
|
||||||
|
|
||||||
// 首先检查服务器健康状态
|
// 首先检查服务器健康状态
|
||||||
await checkServerHealth()
|
await checkServerHealth()
|
||||||
|
|
||||||
// 检查是否是首次运行
|
// 检查是否是首次运行
|
||||||
await checkFirstRunStatus()
|
await checkFirstRunStatus()
|
||||||
|
|
||||||
|
// 检查 OIDC 配置
|
||||||
|
checkOIDCConfig()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载时清理定时器
|
// 组件卸载时清理定时器
|
||||||
@ -763,25 +855,33 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-icons {
|
.login-icons {
|
||||||
display: flex;
|
:deep(.ant-btn) {
|
||||||
justify-content: center;
|
|
||||||
gap: 20px;
|
|
||||||
.login-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 18px;
|
|
||||||
color: var(--gray-500);
|
|
||||||
border-color: var(--gray-300);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s ease;
|
gap: 8px;
|
||||||
|
border-color: var(--gray-300);
|
||||||
|
color: var(--gray-700);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--main-color);
|
|
||||||
border-color: var(--main-color);
|
border-color: var(--main-color);
|
||||||
|
color: var(--main-color);
|
||||||
background-color: var(--main-10);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
193
web/src/views/OIDCCallbackView.vue
Normal file
193
web/src/views/OIDCCallbackView.vue
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user