fix(auth): 修复OIDC第三方登录认证集成的潜在风险
- 实现OIDC用户自动创建和部门管理逻辑,支持并发场景下的用户去重处理 - 重构OIDC回调流程,采用一次性code机制提升安全性,避免敏感信息通过URL传递 - 增加对已注销OIDC用户的恢复支持,兼容历史后缀用户ID格式 - 优化用户软删除逻辑,使用user_id和id组合生成哈希避免重名冲突
This commit is contained in:
parent
7f042ffb76
commit
01705aa66b
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
- 新增面向用户的 Langfuse 集成文档:在“智能体开发”分组中说明 Langfuse 的定位、能力、配置方式与查看路径,并与当前 `LANGFUSE_BASE_URL` 配置保持一致
|
||||
|
||||
<!-- 添加到这里 -->
|
||||
|
||||
- 新增第三方登录认证集成,支持以OIDC方式接入
|
||||
### 修复
|
||||
|
||||
- 优化 Agent 输入框 mention 行为:在保留附件 mention 的同时,将共享 `workspace` 文件纳入候选范围;并将 `@` 空查询时的候选列表改为空,仅在继续输入后再执行筛选,避免工作区文件过多时直接铺满下拉面板
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user