Skip to content

Feature/OIDC enhancements#644

Merged
xerrors merged 12 commits intoxerrors:mainfrom
szmadd:feature/oidc-enhancements
Apr 16, 2026
Merged

Feature/OIDC enhancements#644
xerrors merged 12 commits intoxerrors:mainfrom
szmadd:feature/oidc-enhancements

Conversation

@szmadd
Copy link
Copy Markdown

@szmadd szmadd commented Apr 16, 2026

变更描述

简要描述这个 PR 做了什么

功能描述

此PR增强OIDC登录功能,新增三个环境变量配置:

环境变量 说明 默认值
OIDC_USE_RAW_USERNAME 是否使用原始用户名(不带 oidc: 前缀),支持与已有本地账号映射 false
OIDC_FETCH_DEPARTMENT_INFO 是否从OIDC userinfo中获取部门信息并自动创建/关联 false
OIDC_FORCE_PROMPT_LOGIN 授权时是否添加 prompt=login 参数强制用户重新登录 false
OIDC_DEPARTMENT_CLAIM 部门名称字段映射 department

主要改进

  • ✅ 支持使用OIDC返回的原始用户名直接作为登录ID,便于企业SSO场景映射已有账号
  • ✅ 支持从OIDC用户信息中自动获取部门信息,创建部门并关联用户
  • ✅ 部门名称/描述增加长度校验(50/255字符)和空格清理,避免数据库写入错误
  • ✅ 修复安全问题:通过 oidc:{sub} 占位用户记录绑定关系,防止账号冒用
  • 无需修改User表结构,向后兼容

安全修复

启用 use_raw_username 后,每个OIDC sub 必须验证绑定关系才能登录,避免攻击者使用相同用户名冒用他人账号。

测试

  • 已在 Docker 环境测试
  • 相关功能正常工作

相关日志或者截图

说明

(可选)有什么需要特别说明的吗?


💡 提示: 提交前可以运行 make lintmake format 检查代码规范

@xerrors
Copy link
Copy Markdown
Owner

xerrors commented Apr 16, 2026

因为我也不懂这个鉴权部分,我就让 copilot 再审阅一下,意见供你参考。

@xerrors xerrors requested a review from Copilot April 16, 2026 05:15
@xerrors
Copy link
Copy Markdown
Owner

xerrors commented Apr 16, 2026

我的建议是更新一下鉴权部分的文档

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

该 PR 旨在增强后端 OIDC 登录能力,通过新增环境变量配置来支持“原始用户名映射”、从 userinfo 自动同步部门信息,以及可选强制 prompt=login 的授权参数,从而提升企业 SSO 场景的账号映射与组织信息同步能力。

Changes:

  • 新增 OIDC 配置项:原始用户名模式、部门信息拉取/字段映射、强制 prompt=login。
  • OIDC userinfo 提取逻辑增加部门字段,并在回调创建用户时按部门信息自动创建/复用部门。
  • 引入“sub 绑定占位记录”的逻辑以尝试在不改表结构下校验 sub 绑定关系。

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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",
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

force_prompt_login 的环境变量解析默认值是 "true",这会导致在启用 OIDC 且未设置 OIDC_FORCE_PROMPT_LOGIN 时默认强制添加 prompt=login,与配置字段默认值(False)及 PR 描述的默认值(false)不一致,可能造成行为意外变化。建议将默认值改为 "false",并与其它布尔环境变量保持一致。

Suggested change
force_prompt_login=os.environ.get("OIDC_FORCE_PROMPT_LOGIN", "true").lower() == "true",
force_prompt_login=os.environ.get("OIDC_FORCE_PROMPT_LOGIN", "false").lower() == "true",

Copilot uses AI. Check for mistakes.
Comment on lines +589 to +605
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 username {user_id} 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 username {user_id}")
await _create_oidc_binding_placeholder(db, sub, existing_user)
return existing_user
else:
# sub 已经绑定到另一个用户,冲突,拒绝创建
logger.warning(f"Cannot create OIDC user with raw username {user_id}: sub {sub} is already bound to another user {user_by_sub.id}, conflict")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"用户名 {user_id} 已存在且OIDC标识 {sub} 已绑定到其他账号,请联系管理员处理冲突",
)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_create_oidc_binding_placeholder 创建的占位用户 user_id 正是 oidc:{sub},而 find_user_by_oidc_sub() 的方法1会优先返回该记录;但在 use_raw_username 流程中又用 user_by_sub.id == existing_user.id 来验证绑定关系。占位用户的 id 不可能等于被绑定的真实用户,因此会导致已有账号在绑定后/登录时被误判为冲突并拒绝登录。建议调整绑定数据的存储方式,使 find_user_by_oidc_sub 能解析并返回“真实被绑定用户”(例如单独的绑定表,或在 legacy 格式中编码目标 user_id 并在查询后解析再反查)。

Copilot uses AI. Check for mistakes.
Comment on lines +522 to +537
# 创建占位用户:使用随机密码,soft deleted 标记不参与实际登录
# 但我们保留它存在只是为了记录 sub -> target_user 的绑定关系
random_password = secrets.token_urlsafe(32)
password_hash = AuthUtils.hash_password(random_password)

try:
await user_repo.create({
"username": f"oidc-binding-{sub[:8]}",
"user_id": oidc_user_id,
"phone_number": None,
"avatar": None,
"password_hash": password_hash,
"role": target_user.role,
"department_id": target_user.department_id,
"last_login": utc_now_naive(),
})
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

占位用户的注释写的是“soft deleted 标记不参与实际登录”,但创建时没有设置 is_deleted=1(模型默认是 0),因此该占位用户会被当作正常用户返回(例如 find_user_by_oidc_sub 过滤 is_deleted==0)。这不仅与注释不一致,也可能导致占位账号被错误用于登录/发放 JWT。建议明确将占位记录标记为不可登录(例如 is_deleted=1 并调整查询逻辑专门读取绑定记录),或改为使用专门的绑定存储结构。

Copilot uses AI. Check for mistakes.
Comment on lines +513 to +542
from yuxi.repositories.user_repository import UserRepository
user_repo = UserRepository()

oidc_user_id = f"oidc:{sub}"
result = await db.execute(select(User).filter(User.user_id == oidc_user_id, User.is_deleted == 0))
if result.scalar_one_or_none():
# 占位用户已存在,无需重复创建
return

# 创建占位用户:使用随机密码,soft deleted 标记不参与实际登录
# 但我们保留它存在只是为了记录 sub -> target_user 的绑定关系
random_password = secrets.token_urlsafe(32)
password_hash = AuthUtils.hash_password(random_password)

try:
await user_repo.create({
"username": f"oidc-binding-{sub[:8]}",
"user_id": oidc_user_id,
"phone_number": None,
"avatar": None,
"password_hash": password_hash,
"role": target_user.role,
"department_id": target_user.department_id,
"last_login": utc_now_naive(),
})
logger.info(f"Created OIDC binding placeholder for sub {sub} -> user {target_user.user_id}")
except IntegrityError:
# 并发创建冲突,忽略
await db.rollback()
logger.info(f"OIDC binding placeholder already exists for sub {sub}")
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_create_oidc_binding_placeholder 先用传入的 db session 查询是否存在,再调用 UserRepository.create()(该方法会开启并提交自己的 session)。这会导致同一请求内存在跨 session 的一致性问题,并且 except IntegrityError 分支里调用 await db.rollback() 也不会回滚 UserRepository.create() 使用的 session。建议统一使用同一个 db session 来创建占位记录(直接 db.add/commit),或将创建逻辑改造成接收 session 的 repository 方法。

Copilot uses AI. Check for mistakes.
Comment on lines +528 to +533
await user_repo.create({
"username": f"oidc-binding-{sub[:8]}",
"user_id": oidc_user_id,
"phone_number": None,
"avatar": None,
"password_hash": password_hash,
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

占位用户的 username 使用 oidc-binding-{sub[:8]},但 users.username 在数据库中是唯一索引;不同 sub 的前 8 位碰撞时会触发 IntegrityError。当前捕获后直接认为“占位用户已存在”,但实际上可能只是用户名冲突,导致绑定记录并未创建。建议使用与 build_unique_oidc_username 类似的去重策略(例如加入更长哈希/随机后缀),并区分 user_id 冲突与 username 冲突。

Copilot uses AI. Check for mistakes.
Comment on lines 724 to 783
@@ -601,7 +775,10 @@ async def oidc_callback_handler(code: str, state: str, db, request: Request | No
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)
# 从用户信息中获取部门信息
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)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

该 PR 在 OIDC 回调/用户创建路径引入了多条新分支(use_raw_username 绑定校验、部门自动创建、prompt=login 参数等),但当前测试集中未发现任何 OIDC 相关用例。建议在现有 backend/test/integration/api 的认证测试基础上补充 OIDC 回调的集成测试(可通过 mock token/userinfo 响应),覆盖:首次绑定、已绑定登录、冲突拒绝、部门创建/复用以及 prompt=login 参数是否按配置生效。

Copilot uses AI. Check for mistakes.
@szmadd
Copy link
Copy Markdown
Author

szmadd commented Apr 16, 2026

根据Copilot的建议再次梳理了代码,并增加了文档。

@xerrors xerrors merged commit c47a019 into xerrors:main Apr 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants