From 01705aa66b65a7a1ee5dd54d4a77f7331e1a623c Mon Sep 17 00:00:00 2001 From: DSYZayn Date: Wed, 1 Apr 2026 22:10:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8DOIDC=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=E7=99=BB=E5=BD=95=E8=AE=A4=E8=AF=81=E9=9B=86?= =?UTF-8?q?=E6=88=90=E7=9A=84=E6=BD=9C=E5=9C=A8=E9=A3=8E=E9=99=A9=20-=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0OIDC=E7=94=A8=E6=88=B7=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=92=8C=E9=83=A8=E9=97=A8=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E4=B8=8B=E7=9A=84=E7=94=A8=E6=88=B7=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E5=A4=84=E7=90=86=20-=20=E9=87=8D=E6=9E=84OIDC?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E6=B5=81=E7=A8=8B=EF=BC=8C=E9=87=87=E7=94=A8?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=80=A7code=E6=9C=BA=E5=88=B6=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E5=AE=89=E5=85=A8=E6=80=A7=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E6=95=8F=E6=84=9F=E4=BF=A1=E6=81=AF=E9=80=9A=E8=BF=87URL?= =?UTF-8?q?=E4=BC=A0=E9=80=92=20-=20=E5=A2=9E=E5=8A=A0=E5=AF=B9=E5=B7=B2?= =?UTF-8?q?=E6=B3=A8=E9=94=80OIDC=E7=94=A8=E6=88=B7=E7=9A=84=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=94=AF=E6=8C=81=EF=BC=8C=E5=85=BC=E5=AE=B9=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=90=8E=E7=BC=80=E7=94=A8=E6=88=B7ID=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=20-=20=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E8=BD=AF?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BD=BF=E7=94=A8?= =?UTF-8?q?user=5Fid=E5=92=8Cid=E7=BB=84=E5=90=88=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=93=88=E5=B8=8C=E9=81=BF=E5=85=8D=E9=87=8D=E5=90=8D=E5=86=B2?= =?UTF-8?q?=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 | 238 ++++++++++++++------- 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, 275 insertions(+), 140 deletions(-) diff --git a/backend/server/routers/auth_router.py b/backend/server/routers/auth_router.py index 67afd156..55a4539b 100644 --- a/backend/server/routers/auth_router.py +++ b/backend/server/routers/auth_router.py @@ -2,7 +2,7 @@ import re 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 yuxi.utils.datetime_utils import utc_now_naive 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 7a8f449e..e3a1337a 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) - logger.info(f"Created OIDC user: {username} ({user_id})") - return new_user + # 并发场景下兜底:若创建时发生唯一键冲突,优先复用已创建账号;否则重试用户名。 + 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: @@ -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 + 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) + # 创建新用户 + 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 1254b6ea..9f84ce19 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 @@ class OIDCUtils: @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 963875d7..63b109c0 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 f84bf2da..1bbb3a83 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 9d3fd0d6..09b2b7b0 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() })