2026-04-01 00:42:25 +08:00
|
|
|
|
"""OIDC 认证路由模块
|
|
|
|
|
|
|
|
|
|
|
|
此模块包含 OIDC 认证相关的路由,需要被导入到主 auth_router.py 中使用。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from urllib.parse import urlencode
|
2026-04-01 22:10:24 +08:00
|
|
|
|
import hashlib
|
|
|
|
|
|
from fastapi import HTTPException, Request, status
|
2026-04-01 00:42:25 +08:00
|
|
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
from sqlalchemy import select
|
2026-04-01 22:10:24 +08:00
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
2026-04-01 00:42:25 +08:00
|
|
|
|
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:
|
2026-04-01 22:10:24 +08:00
|
|
|
|
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()
|
2026-04-01 00:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
return dept
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def find_user_by_oidc_sub(db, sub: str) -> User | None:
|
|
|
|
|
|
"""通过 OIDC sub 查找用户"""
|
|
|
|
|
|
oidc_user_id = f"oidc:{sub}"
|
2026-04-01 22:10:24 +08:00
|
|
|
|
|
|
|
|
|
|
# 优先匹配标准 user_id(oidc:{sub})
|
2026-04-01 00:42:25 +08:00
|
|
|
|
result = await db.execute(select(User).filter(User.user_id == oidc_user_id, User.is_deleted == 0))
|
2026-04-01 22:10:24 +08:00
|
|
|
|
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="无法生成可用用户名,请联系管理员",
|
|
|
|
|
|
)
|
2026-04-01 00:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create_oidc_user(db, user_info: dict, department_id: int | None = None) -> User:
|
|
|
|
|
|
"""创建 OIDC 用户"""
|
|
|
|
|
|
user_repo = UserRepository()
|
|
|
|
|
|
|
|
|
|
|
|
sub = user_info["sub"]
|
2026-04-01 22:10:24 +08:00
|
|
|
|
preferred_username = user_info["name"] or user_info["username"]
|
2026-04-01 00:42:25 +08:00
|
|
|
|
user_id = f"oidc:{sub}"
|
|
|
|
|
|
|
|
|
|
|
|
# 生成随机密码(OIDC 用户不需要密码登录)
|
|
|
|
|
|
import secrets
|
|
|
|
|
|
random_password = secrets.token_urlsafe(32)
|
|
|
|
|
|
password_hash = AuthUtils.hash_password(random_password)
|
|
|
|
|
|
|
2026-04-01 22:10:24 +08:00
|
|
|
|
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)
|
2026-04-01 00:42:25 +08:00
|
|
|
|
|
2026-04-01 22:10:24 +08:00
|
|
|
|
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
|
2026-04-01 00:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def update_oidc_user_login(db, user: User) -> None:
|
|
|
|
|
|
"""更新 OIDC 用户登录时间"""
|
|
|
|
|
|
user.last_login = utc_now_naive()
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 22:10:24 +08:00
|
|
|
|
def _redirect_to_callback(exchange_code: str) -> RedirectResponse:
|
|
|
|
|
|
"""成功后重定向到前端 OIDC 回调页面,仅携带一次性 code"""
|
|
|
|
|
|
url = f"{FRONTEND_CALLBACK_PATH}?{urlencode({'code': exchange_code})}"
|
2026-04-01 00:42:25 +08:00
|
|
|
|
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
|
2026-04-01 22:10:24 +08:00
|
|
|
|
return OIDCConfigResponse(enabled=True, provider_name=provider_name)
|
2026-04-01 00:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 22:10:24 +08:00
|
|
|
|
async def oidc_callback_handler(code: str, state: str, db, request: Request | None = None):
|
2026-04-01 00:42:25 +08:00
|
|
|
|
"""处理 OIDC 回调 - 重定向到前端 Vue 路由"""
|
|
|
|
|
|
|
|
|
|
|
|
# 验证 state
|
2026-04-01 22:10:24 +08:00
|
|
|
|
if not OIDCUtils.verify_state(state):
|
2026-04-01 00:42:25 +08:00
|
|
|
|
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:
|
2026-04-01 22:10:24 +08:00
|
|
|
|
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)
|
2026-04-01 00:42:25 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
# 记录登录操作
|
2026-04-01 22:10:24 +08:00
|
|
|
|
await log_operation(db, user.id, "OIDC 登录", request=request)
|
2026-04-01 00:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
# 获取部门名称
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 22:10:24 +08:00
|
|
|
|
exchange_code = OIDCUtils.generate_login_code(response_data)
|
|
|
|
|
|
|
2026-04-01 00:42:25 +08:00
|
|
|
|
# 重定向到前端 OIDC 回调 Vue 页面
|
2026-04-01 22:10:24 +08:00
|
|
|
|
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
|
2026-04-01 00:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-02 12:12:56 +08:00
|
|
|
|
detail="OIDC 登录暂不可用,请联系管理员"
|
2026-04-01 00:42:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
login_url = await OIDCUtils.build_authorization_url(redirect_path)
|
|
|
|
|
|
if not login_url:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
2026-04-02 12:12:56 +08:00
|
|
|
|
detail="生成登录链接失败,请稍后重试或联系管理员"
|
2026-04-01 00:42:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"login_url": login_url}
|