Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

902 lines
36 KiB
Python

"""OIDC 服务模块。
统一封装 OIDC 配置、工具能力和认证业务处理逻辑
"""
import hashlib
import os
import secrets
import time
import urllib.parse
from typing import Any
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 yuxi.repositories.user_repository import UserRepository
from yuxi.services.operation_log_service import log_operation
from yuxi.storage.postgres.models_business import Department, User
from yuxi.utils.auth_utils import AuthUtils
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="姓名映射字段")
use_raw_username: bool = Field(default=False, description="是否使用原始用户名(不带oidc前缀)")
fetch_department_info: bool = Field(default=False, description="是否从OIDC中获取部门信息")
department_claim: str = Field(default="department", description="部门信息映射字段")
force_prompt_login: bool = Field(default=False, description="是否强制用户重新登录(添加prompt=login参数)")
@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"),
use_raw_username=os.environ.get("OIDC_USE_RAW_USERNAME", "false").lower() == "true",
fetch_department_info=os.environ.get("OIDC_FETCH_DEPARTMENT_INFO", "false").lower() == "true",
department_claim=_env("OIDC_DEPARTMENT_CLAIM", "department"),
force_prompt_login=os.environ.get("OIDC_FORCE_PROMPT_LOGIN", "true").lower() == "true",
)
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: str | None = None
self.token_endpoint: str | None = None
self.userinfo_endpoint: str | None = None
self.end_session_endpoint: str | None = None
self.last_error: str | None = 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: OIDCProviderMetadata | None = 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: str | None = 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) -> OIDCProviderMetadata | None:
"""获取 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) -> str | None:
"""获取最近一次 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) -> dict[str, Any] | None:
"""验证 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) -> dict[str, Any] | None:
"""消费一次性短期登录 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 = "/") -> str | None:
"""构建授权 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,
}
# 如果配置强制登录,添加 prompt=login 参数
if oidc_config.force_prompt_login:
params["prompt"] = "login"
query_string = urllib.parse.urlencode(params)
return f"{metadata.authorization_endpoint}?{query_string}"
@classmethod
async def exchange_code_for_token(cls, code: str) -> dict[str, Any] | None:
"""用授权码交换令牌"""
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) -> dict[str, Any] | None:
"""获取用户信息"""
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: str | None = None) -> str | None:
"""构建登出 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
department_name = None
department_description = None
if oidc_config.fetch_department_info:
department_name = userinfo.get(oidc_config.department_claim)
if not department_name:
department_name = userinfo.get("department")
# 获取部门描述
department_description = userinfo.get("department_description")
if not department_description:
department_description = userinfo.get("department_desc")
return {
"sub": sub,
"username": username,
"email": email,
"name": name,
"department_name": department_name,
"department_description": department_description,
"raw": userinfo,
}
async def get_or_create_oidc_department(
db,
dept_name_from_oidc: str | None = None,
dept_desc_from_oidc: str | None = None,
) -> Department | None:
"""获取或创建 OIDC 用户的部门"""
# 清理并验证从 OIDC 获取的部门名称
processed_dept_name = None
processed_dept_desc = None
if dept_name_from_oidc:
# 去除首尾空格
processed_dept_name = dept_name_from_oidc.strip()
# 截断到 50 字符(匹配数据库限制)
if len(processed_dept_name) > 50:
processed_dept_name = processed_dept_name[:50]
# 如果处理后为空,放弃使用
if not processed_dept_name:
processed_dept_name = None
# 清理并验证从 OIDC 获取的部门描述
if dept_desc_from_oidc:
processed_dept_desc = dept_desc_from_oidc.strip()
# 截断到 255 字符(匹配数据库限制)
if len(processed_dept_desc) > 255:
processed_dept_desc = processed_dept_desc[:255]
if not processed_dept_desc:
processed_dept_desc = None
# 最终确定部门名称:优先使用处理后的OIDC部门名称,否则使用默认部门名称
final_dept_name = processed_dept_name or oidc_config.default_department
# 最终确定部门描述:优先使用处理后的OIDC部门描述,否则使用默认描述
final_dept_desc = processed_dept_desc or f"{final_dept_name}部门"
result = await db.execute(select(Department).filter(Department.name == final_dept_name))
dept = result.scalar_one_or_none()
if dept:
# 部门已存在,直接返回
logger.info(f"Using existing department: {final_dept_name}")
return dept
# 部门不存在,创建新部门
dept = Department(
name=final_dept_name,
description=final_dept_desc,
)
db.add(dept)
try:
await db.commit()
await db.refresh(dept)
logger.info(f"Created OIDC department: {final_dept_name}")
except IntegrityError:
# 并发创建时部门可能已存在,再次查询
await db.rollback()
result = await db.execute(select(Department).filter(Department.name == final_dept_name))
dept = result.scalar_one_or_none()
return dept
async def find_user_by_oidc_sub(db, sub: str) -> User | None:
"""通过 OIDC sub 查找用户"""
# 方法1: 检查是否有用户的 uid 直接等于 "oidc:{sub}"(标准 OIDC 用户)
standard_oidc_uid = f"oidc:{sub}"
# 占位绑定记录会被标记为 is_deleted=1,但我们仍需要查询它们来获取绑定关系
result = await db.execute(select(User).filter(User.uid == standard_oidc_uid, User.is_deleted == 0))
user = result.scalar_one_or_none()
if user:
return user
# 绑定占位用户被标记为 is_deleted=1,需要包括deleted来查询
binding_result = await db.execute(
select(User)
.filter(User.uid.like(f"{standard_oidc_uid}:%"), User.is_deleted.in_([0, 1]))
.order_by(User.id.asc())
)
binding_users = list(binding_result.scalars().all())
if binding_users:
for placeholder in binding_users:
target_user_id = _extract_oidc_placeholder_target_user_id(placeholder.uid)
if target_user_id is None:
continue
result = await db.execute(select(User).filter(User.id == target_user_id, User.is_deleted == 0))
target_user = result.scalar_one_or_none()
if target_user:
logger.debug(f"Resolved OIDC binding placeholder {placeholder.uid} to user {target_user_id}")
return target_user
return None
async def find_deleted_oidc_user_by_sub(db, sub: str) -> User | None:
"""查找已注销的 OIDC 账户(标准与历史后缀)"""
oidc_uid = f"oidc:{sub}"
result = await db.execute(select(User).filter(User.uid == oidc_uid, User.is_deleted == 1))
deleted_user = result.scalar_one_or_none()
if deleted_user:
return deleted_user
binding_result = await db.execute(
select(User).filter(User.uid.like(f"{oidc_uid}:%"), User.is_deleted == 1).order_by(User.id.asc())
)
binding_users = list(binding_result.scalars().all())
if binding_users:
for placeholder in binding_users:
target_user_id = _extract_oidc_placeholder_target_user_id(placeholder.uid)
if target_user_id is None:
continue
result = await db.execute(select(User).filter(User.id == target_user_id, User.is_deleted == 1))
target_user = result.scalar_one_or_none()
if target_user:
return target_user
return None
def _extract_oidc_placeholder_target_user_id(uid: str) -> int | None:
"""从占位 uid 中解析真实用户 ID,允许 sub 中包含冒号。"""
value = str(uid or "").strip()
if not value.startswith("oidc:"):
return None
# 占位格式始终以 `:{target_user_id}` 结尾,因此从右侧拆分即可避免 sub 中的冒号干扰。
try:
_prefix, target_user_id = value.rsplit(":", 1)
return int(target_user_id)
except ValueError:
return None
async def _create_oidc_binding_placeholder(db, sub: str, target_user: User) -> None:
"""创建 OIDC sub 绑定占位用户(仅用于记录绑定关系,不用于登录)
在 use_raw_username 模式下,我们创建一个占位用户格式: oidc:{sub}:{target_user_id},
占位用户标记为 is_deleted=1(不参与实际登录),仅用于存储绑定关系,
find_user_by_oidc_sub 查询时会读取该占位记录并解析出绑定的真实用户,
这样就能在不修改User表结构的前提下,保持绑定关系可验证,防止账号冒用。
使用传入的同一个 db session,避免跨session一致性问题。
"""
# 占位用户格式: oidc:{sub}:{target_user_id},这样find_user_by_oidc_sub可以解析出目标用户ID
oidc_placeholder_id = f"oidc:{sub}:{target_user.id}"
# 占位用户标记为 deleted,查询时需要特别包括deleted才能找到
result = await db.execute(select(User).filter(User.uid == oidc_placeholder_id, User.is_deleted.in_([0, 1])))
if result.scalar_one_or_none():
# 占位用户已存在,无需重复创建
return
# 创建占位用户:使用随机密码,标记为deleted,不用于实际登录,仅存储绑定关系
random_password = secrets.token_urlsafe(32)
password_hash = AuthUtils.hash_password(random_password)
# username 使用 oidc-binding-{sub_hash} 避免冲突,sub_hash 基于完整 sub 生成
sub_hash = hashlib.sha256(sub.encode()).hexdigest()[:8]
username = f"oidc-binding-{sub_hash}"
placeholder_user = User(
username=username,
uid=oidc_placeholder_id,
phone_number=None,
avatar=None,
password_hash=password_hash,
role=target_user.role,
department_id=target_user.department_id,
is_deleted=1, # 标记为deleted,不参与实际登录
last_login=utc_now_naive(),
)
try:
db.add(placeholder_user)
await db.commit()
logger.info(
f"Created OIDC binding placeholder (deleted) for sub {sub} -> user {target_user.id} ({target_user.uid})"
)
except IntegrityError:
# 并发创建冲突,回滚后忽略
await db.rollback()
logger.info(f"OIDC binding placeholder already exists for sub {sub}")
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"]
# 根据配置决定 uid 是否带 oidc 前缀
if oidc_config.use_raw_username:
uid = user_info["username"]
result = await db.execute(select(User).filter(User.uid == uid, User.is_deleted == 0))
existing_user = result.scalar_one_or_none()
if existing_user:
# 用户已存在,必须验证当前sub是否已经绑定到这个用户
# 如果sub未绑定该用户,不能直接复用,存在账号冒用风险
user_by_sub = await find_user_by_oidc_sub(db, sub)
if user_by_sub and user_by_sub.id == existing_user.id:
# sub 已经正确绑定到该用户,允许返回
logger.info(f"User with raw uid {uid} already exists and bound to sub {sub}, returning existing user")
return existing_user
elif user_by_sub is None:
# sub 尚未绑定任何用户,可以将sub绑定到这个现有用户
logger.info(f"Binding new OIDC sub {sub} to existing user with raw uid {uid}")
await _create_oidc_binding_placeholder(db, sub, existing_user)
return existing_user
else:
# sub 已经绑定到另一个用户,冲突,拒绝创建
logger.warning(
f"Cannot create OIDC user with raw uid {uid}: "
f"sub {sub} is already bound to another user {user_by_sub.id}, conflict"
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"UID {uid} 已存在且OIDC标识 {sub} 已绑定到其他账号,请联系管理员处理冲突",
)
else:
uid = 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,
"uid": uid,
"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} ({uid})")
# use_raw_username 模式下,创建占位用户记录绑定关系
if oidc_config.use_raw_username:
await _create_oidc_binding_placeholder(db, sub, new_user)
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.uid})")
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("无法获取用户标识,请返回登录页重试")
# 查找用户:总是先通过 sub 查找,保证绑定关系可验证
user_by_sub = await find_user_by_oidc_sub(db, sub)
if oidc_config.use_raw_username:
# 使用原始用户名模式
username = extracted_info["username"]
user = None
if username:
result = await db.execute(select(User).filter(User.uid == username, User.is_deleted == 0))
user_by_name = result.scalar_one_or_none()
if user_by_sub:
# sub 已经绑定到一个用户
if user_by_name and user_by_sub.id == user_by_name.id:
# sub 绑定的用户就是找到的用户名用户 -> 验证通过
user = user_by_name
logger.info(f"OIDC user logged in with raw username: {username} (sub: {sub})")
else:
# sub 已经绑定到另一个用户,存在冲突,拒绝登录
conflict_name = user_by_sub.username if not user_by_name else user_by_name.username
logger.warning(
f"OIDC sub {sub} is already bound to a different user, "
f"login rejected to prevent account hijacking (conflict: {conflict_name})"
)
return _redirect_to_login_with_error("OIDC标识已绑定到其他账号,请联系管理员处理绑定冲突")
else:
# sub 尚未绑定到任何用户
if user_by_name:
# 用户名存在,且 sub 没有绑定 -> 允许登录,并创建绑定记录
# 在不修改表结构的情况下,我们创建一个占位用户 oidc:{sub} 来记录绑定关系
# 这个占位用户不会被用来登录,仅用于存储sub -> 用户的绑定关系
user = user_by_name
logger.info(f"Binding new OIDC sub {sub} to existing user with raw username: {username}")
# 创建绑定占位用户(后台静默创建,不影响现有用户)
await _create_oidc_binding_placeholder(db, sub, user_by_name)
else:
# 用户名不存在,需要创建新用户
if oidc_config.auto_create_user:
user = None # 让后续逻辑创建
else:
return _redirect_to_login_with_error("用户不存在,请联系管理员开通账号")
else:
# 没有获取到 username,回退到按sub查找
user = user_by_sub
else:
# 标准 OIDC 模式,通过 sub 查找
user = user_by_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_name = extracted_info.get("department_name")
dept_desc = extracted_info.get("department_description")
dept = await get_or_create_oidc_department(db, dept_name, dept_desc)
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,
"uid": user.uid,
"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}