From f1997d3194ad6130053019737c0ac2bfa449b258 Mon Sep 17 00:00:00 2001 From: DSYZayn Date: Wed, 1 Apr 2026 00:42:25 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=20OIDC=20?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=96=B9=E8=AE=A4=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 OIDC 认证配置项到 .env.template 文件 - 实现 OIDC 认证路由和处理逻辑,包括登录、回调和配置获取接口 - 创建 OIDC 配置模型和服务工具类,支持从环境变量加载配置 - 在前端登录页面添加 OIDC 登录选项,支持动态配置显示 - 添加 OIDC 认证相关配置文档说明 - 支持自动创建 OIDC 用户和部门管理功能 --- .env.template | 54 ++++ backend/server/routers/auth_router.py | 35 +++ backend/server/routers/auth_router_oidc.py | 274 +++++++++++++++++++++ backend/server/utils/oidc_config.py | 90 +++++++ backend/server/utils/oidc_utils.py | 229 +++++++++++++++++ docs/advanced/third-party-auth.md | 73 ++++++ web/src/apis/auth_api.js | 87 +++++++ web/src/router/index.js | 6 + web/src/views/LoginView.vue | 126 +++++++++- web/src/views/OIDCCallbackView.vue | 193 +++++++++++++++ 10 files changed, 1154 insertions(+), 13 deletions(-) create mode 100644 backend/server/routers/auth_router_oidc.py create mode 100644 backend/server/utils/oidc_config.py create mode 100644 backend/server/utils/oidc_utils.py create mode 100644 docs/advanced/third-party-auth.md create mode 100644 web/src/apis/auth_api.js create mode 100644 web/src/views/OIDCCallbackView.vue diff --git a/.env.template b/.env.template index 61d69991b..135625d9b 100644 --- a/.env.template +++ b/.env.template @@ -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 diff --git a/backend/server/routers/auth_router.py b/backend/server/routers/auth_router.py index 7b565d938..67afd1563 100644 --- a/backend/server/routers/auth_router.py +++ b/backend/server/routers/auth_router.py @@ -4,6 +4,7 @@ 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 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) diff --git a/backend/server/routers/auth_router_oidc.py b/backend/server/routers/auth_router_oidc.py new file mode 100644 index 000000000..7a8f449ed --- /dev/null +++ b/backend/server/routers/auth_router_oidc.py @@ -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} diff --git a/backend/server/utils/oidc_config.py b/backend/server/utils/oidc_config.py new file mode 100644 index 000000000..a979d3389 --- /dev/null +++ b/backend/server/utils/oidc_config.py @@ -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() diff --git a/backend/server/utils/oidc_utils.py b/backend/server/utils/oidc_utils.py new file mode 100644 index 000000000..1254b6eab --- /dev/null +++ b/backend/server/utils/oidc_utils.py @@ -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, + } diff --git a/docs/advanced/third-party-auth.md b/docs/advanced/third-party-auth.md new file mode 100644 index 000000000..fd1786466 --- /dev/null +++ b/docs/advanced/third-party-auth.md @@ -0,0 +1,73 @@ +# 第三方登录认证 +Yuxi 支持以OIDC接入第三方登录认证,方便企业用户集成现有的身份认证系统。 + +## 配置步骤 +1. 前提条件: +在你的SSO系统中注册一个新的客户端应用,获取以下信息: +- 客户端ID(Client ID) +- 客户端密钥(Client Secret) +- ISSUER URL + +回调地址(Redirect URI):/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 +``` \ No newline at end of file diff --git a/web/src/apis/auth_api.js b/web/src/apis/auth_api.js new file mode 100644 index 000000000..f84bf2dac --- /dev/null +++ b/web/src/apis/auth_api.js @@ -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, +} diff --git a/web/src/router/index.js b/web/src/router/index.js index 0834a635a..73e587d96 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -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', diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index b9e9c6939..c8f56f081 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -215,6 +215,33 @@ + + + @@ -243,18 +270,22 @@ + + From 01705aa66b65a7a1ee5dd54d4a77f7331e1a623c Mon Sep 17 00:00:00 2001 From: DSYZayn Date: Wed, 1 Apr 2026 22:10:24 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8DOIDC?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=96=B9=E7=99=BB=E5=BD=95=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E9=9B=86=E6=88=90=E7=9A=84=E6=BD=9C=E5=9C=A8=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=20-=20=E5=AE=9E=E7=8E=B0OIDC=E7=94=A8=E6=88=B7=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=9B=E5=BB=BA=E5=92=8C=E9=83=A8=E9=97=A8=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=9C=BA=E6=99=AF=E4=B8=8B=E7=9A=84=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E5=A4=84=E7=90=86=20-=20=E9=87=8D=E6=9E=84OI?= =?UTF-8?q?DC=E5=9B=9E=E8=B0=83=E6=B5=81=E7=A8=8B=EF=BC=8C=E9=87=87?= =?UTF-8?q?=E7=94=A8=E4=B8=80=E6=AC=A1=E6=80=A7code=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E5=AE=89=E5=85=A8=E6=80=A7=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=95=8F=E6=84=9F=E4=BF=A1=E6=81=AF=E9=80=9A=E8=BF=87?= =?UTF-8?q?URL=E4=BC=A0=E9=80=92=20-=20=E5=A2=9E=E5=8A=A0=E5=AF=B9?= =?UTF-8?q?=E5=B7=B2=E6=B3=A8=E9=94=80OIDC=E7=94=A8=E6=88=B7=E7=9A=84?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=94=AF=E6=8C=81=EF=BC=8C=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=90=8E=E7=BC=80=E7=94=A8=E6=88=B7ID?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=20-=20=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=BD=AF=E5=88=A0=E9=99=A4=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BD=BF?= =?UTF-8?q?=E7=94=A8user=5Fid=E5=92=8Cid=E7=BB=84=E5=90=88=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=93=88=E5=B8=8C=E9=81=BF=E5=85=8D=E9=87=8D=E5=90=8D?= =?UTF-8?q?=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/server/routers/auth_router.py | 17 +- backend/server/routers/auth_router_oidc.py | 240 ++++++++++++++------- backend/server/utils/oidc_utils.py | 54 ++++- docs/develop-guides/roadmap.md | 2 +- web/src/apis/auth_api.js | 54 +++-- web/src/views/OIDCCallbackView.vue | 50 ++--- 6 files changed, 276 insertions(+), 141 deletions(-) diff --git a/backend/server/routers/auth_router.py b/backend/server/routers/auth_router.py index 67afd1563..55a4539bd 100644 --- a/backend/server/routers/auth_router.py +++ b/backend/server/routers/auth_router.py @@ -2,7 +2,7 @@ import uuid from yuxi.utils import logger -from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File +from fastapi import APIRouter, Body, Depends, HTTPException, Request, status, UploadFile, File from fastapi.security import OAuth2PasswordRequestForm from fastapi.responses import RedirectResponse from pydantic import BaseModel @@ -30,8 +30,10 @@ from server.routers.auth_router_oidc import ( get_oidc_config_handler, oidc_callback_handler, + oidc_exchange_code_handler, oidc_login_url_handler, OIDCConfigResponse, + OIDCLoginResponse, ) # 创建路由器 @@ -681,8 +683,8 @@ async def delete_user( # 软删除:标记删除状态并脱敏 import hashlib - # 生成4位哈希(基于user_id保证唯一性) - hash_suffix = hashlib.sha256(user.user_id.encode()).hexdigest()[:4] + # 生成4位哈希(基于 user_id + id,避免历史软删除记录重名冲突) + hash_suffix = hashlib.sha256(f"{user.user_id}:{user.id}".encode()).hexdigest()[:4] user.is_deleted = 1 user.deleted_at = utc_now_naive() @@ -855,9 +857,16 @@ async def get_oidc_login_url(redirect_path: str = "/"): @auth.get("/oidc/callback", response_class=RedirectResponse) async def oidc_callback( + request: Request, code: str, state: str, db: AsyncSession = Depends(get_db) ): """处理 OIDC 回调 - 重定向到前端 Vue 路由""" - return await oidc_callback_handler(None, code, state, db) + return await oidc_callback_handler(code, state, db, request) + + +@auth.post("/oidc/exchange-code", response_model=OIDCLoginResponse) +async def oidc_exchange_code(code: str = Body(..., embed=True)): + """使用一次性 code 交换 OIDC 登录数据""" + return await oidc_exchange_code_handler(code) diff --git a/backend/server/routers/auth_router_oidc.py b/backend/server/routers/auth_router_oidc.py index 7a8f449ed..e3a1337a7 100644 --- a/backend/server/routers/auth_router_oidc.py +++ b/backend/server/routers/auth_router_oidc.py @@ -3,16 +3,16 @@ 此模块包含 OIDC 认证相关的路由,需要被导入到主 auth_router.py 中使用。 """ from urllib.parse import urlencode -from fastapi import Request +import hashlib +from fastapi import HTTPException, Request, status from fastapi.responses import RedirectResponse from pydantic import BaseModel from sqlalchemy import select +from sqlalchemy.exc import IntegrityError 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 @@ -35,12 +35,6 @@ class OIDCConfigResponse(BaseModel): provider_name: str | None = "OIDC登录" -class OIDCCallbackRequest(BaseModel): - """OIDC 回调请求""" - code: str - state: str - - class OIDCLoginResponse(BaseModel): """OIDC 登录响应""" access_token: str @@ -67,23 +61,91 @@ async def get_or_create_oidc_department(db) -> Department | None: 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}") + dept = Department( + name=dept_name, + description=f"{dept_name}部门", + ) + db.add(dept) + try: + await db.commit() + await db.refresh(dept) + logger.info(f"Created OIDC department: {dept_name}") + except IntegrityError: + await db.rollback() + result = await db.execute(select(Department).filter(Department.name == dept_name)) + dept = result.scalar_one_or_none() 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}" + + # 优先匹配标准 user_id(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() + user = result.scalar_one_or_none() + if user: + return user + + # 兼容历史后缀 user_id(oidc:{sub}:xxxx) + legacy_result = await db.execute( + select(User) + .filter(User.user_id.like(f"{oidc_user_id}:%"), User.is_deleted == 0) + .order_by(User.id.asc()) + ) + legacy_users = list(legacy_result.scalars().all()) + if legacy_users: + if len(legacy_users) > 1: + logger.warning(f"Multiple legacy OIDC users matched for sub={sub}, use earliest id={legacy_users[0].id}") + return legacy_users[0] + + return None + + +async def find_deleted_oidc_user_by_sub(db, sub: str) -> User | None: + """查找已注销的 OIDC 账户(标准与历史后缀)""" + oidc_user_id = f"oidc:{sub}" + + result = await db.execute(select(User).filter(User.user_id == oidc_user_id, User.is_deleted == 1)) + deleted_user = result.scalar_one_or_none() + if deleted_user: + return deleted_user + + legacy_result = await db.execute( + select(User) + .filter(User.user_id.like(f"{oidc_user_id}:%"), User.is_deleted == 1) + .order_by(User.id.asc()) + ) + return legacy_result.scalar_one_or_none() + + +async def build_unique_oidc_username(db, preferred_username: str, sub: str) -> str: + """为 OIDC 用户生成不冲突的用户名""" + base_username = preferred_username.strip() if preferred_username else "" + if not base_username: + base_username = f"oidc_{sub[:8]}" + + result = await db.execute(select(User.id).filter(User.username == base_username)) + if result.scalar_one_or_none() is None: + return base_username + + hash_suffix = hashlib.sha256(sub.encode()).hexdigest()[:6] + candidate = f"{base_username}-{hash_suffix}" + result = await db.execute(select(User.id).filter(User.username == candidate)) + if result.scalar_one_or_none() is None: + return candidate + + for i in range(2, 100): + indexed_candidate = f"{candidate}-{i}" + result = await db.execute(select(User.id).filter(User.username == indexed_candidate)) + if result.scalar_one_or_none() is None: + return indexed_candidate + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="无法生成可用用户名,请联系管理员", + ) async def create_oidc_user(db, user_info: dict, department_id: int | None = None) -> User: @@ -91,38 +153,66 @@ async def create_oidc_user(db, user_info: dict, department_id: int | None = None 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"] + preferred_username = user_info["name"] or 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(), - }) + username = await build_unique_oidc_username(db, preferred_username, sub) + + # 并发场景下兜底:若创建时发生唯一键冲突,优先复用已创建账号;否则重试用户名。 + for retry_index in range(3): + try: + 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: {new_user.username} ({user_id})") + return new_user + except IntegrityError: + existing_user = await find_user_by_oidc_sub(db, sub) + if existing_user: + return existing_user + username = await build_unique_oidc_username(db, f"{preferred_username}-{retry_index + 2}", sub) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="创建 OIDC 用户失败,请重试", + ) + + +async def restore_deleted_oidc_user(db, deleted_user: User, user_info: dict) -> User: + """恢复已注销的 OIDC 用户并返回可登录用户""" + preferred_username = user_info["name"] or user_info["username"] + + deleted_user.is_deleted = 0 + deleted_user.deleted_at = None + deleted_user.last_login = utc_now_naive() + deleted_user.phone_number = None + deleted_user.avatar = None + + # 删除流程会把用户名改成“已注销用户-xxxx”,恢复时重新分配可用用户名 + if deleted_user.username.startswith("已注销用户-"): + deleted_user.username = await build_unique_oidc_username(db, preferred_username, user_info["sub"]) + + if deleted_user.password_hash == "DELETED": + import secrets + random_password = secrets.token_urlsafe(32) + deleted_user.password_hash = AuthUtils.hash_password(random_password) - logger.info(f"Created OIDC user: {username} ({user_id})") - return new_user + await db.commit() + await db.refresh(deleted_user) + logger.info(f"Restored deleted OIDC user: {deleted_user.username} ({deleted_user.user_id})") + return deleted_user async def update_oidc_user_login(db, user: User) -> None: @@ -131,25 +221,9 @@ async def update_oidc_user_login(db, user: User) -> None: 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)}" +def _redirect_to_callback(exchange_code: str) -> RedirectResponse: + """成功后重定向到前端 OIDC 回调页面,仅携带一次性 code""" + url = f"{FRONTEND_CALLBACK_PATH}?{urlencode({'code': exchange_code})}" return RedirectResponse(url=url, status_code=302) @@ -168,17 +242,15 @@ async def get_oidc_config_handler(): 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) + return OIDCConfigResponse(enabled=True, provider_name=provider_name) -async def oidc_callback_handler(request: Request, code: str, state: str, db): +async def oidc_callback_handler(code: str, state: str, db, request: Request | None = None): """处理 OIDC 回调 - 重定向到前端 Vue 路由""" # 验证 state - state_data = OIDCUtils.verify_state(state) - if not state_data: + if not OIDCUtils.verify_state(state): return _redirect_to_login_with_error("登录会话已过期,请返回登录页重试") # 用授权码交换令牌 @@ -210,12 +282,17 @@ async def oidc_callback_handler(request: Request, code: str, state: str, db): 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) + deleted_user = await find_deleted_oidc_user_by_sub(db, sub) + if deleted_user: + user = await restore_deleted_oidc_user(db, deleted_user, extracted_info) + logger.info(f"OIDC deleted user restored and logged in: {user.username}") + else: + # 获取或创建 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("用户未注册,请联系管理员开通账号") @@ -228,7 +305,7 @@ async def oidc_callback_handler(request: Request, code: str, state: str, db): jwt_token = AuthUtils.create_access_token(token_data) # 记录登录操作 - await log_operation(db, user.id, "OIDC 登录") + await log_operation(db, user.id, "OIDC 登录", request=request) # 获取部门名称 department_name = None @@ -250,14 +327,25 @@ async def oidc_callback_handler(request: Request, code: str, state: str, db): "department_name": department_name, } + exchange_code = OIDCUtils.generate_login_code(response_data) + # 重定向到前端 OIDC 回调 Vue 页面 - return _redirect_to_callback(response_data) + return _redirect_to_callback(exchange_code) + + +async def oidc_exchange_code_handler(code: str) -> dict: + """用一次性 code 交换登录响应数据""" + token_data = OIDCUtils.consume_login_code(code) + if not token_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="登录 code 无效或已过期,请重新登录", + ) + return token_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, diff --git a/backend/server/utils/oidc_utils.py b/backend/server/utils/oidc_utils.py index 1254b6eab..9f84ce191 100644 --- a/backend/server/utils/oidc_utils.py +++ b/backend/server/utils/oidc_utils.py @@ -1,5 +1,6 @@ """OIDC 认证工具类""" import secrets +import time import urllib.parse from typing import Any, Optional @@ -51,7 +52,24 @@ class OIDCUtils: """OIDC 工具类""" _metadata: Optional[OIDCProviderMetadata] = None - _state_store: dict[str, dict[str, Any]] = {} # 简单的 state 存储 + _state_store: dict[str, dict[str, Any]] = {} + _login_code_store: dict[str, dict[str, Any]] = {} + _state_ttl_seconds = 300 + _login_code_ttl_seconds = 60 + + @classmethod + def _cleanup_expired_state(cls) -> None: + now = time.time() + expired = [k for k, v in cls._state_store.items() if v["expires_at"] <= now] + for key in expired: + cls._state_store.pop(key, None) + + @classmethod + def _cleanup_expired_login_code(cls) -> None: + now = time.time() + expired = [k for k, v in cls._login_code_store.items() if v["expires_at"] <= now] + for key in expired: + cls._login_code_store.pop(key, None) @classmethod async def get_metadata(cls) -> Optional[OIDCProviderMetadata]: @@ -80,14 +98,44 @@ async def get_metadata(cls) -> Optional[OIDCProviderMetadata]: @classmethod def generate_state(cls, redirect_path: str = "/") -> str: """生成 state 参数并存储""" + cls._cleanup_expired_state() state = secrets.token_urlsafe(32) - cls._state_store[state] = {"redirect_path": redirect_path} + cls._state_store[state] = { + "redirect_path": redirect_path, + "expires_at": time.time() + cls._state_ttl_seconds, + } return state @classmethod def verify_state(cls, state: str) -> Optional[dict[str, Any]]: """验证 state 参数""" - return cls._state_store.pop(state, None) + state_data = cls._state_store.pop(state, None) + if not state_data: + return None + if state_data["expires_at"] <= time.time(): + return None + return {"redirect_path": state_data["redirect_path"]} + + @classmethod + def generate_login_code(cls, payload: dict[str, Any]) -> str: + """生成一次性短期登录 code""" + cls._cleanup_expired_login_code() + code = secrets.token_urlsafe(32) + cls._login_code_store[code] = { + "payload": payload, + "expires_at": time.time() + cls._login_code_ttl_seconds, + } + return code + + @classmethod + def consume_login_code(cls, code: str) -> Optional[dict[str, Any]]: + """消费一次性短期登录 code""" + data = cls._login_code_store.pop(code, None) + if not data: + return None + if data["expires_at"] <= time.time(): + return None + return data["payload"] @classmethod def generate_nonce(cls) -> str: diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 963875d7b..63b109c01 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -55,7 +55,7 @@ - 新增面向用户的 Langfuse 集成文档:在“智能体开发”分组中说明 Langfuse 的定位、能力、配置方式与查看路径,并与当前 `LANGFUSE_BASE_URL` 配置保持一致 - +- 新增第三方登录认证集成,支持以OIDC方式接入 ### 修复 - 优化 Agent 输入框 mention 行为:在保留附件 mention 的同时,将共享 `workspace` 文件纳入候选范围;并将 `@` 空查询时的候选列表改为空,仅在继续输入后再执行筛选,避免工作区文件过多时直接铺满下拉面板 diff --git a/web/src/apis/auth_api.js b/web/src/apis/auth_api.js index f84bf2dac..1bbb3a831 100644 --- a/web/src/apis/auth_api.js +++ b/web/src/apis/auth_api.js @@ -2,6 +2,18 @@ * 认证相关 API */ +async function parseErrorDetail(response, fallbackMessage) { + const contentType = response.headers.get('content-type') || '' + + if (contentType.includes('application/json')) { + const error = await response.json() + return error?.detail || fallbackMessage + } + + const text = (await response.text()).trim() + return text || fallbackMessage +} + /** * 获取 OIDC 配置 * @returns {Promise<{enabled: boolean, provider_name?: string}>} @@ -23,16 +35,15 @@ 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 登录地址失败') + const detail = await parseErrorDetail(response, '获取 OIDC 登录地址失败') + throw new Error(detail) } return response.json() } /** - * 处理 OIDC 回调 - * @param {string} code - 授权码 - * @param {string} state - state 参数 + * 使用一次性 code 交换 OIDC 登录结果 + * @param {string} code - 一次性登录 code * @returns {Promise<{ * access_token: string, * token_type: string, @@ -46,34 +57,18 @@ async function getOIDCLoginUrl(redirectPath = '/') { * 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', { +async function exchangeOIDCCode(code) { + const response = await fetch('/api/auth/oidc/exchange-code', { method: 'POST', headers: { - 'Authorization': `Bearer ${token}` - } + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ code }) }) if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || 'OIDC 登出失败') + const detail = await parseErrorDetail(response, 'OIDC 登录失败') + throw new Error(detail) } return response.json() @@ -82,6 +77,5 @@ async function oidcLogout(token) { export const authApi = { getOIDCConfig, getOIDCLoginUrl, - handleOIDCCallback, - oidcLogout, + exchangeOIDCCode, } diff --git a/web/src/views/OIDCCallbackView.vue b/web/src/views/OIDCCallbackView.vue index 9d3fd0d6e..09b2b7b0e 100644 --- a/web/src/views/OIDCCallbackView.vue +++ b/web/src/views/OIDCCallbackView.vue @@ -32,6 +32,7 @@ import { ref, onMounted } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useUserStore } from '@/stores/user' import { useAgentStore } from '@/stores/agent' +import { authApi } from '@/apis/auth_api' import { message } from 'ant-design-vue' const router = useRouter() @@ -50,42 +51,37 @@ const goToLogin = () => { router.push('/login') } -// 处理 OIDC 回调 - 从 URL 参数中获取 token 数据 -const handleCallback = () => { +// 处理 OIDC 回调 - 从 URL 参数中获取一次性 code +const handleCallback = async () => { 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 + const code = route.query.code // 检查必要的参数 - if (!token || !userId || !username) { + if (!code || typeof code !== 'string') { loading.value = false error.value = true errorTitle.value = '参数错误' - errorMessage.value = '缺少必要的登录信息,请重新登录' + errorMessage.value = '缺少有效的登录 code,请重新登录' return } + const tokenData = await authApi.exchangeOIDCCode(code) + + await router.replace({ path: route.path, query: {} }) + // 更新用户状态 - 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 || '' + userStore.token = tokenData.access_token + userStore.userId = tokenData.user_id + userStore.username = tokenData.username + userStore.userIdLogin = tokenData.user_id_login || '' + userStore.phoneNumber = tokenData.phone_number || '' + userStore.avatar = tokenData.avatar || '' + userStore.userRole = tokenData.role || 'user' + userStore.departmentId = tokenData.department_id || null + userStore.departmentName = tokenData.department_name || '' // 保存 token 到 localStorage - localStorage.setItem('user_token', token) + localStorage.setItem('user_token', tokenData.access_token) // 显示成功消息 message.success('登录成功') @@ -117,19 +113,19 @@ const handleCallback = () => { loading.value = false error.value = true errorTitle.value = '登录失败' - errorMessage.value = err.message || '处理登录请求时发生错误,请重试' + errorMessage.value = err?.message || '处理登录请求时发生错误,请重试' } } // 组件挂载时处理回调 -onMounted(() => { +onMounted(async () => { // 如果已登录,跳转到首页 if (userStore.isLoggedIn) { router.push('/') return } - handleCallback() + await handleCallback() }) From ca016151e5a675b7a66b60f04240542bd5af80a5 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Thu, 2 Apr 2026 12:12:56 +0800 Subject: [PATCH 3/5] Update backend/server/routers/auth_router_oidc.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/server/routers/auth_router_oidc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/server/routers/auth_router_oidc.py b/backend/server/routers/auth_router_oidc.py index e3a1337a7..c297fddb5 100644 --- a/backend/server/routers/auth_router_oidc.py +++ b/backend/server/routers/auth_router_oidc.py @@ -349,14 +349,14 @@ async def oidc_login_url_handler(redirect_path: str = "/"): 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" + detail="OIDC 登录暂不可用,请联系管理员" ) 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" + detail="生成登录链接失败,请稍后重试或联系管理员" ) return {"login_url": login_url} From ffda0777467849d3a3e46dfaede8652892f21866 Mon Sep 17 00:00:00 2001 From: DSYZayn Date: Fri, 3 Apr 2026 23:07:22 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(auth):=20=E5=B0=86oidc=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=8A=9F=E8=83=BD=E6=89=93=E5=8C=85=E5=88=B0service?= =?UTF-8?q?=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package/yuxi/services/oidc_service.py | 676 ++++++++++++++++++ backend/server/routers/auth_router.py | 27 +- backend/server/routers/auth_router_oidc.py | 362 ---------- backend/server/utils/oidc_config.py | 90 --- backend/server/utils/oidc_utils.py | 277 ------- 5 files changed, 700 insertions(+), 732 deletions(-) create mode 100644 backend/package/yuxi/services/oidc_service.py delete mode 100644 backend/server/routers/auth_router_oidc.py delete mode 100644 backend/server/utils/oidc_config.py delete mode 100644 backend/server/utils/oidc_utils.py diff --git a/backend/package/yuxi/services/oidc_service.py b/backend/package/yuxi/services/oidc_service.py new file mode 100644 index 000000000..e1d3d55c2 --- /dev/null +++ b/backend/package/yuxi/services/oidc_service.py @@ -0,0 +1,676 @@ +"""OIDC 服务模块。 + +统一封装 OIDC 配置、工具能力和认证业务处理逻辑 +""" + +import hashlib +import os +import secrets +import time +import urllib.parse +from typing import Any, Optional +from urllib.parse import urlencode + +import httpx +from fastapi import HTTPException, Request, status +from fastapi.responses import RedirectResponse +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from server.utils.auth_utils import AuthUtils +from server.utils.common_utils import log_operation +from yuxi.repositories.user_repository import UserRepository +from yuxi.storage.postgres.models_business import Department, User +from yuxi.utils.datetime_utils import utc_now_naive +from yuxi.utils.logging_config import logger + +# 前端 OIDC 回调路由路径(与 web/src/router/index.js 中的路由保持一致) +FRONTEND_CALLBACK_PATH = "/auth/oidc/callback" +# 登录页路径 +FRONTEND_LOGIN_PATH = "/login" + + +class OIDCConfig(BaseModel): + """OIDC 配置模型""" + + enabled: bool = Field(default=False, description="是否启用 OIDC 认证") + 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") + redirect_uri: str = Field(default="", description="OIDC 回调 URL") + 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="认证源名称,显示在登录按钮上的文字") + 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": + """从环境变量加载配置""" + + def _env(name: str, default: str = "") -> str: + return os.environ.get(name, default).strip() + + enabled = os.environ.get("OIDC_ENABLED", "false").lower() == "true" + + if not enabled: + return cls(enabled=False) + + return cls( + enabled=enabled, + provider_name=_env("OIDC_PROVIDER_NAME", "OIDC登录"), + issuer_url=_env("OIDC_ISSUER_URL"), + client_id=_env("OIDC_CLIENT_ID"), + client_secret=_env("OIDC_CLIENT_SECRET"), + redirect_uri=_env("OIDC_REDIRECT_URI"), + authorization_endpoint=_env("OIDC_AUTHORIZATION_ENDPOINT"), + token_endpoint=_env("OIDC_TOKEN_ENDPOINT"), + userinfo_endpoint=_env("OIDC_USERINFO_ENDPOINT"), + end_session_endpoint=_env("OIDC_END_SESSION_ENDPOINT"), + scopes=_env("OIDC_SCOPES", "openid profile email"), + auto_create_user=os.environ.get("OIDC_AUTO_CREATE_USER", "true").lower() == "true", + default_role=_env("OIDC_DEFAULT_ROLE", "user"), + default_department=_env("OIDC_DEFAULT_DEPARTMENT", "OIDC用户"), + username_claim=_env("OIDC_USERNAME_CLAIM", "preferred_username"), + email_claim=_env("OIDC_EMAIL_CLAIM", "email"), + name_claim=_env("OIDC_NAME_CLAIM", "name"), + ) + + def is_configured(self) -> bool: + """检查登录链接生成所需配置是否完整""" + if not self.enabled: + return False + # 生成登录链接只要求 client_id + (issuer_url 或 authorization_endpoint) + return bool(self.client_id and (self.issuer_url or self.authorization_endpoint)) + + def is_token_exchange_configured(self) -> bool: + """检查授权码换 token 所需配置是否完整""" + if not self.enabled: + return False + # 回调换 token 需要 client_id + client_secret + (issuer_url 或 token_endpoint) + return bool(self.client_id and self.client_secret and (self.issuer_url or self.token_endpoint)) + + +oidc_config = OIDCConfig.from_env() + + +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.last_error: Optional[str] = None + self._loaded = False + + async def load(self, issuer_url: str) -> bool: + """从 discovery 端点加载元数据""" + if self._loaded: + return True + + discovery_url = f"{issuer_url.rstrip('/')}/.well-known/openid-configuration" + try: + 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") + + # 登录 URL 生成至少需要 authorization_endpoint。 + if not self.authorization_endpoint: + self.last_error = "discovery 响应缺少 authorization_endpoint" + logger.error(f"Failed to load OIDC discovery: {self.last_error}, url={discovery_url}") + return False + + self._loaded = True + self.last_error = None + logger.info(f"OIDC discovery loaded from {discovery_url}") + return True + + except Exception as e: + self.last_error = f"{type(e).__name__}: {repr(e)}" + logger.error(f"Failed to load OIDC discovery: {self.last_error}, url={discovery_url}") + return False + + +class OIDCUtils: + """OIDC 工具类""" + + _metadata: Optional[OIDCProviderMetadata] = None + _state_store: dict[str, dict[str, Any]] = {} + _login_code_store: dict[str, dict[str, Any]] = {} + _state_ttl_seconds = 300 + _login_code_ttl_seconds = 60 + _last_metadata_error: Optional[str] = None + + @classmethod + def _cleanup_expired_state(cls) -> None: + now = time.time() + expired = [k for k, v in cls._state_store.items() if v["expires_at"] <= now] + for key in expired: + cls._state_store.pop(key, None) + + @classmethod + def _cleanup_expired_login_code(cls) -> None: + now = time.time() + expired = [k for k, v in cls._login_code_store.items() if v["expires_at"] <= now] + for key in expired: + cls._login_code_store.pop(key, None) + + @classmethod + async def get_metadata(cls) -> Optional[OIDCProviderMetadata]: + """获取 OIDC Provider 元数据""" + if not oidc_config.enabled or not oidc_config.is_configured(): + cls._last_metadata_error = "OIDC 未启用或基础配置不完整" + 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 + cls._last_metadata_error = None + else: + success = await cls._metadata.load(oidc_config.issuer_url) + if not success: + cls._last_metadata_error = cls._metadata.last_error or "OIDC discovery 加载失败" + return None + + if not cls._metadata.authorization_endpoint: + cls._last_metadata_error = "OIDC 授权端点不可用" + return None + + cls._last_metadata_error = None + + return cls._metadata + + @classmethod + def get_last_metadata_error(cls) -> Optional[str]: + """获取最近一次 OIDC 元数据加载错误""" + return cls._last_metadata_error + + @classmethod + def generate_state(cls, redirect_path: str = "/") -> str: + """生成 state 参数并存储""" + cls._cleanup_expired_state() + state = secrets.token_urlsafe(32) + cls._state_store[state] = { + "redirect_path": redirect_path, + "expires_at": time.time() + cls._state_ttl_seconds, + } + return state + + @classmethod + def verify_state(cls, state: str) -> Optional[dict[str, Any]]: + """验证 state 参数""" + state_data = cls._state_store.pop(state, None) + if not state_data: + return None + if state_data["expires_at"] <= time.time(): + return None + return {"redirect_path": state_data["redirect_path"]} + + @classmethod + def generate_login_code(cls, payload: dict[str, Any]) -> str: + """生成一次性短期登录 code""" + cls._cleanup_expired_login_code() + code = secrets.token_urlsafe(32) + cls._login_code_store[code] = { + "payload": payload, + "expires_at": time.time() + cls._login_code_ttl_seconds, + } + return code + + @classmethod + def consume_login_code(cls, code: str) -> Optional[dict[str, Any]]: + """消费一次性短期登录 code""" + data = cls._login_code_store.pop(code, None) + if not data: + return None + if data["expires_at"] <= time.time(): + return None + return data["payload"] + + @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 = oidc_config.redirect_uri + if not redirect_uri: + 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 = 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] + + 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, + } + + +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: + dept = Department( + name=dept_name, + description=f"{dept_name}部门", + ) + db.add(dept) + try: + await db.commit() + await db.refresh(dept) + logger.info(f"Created OIDC department: {dept_name}") + except IntegrityError: + await db.rollback() + result = await db.execute(select(Department).filter(Department.name == dept_name)) + dept = result.scalar_one_or_none() + + return dept + + +async def find_user_by_oidc_sub(db, sub: str) -> User | None: + """通过 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)) + user = result.scalar_one_or_none() + if user: + return user + + legacy_result = await db.execute( + select(User) + .filter(User.user_id.like(f"{oidc_user_id}:%"), User.is_deleted == 0) + .order_by(User.id.asc()) + ) + legacy_users = list(legacy_result.scalars().all()) + if legacy_users: + if len(legacy_users) > 1: + logger.warning(f"Multiple legacy OIDC users matched for sub={sub}, use earliest id={legacy_users[0].id}") + return legacy_users[0] + + return None + + +async def find_deleted_oidc_user_by_sub(db, sub: str) -> User | None: + """查找已注销的 OIDC 账户(标准与历史后缀)""" + oidc_user_id = f"oidc:{sub}" + + result = await db.execute(select(User).filter(User.user_id == oidc_user_id, User.is_deleted == 1)) + deleted_user = result.scalar_one_or_none() + if deleted_user: + return deleted_user + + legacy_result = await db.execute( + select(User) + .filter(User.user_id.like(f"{oidc_user_id}:%"), User.is_deleted == 1) + .order_by(User.id.asc()) + ) + return legacy_result.scalar_one_or_none() + + +async def build_unique_oidc_username(db, preferred_username: str, sub: str) -> str: + """为 OIDC 用户生成不冲突的用户名""" + base_username = preferred_username.strip() if preferred_username else "" + if not base_username: + base_username = f"oidc_{sub[:8]}" + + result = await db.execute(select(User.id).filter(User.username == base_username)) + if result.scalar_one_or_none() is None: + return base_username + + hash_suffix = hashlib.sha256(sub.encode()).hexdigest()[:6] + candidate = f"{base_username}-{hash_suffix}" + result = await db.execute(select(User.id).filter(User.username == candidate)) + if result.scalar_one_or_none() is None: + return candidate + + for i in range(2, 100): + indexed_candidate = f"{candidate}-{i}" + result = await db.execute(select(User.id).filter(User.username == indexed_candidate)) + if result.scalar_one_or_none() is None: + return indexed_candidate + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="无法生成可用用户名,请联系管理员", + ) + + +async def create_oidc_user(db, user_info: dict, department_id: int | None = None) -> User: + """创建 OIDC 用户""" + user_repo = UserRepository() + + sub = user_info["sub"] + preferred_username = user_info["name"] or user_info["username"] + user_id = f"oidc:{sub}" + + random_password = secrets.token_urlsafe(32) + password_hash = AuthUtils.hash_password(random_password) + + username = await build_unique_oidc_username(db, preferred_username, sub) + + for retry_index in range(3): + try: + new_user = await user_repo.create( + { + "username": username, + "user_id": user_id, + "phone_number": None, + "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: {new_user.username} ({user_id})") + return new_user + except IntegrityError: + existing_user = await find_user_by_oidc_sub(db, sub) + if existing_user: + return existing_user + username = await build_unique_oidc_username(db, f"{preferred_username}-{retry_index + 2}", sub) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="创建 OIDC 用户失败,请重试", + ) + + +async def restore_deleted_oidc_user(db, deleted_user: User, user_info: dict) -> User: + """恢复已注销的 OIDC 用户并返回可登录用户""" + preferred_username = user_info["name"] or user_info["username"] + + deleted_user.is_deleted = 0 + deleted_user.deleted_at = None + deleted_user.last_login = utc_now_naive() + deleted_user.phone_number = None + deleted_user.avatar = None + + if deleted_user.username.startswith("已注销用户-"): + deleted_user.username = await build_unique_oidc_username(db, preferred_username, user_info["sub"]) + + if deleted_user.password_hash == "DELETED": + random_password = secrets.token_urlsafe(32) + deleted_user.password_hash = AuthUtils.hash_password(random_password) + + await db.commit() + await db.refresh(deleted_user) + logger.info(f"Restored deleted OIDC user: {deleted_user.username} ({deleted_user.user_id})") + return deleted_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(exchange_code: str) -> RedirectResponse: + """成功后重定向到前端 OIDC 回调页面,仅携带一次性 code""" + url = f"{FRONTEND_CALLBACK_PATH}?{urlencode({'code': exchange_code})}" + 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) + + +async def get_oidc_config_handler(): + """获取 OIDC 配置(供前端使用)""" + if not oidc_config.enabled or not oidc_config.is_configured(): + return {"enabled": False} + + provider_name = oidc_config.provider_name + return {"enabled": True, "provider_name": provider_name} + + +async def oidc_callback_handler(code: str, state: str, db, request: Request | None = None): + """处理 OIDC 回调 - 重定向到前端 Vue 路由""" + + if not oidc_config.is_token_exchange_configured(): + return _redirect_to_login_with_error("OIDC 配置不完整,请联系管理员") + + if not OIDCUtils.verify_state(state): + 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: + deleted_user = await find_deleted_oidc_user_by_sub(db, sub) + if deleted_user: + user = await restore_deleted_oidc_user(db, deleted_user, extracted_info) + logger.info(f"OIDC deleted user restored and logged in: {user.username}") + else: + 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 登录", request=request) + + 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, + } + + exchange_code = OIDCUtils.generate_login_code(response_data) + return _redirect_to_callback(exchange_code) + + +async def oidc_exchange_code_handler(code: str) -> dict: + """用一次性 code 交换登录响应数据""" + token_data = OIDCUtils.consume_login_code(code) + if not token_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="登录 code 无效或已过期,请重新登录", + ) + return token_data + + +async def oidc_login_url_handler(redirect_path: str = "/"): + """获取 OIDC 登录 URL""" + if not oidc_config.enabled or not oidc_config.is_configured(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="OIDC 登录暂不可用,请联系管理员", + ) + + login_url = await OIDCUtils.build_authorization_url(redirect_path) + if not login_url: + metadata_error = OIDCUtils.get_last_metadata_error() + if metadata_error: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"生成登录链接失败:{metadata_error}", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="生成登录链接失败,请稍后重试或联系管理员", + ) + + return {"login_url": login_url} diff --git a/backend/server/routers/auth_router.py b/backend/server/routers/auth_router.py index 55a4539bd..c643ce65e 100644 --- a/backend/server/routers/auth_router.py +++ b/backend/server/routers/auth_router.py @@ -27,13 +27,11 @@ from yuxi.utils.datetime_utils import utc_now_naive # OIDC 认证相关导入 -from server.routers.auth_router_oidc import ( +from yuxi.services.oidc_service import ( get_oidc_config_handler, oidc_callback_handler, oidc_exchange_code_handler, oidc_login_url_handler, - OIDCConfigResponse, - OIDCLoginResponse, ) # 创建路由器 @@ -105,6 +103,29 @@ class UserIdGeneration(BaseModel): is_available: bool +class OIDCConfigResponse(BaseModel): + """OIDC 配置响应""" + + enabled: bool + login_url: str | None = None + provider_name: str | None = "OIDC登录" + + +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 + + # ============================================================================= # === 工具函数 === # ============================================================================= diff --git a/backend/server/routers/auth_router_oidc.py b/backend/server/routers/auth_router_oidc.py deleted file mode 100644 index c297fddb5..000000000 --- a/backend/server/routers/auth_router_oidc.py +++ /dev/null @@ -1,362 +0,0 @@ -"""OIDC 认证路由模块 - -此模块包含 OIDC 认证相关的路由,需要被导入到主 auth_router.py 中使用。 -""" -from urllib.parse import urlencode -import hashlib -from fastapi import HTTPException, Request, status -from fastapi.responses import RedirectResponse -from pydantic import BaseModel -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError -from yuxi.utils import logger -from yuxi.storage.postgres.models_business import User, Department -from yuxi.repositories.user_repository import UserRepository -from server.utils.auth_utils import AuthUtils -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 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: - dept = Department( - name=dept_name, - description=f"{dept_name}部门", - ) - db.add(dept) - try: - await db.commit() - await db.refresh(dept) - logger.info(f"Created OIDC department: {dept_name}") - except IntegrityError: - await db.rollback() - result = await db.execute(select(Department).filter(Department.name == dept_name)) - dept = result.scalar_one_or_none() - - return dept - - -async def find_user_by_oidc_sub(db, sub: str) -> User | None: - """通过 OIDC sub 查找用户""" - oidc_user_id = f"oidc:{sub}" - - # 优先匹配标准 user_id(oidc:{sub}) - result = await db.execute(select(User).filter(User.user_id == oidc_user_id, User.is_deleted == 0)) - user = result.scalar_one_or_none() - if user: - return user - - # 兼容历史后缀 user_id(oidc:{sub}:xxxx) - legacy_result = await db.execute( - select(User) - .filter(User.user_id.like(f"{oidc_user_id}:%"), User.is_deleted == 0) - .order_by(User.id.asc()) - ) - legacy_users = list(legacy_result.scalars().all()) - if legacy_users: - if len(legacy_users) > 1: - logger.warning(f"Multiple legacy OIDC users matched for sub={sub}, use earliest id={legacy_users[0].id}") - return legacy_users[0] - - return None - - -async def find_deleted_oidc_user_by_sub(db, sub: str) -> User | None: - """查找已注销的 OIDC 账户(标准与历史后缀)""" - oidc_user_id = f"oidc:{sub}" - - result = await db.execute(select(User).filter(User.user_id == oidc_user_id, User.is_deleted == 1)) - deleted_user = result.scalar_one_or_none() - if deleted_user: - return deleted_user - - legacy_result = await db.execute( - select(User) - .filter(User.user_id.like(f"{oidc_user_id}:%"), User.is_deleted == 1) - .order_by(User.id.asc()) - ) - return legacy_result.scalar_one_or_none() - - -async def build_unique_oidc_username(db, preferred_username: str, sub: str) -> str: - """为 OIDC 用户生成不冲突的用户名""" - base_username = preferred_username.strip() if preferred_username else "" - if not base_username: - base_username = f"oidc_{sub[:8]}" - - result = await db.execute(select(User.id).filter(User.username == base_username)) - if result.scalar_one_or_none() is None: - return base_username - - hash_suffix = hashlib.sha256(sub.encode()).hexdigest()[:6] - candidate = f"{base_username}-{hash_suffix}" - result = await db.execute(select(User.id).filter(User.username == candidate)) - if result.scalar_one_or_none() is None: - return candidate - - for i in range(2, 100): - indexed_candidate = f"{candidate}-{i}" - result = await db.execute(select(User.id).filter(User.username == indexed_candidate)) - if result.scalar_one_or_none() is None: - return indexed_candidate - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="无法生成可用用户名,请联系管理员", - ) - - -async def create_oidc_user(db, user_info: dict, department_id: int | None = None) -> User: - """创建 OIDC 用户""" - user_repo = UserRepository() - - sub = user_info["sub"] - preferred_username = user_info["name"] or user_info["username"] - user_id = f"oidc:{sub}" - - # 生成随机密码(OIDC 用户不需要密码登录) - import secrets - random_password = secrets.token_urlsafe(32) - password_hash = AuthUtils.hash_password(random_password) - - username = await build_unique_oidc_username(db, preferred_username, sub) - - # 并发场景下兜底:若创建时发生唯一键冲突,优先复用已创建账号;否则重试用户名。 - for retry_index in range(3): - try: - 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: {new_user.username} ({user_id})") - return new_user - except IntegrityError: - existing_user = await find_user_by_oidc_sub(db, sub) - if existing_user: - return existing_user - username = await build_unique_oidc_username(db, f"{preferred_username}-{retry_index + 2}", sub) - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="创建 OIDC 用户失败,请重试", - ) - - -async def restore_deleted_oidc_user(db, deleted_user: User, user_info: dict) -> User: - """恢复已注销的 OIDC 用户并返回可登录用户""" - preferred_username = user_info["name"] or user_info["username"] - - deleted_user.is_deleted = 0 - deleted_user.deleted_at = None - deleted_user.last_login = utc_now_naive() - deleted_user.phone_number = None - deleted_user.avatar = None - - # 删除流程会把用户名改成“已注销用户-xxxx”,恢复时重新分配可用用户名 - if deleted_user.username.startswith("已注销用户-"): - deleted_user.username = await build_unique_oidc_username(db, preferred_username, user_info["sub"]) - - if deleted_user.password_hash == "DELETED": - import secrets - random_password = secrets.token_urlsafe(32) - deleted_user.password_hash = AuthUtils.hash_password(random_password) - - await db.commit() - await db.refresh(deleted_user) - logger.info(f"Restored deleted OIDC user: {deleted_user.username} ({deleted_user.user_id})") - return deleted_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(exchange_code: str) -> RedirectResponse: - """成功后重定向到前端 OIDC 回调页面,仅携带一次性 code""" - url = f"{FRONTEND_CALLBACK_PATH}?{urlencode({'code': exchange_code})}" - 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) - - provider_name = oidc_config.provider_name - return OIDCConfigResponse(enabled=True, provider_name=provider_name) - - -async def oidc_callback_handler(code: str, state: str, db, request: Request | None = None): - """处理 OIDC 回调 - 重定向到前端 Vue 路由""" - - # 验证 state - if not OIDCUtils.verify_state(state): - 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: - deleted_user = await find_deleted_oidc_user_by_sub(db, sub) - if deleted_user: - user = await restore_deleted_oidc_user(db, deleted_user, extracted_info) - logger.info(f"OIDC deleted user restored and logged in: {user.username}") - else: - # 获取或创建 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 登录", request=request) - - # 获取部门名称 - 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, - } - - exchange_code = OIDCUtils.generate_login_code(response_data) - - # 重定向到前端 OIDC 回调 Vue 页面 - return _redirect_to_callback(exchange_code) - - -async def oidc_exchange_code_handler(code: str) -> dict: - """用一次性 code 交换登录响应数据""" - token_data = OIDCUtils.consume_login_code(code) - if not token_data: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="登录 code 无效或已过期,请重新登录", - ) - return token_data - - -async def oidc_login_url_handler(redirect_path: str = "/"): - """获取 OIDC 登录 URL""" - if not oidc_config.enabled or not oidc_config.is_configured(): - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="OIDC 登录暂不可用,请联系管理员" - ) - - login_url = await OIDCUtils.build_authorization_url(redirect_path) - if not login_url: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="生成登录链接失败,请稍后重试或联系管理员" - ) - - return {"login_url": login_url} diff --git a/backend/server/utils/oidc_config.py b/backend/server/utils/oidc_config.py deleted file mode 100644 index a979d3389..000000000 --- a/backend/server/utils/oidc_config.py +++ /dev/null @@ -1,90 +0,0 @@ -"""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() diff --git a/backend/server/utils/oidc_utils.py b/backend/server/utils/oidc_utils.py deleted file mode 100644 index 9f84ce191..000000000 --- a/backend/server/utils/oidc_utils.py +++ /dev/null @@ -1,277 +0,0 @@ -"""OIDC 认证工具类""" -import secrets -import time -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]] = {} - _login_code_store: dict[str, dict[str, Any]] = {} - _state_ttl_seconds = 300 - _login_code_ttl_seconds = 60 - - @classmethod - def _cleanup_expired_state(cls) -> None: - now = time.time() - expired = [k for k, v in cls._state_store.items() if v["expires_at"] <= now] - for key in expired: - cls._state_store.pop(key, None) - - @classmethod - def _cleanup_expired_login_code(cls) -> None: - now = time.time() - expired = [k for k, v in cls._login_code_store.items() if v["expires_at"] <= now] - for key in expired: - cls._login_code_store.pop(key, None) - - @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 参数并存储""" - cls._cleanup_expired_state() - state = secrets.token_urlsafe(32) - cls._state_store[state] = { - "redirect_path": redirect_path, - "expires_at": time.time() + cls._state_ttl_seconds, - } - return state - - @classmethod - def verify_state(cls, state: str) -> Optional[dict[str, Any]]: - """验证 state 参数""" - state_data = cls._state_store.pop(state, None) - if not state_data: - return None - if state_data["expires_at"] <= time.time(): - return None - return {"redirect_path": state_data["redirect_path"]} - - @classmethod - def generate_login_code(cls, payload: dict[str, Any]) -> str: - """生成一次性短期登录 code""" - cls._cleanup_expired_login_code() - code = secrets.token_urlsafe(32) - cls._login_code_store[code] = { - "payload": payload, - "expires_at": time.time() + cls._login_code_ttl_seconds, - } - return code - - @classmethod - def consume_login_code(cls, code: str) -> Optional[dict[str, Any]]: - """消费一次性短期登录 code""" - data = cls._login_code_store.pop(code, None) - if not data: - return None - if data["expires_at"] <= time.time(): - return None - return data["payload"] - - @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, - } From 351a89e205b26280e5134ed136a1908dd91455e1 Mon Sep 17 00:00:00 2001 From: DSYZayn Date: Fri, 3 Apr 2026 23:18:00 +0800 Subject: [PATCH 5/5] feat(docs): update docs about oidc login --- .env.template | 57 +------------------------------ docs/advanced/third-party-auth.md | 12 ++++--- 2 files changed, 8 insertions(+), 61 deletions(-) diff --git a/.env.template b/.env.template index 135625d9b..9923d48ed 100644 --- a/.env.template +++ b/.env.template @@ -68,59 +68,4 @@ TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.co # SANDBOX_NODE_HOST=host.docker.internal # KUBECONFIG_PATH=/root/.kube/config # 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 +# SKILLS_PVC=yuxi-skills # 当前代码会读取,但 Pod 挂载实际仍只使用 THREAD_PVC \ No newline at end of file diff --git a/docs/advanced/third-party-auth.md b/docs/advanced/third-party-auth.md index fd1786466..98af430af 100644 --- a/docs/advanced/third-party-auth.md +++ b/docs/advanced/third-party-auth.md @@ -1,19 +1,20 @@ # 第三方登录认证 Yuxi 支持以OIDC接入第三方登录认证,方便企业用户集成现有的身份认证系统。 +> 此功能默认关闭,需要在配置文件中启用并提供相关参数。 ## 配置步骤 -1. 前提条件: +### 1. 前提条件 在你的SSO系统中注册一个新的客户端应用,获取以下信息: - 客户端ID(Client ID) - 客户端密钥(Client Secret) - ISSUER URL -回调地址(Redirect URI):/api/auth/oidc/callback +填入回调地址(Redirect URI):https:///api/auth/oidc/callback -2. 配置Yuxi: +### 2. 配置Yuxi 在Yuxi的.env文件中添加以下配置项: -```env +```sh # 是否启用 OIDC 认证 (true/false) # OIDC_ENABLED=false @@ -30,6 +31,7 @@ Yuxi 支持以OIDC接入第三方登录认证,方便企业用户集成现有 # OIDC_CLIENT_SECRET= # OIDC 回调 URL (可选,默认自动构建为 /api/auth/oidc/callback, 不建议自定义) +# 填写完整的地址:https:///api/auth/oidc/callback # 需要确保此 URL 在 OIDC Provider 中已注册 # OIDC_REDIRECT_URI= @@ -67,7 +69,7 @@ Yuxi 支持以OIDC接入第三方登录认证,方便企业用户集成现有 # OIDC_NAME_CLAIM=name ``` -3. 重启Yuxi服务使配置生效 +### 3. 重启Yuxi服务使配置生效 ```bash docker restart api-dev web-dev ``` \ No newline at end of file