Skip to content

feat(web): add OIDC SSO login support#1943

Merged
KKRainbow merged 1 commit intoEasyTier:mainfrom
xzzpig:web-oidc
Mar 3, 2026
Merged

feat(web): add OIDC SSO login support#1943
KKRainbow merged 1 commit intoEasyTier:mainfrom
xzzpig:web-oidc

Conversation

@xzzpig
Copy link
Copy Markdown
Contributor

@xzzpig xzzpig commented Feb 26, 2026

摘要

easytier-web 添加 OIDC (OpenID Connect) 单点登录认证支持,允许用户通过外部身份提供商(如 Keycloak, Authentik, Okta, Azure AD)进行身份验证。

核心变更

  • OIDC SSO 认证:完整的 Authorization Code + PKCE 流程实现,包含 CSRF 保护、nonce 验证和 access token hash 校验。
  • 心跳自动创建用户:新增 --allow-auto-create-user 命令行标志,当 easytier-core 以未知用户名连接时,可自动为其拨备本地账号。
  • 用户创建逻辑重构:将所有用户创建路径统一到事务函数 create_user_and_join_users_group 中,消除了原有 register_new_user 中的 .unwrap() panic 风险。
  • 前端 SSO 按钮:登录页面根据服务端配置动态显示“SSO 登录”按钮。

新增命令行选项

标志 说明
--oidc-issuer-url OIDC 发行者 URL(用于自动发现配置)
--oidc-client-id OIDC 客户端 ID
--oidc-client-secret / OIDC_CLIENT_SECRET 环境变量 客户端密钥
--oidc-redirect-url 回调 URL(必须与 IdP 配置完全一致)
--oidc-username-claim 用于提取用户名的 Claim(默认:preferred_username
--oidc-scopes 请求的权限范围(默认:openid,profile
--oidc-disable-pkce 为不支持 PKCE 的旧版 IdP 禁用 PKCE
--oidc-frontend-base-url 前后端分离部署模式下的前端基础 URL
--allow-auto-create-user 允许为未知的心跳 Token 自动创建本地用户

新增 API 端点

方法 路径 认证要求 说明
GET /api/v1/auth/oidc/config 检查 OIDC 是否已启用
GET /api/v1/auth/oidc/login 发起 OIDC 登录流程
GET /api/v1/auth/oidc/callback 处理 IdP 认证回调

OIDC 流程说明

浏览器 → GET /auth/oidc/login
  → 服务端生成认证 URL (包含 CSRF + nonce + PKCE) → 存入 session
  → 302 重定向至身份提供商 (IdP)
  → 用户在 IdP 完成登录
  → IdP → 302 重定向至 /auth/oidc/callback?code=...&state=...
  → 服务端验证 CSRF,交换 Code,验证 ID Token
  → 查找或自动拨备本地用户账号
  → 建立登录会话 → 302 重定向回前端

安全措施

  • 使用 subtle 库进行恒定时间比较(Constant-time comparison)验证 CSRF 状态,防止时序攻击。
  • 默认启用 PKCE (Proof Key for Code Exchange)。
  • 对 ID Token Claim 进行 Nonce 验证。
  • 存在时强制执行 Access Token Hash (at_hash) 校验。
  • 认证回调完成后立即清理 Session 中的一次性安全参数。
  • Session 配置 SameSite=Lax 以支持跨站认证重定向。

已知局限

  • 新用户的首次并发 OIDC 登录可能因用户创建时的 TOCTOU 竞态导致瞬间失败(唯一约束保护了数据一致性,重试即可成功)。
  • register_new_userchange_password 在异步运行时中直接调用了 CPU 密集型的哈希函数(此为既有代码遗留问题,非本次 PR 引入)。
  • 跨域 Cookie (SameSite) 限制SessionManagerLayer 配置为 SameSite::Lax,如果 API 与 Frontend 部署在完全不同的域名下(非同父域),OIDC 回调后浏览器不会携带 Cookie,导致用户始终处于未登录状态。当前假定通过反代将前后端置于同一域名下;若需完全跨域部署,后续需增加 SameSite=None + Secure=true 的可选配置。
  • OIDC 与本地账户按用户名统一匹配:系统通过 username_claim 提取的用户名与本地 username 进行匹配,不区分用户来源(本地注册 vs OIDC)。这是有意为之的设计——同一用户名始终对应同一账户,无论通过密码还是 SSO 登录均操作同一身份。部署时管理员应确保 IdP 侧的用户名分配策略与本地用户体系一致。

测试情况

自动化单元测试

  • dot_path_to_json_pointer 覆盖各种边缘情况(嵌套路径、RFC 6901 转义、空段等)。

集成 / 人工测试用例

前置条件:需要一个可用的 OIDC IdP(推荐使用 Docker 启动 Keycloak 或 Authentik 进行本地测试)。

一、CLI 参数验证

# 测试场景 操作步骤 预期结果
不提供 OIDC 参数正常启动 正常启动 easytier-web,不传任何 --oidc-* 参数 服务正常启动,GET /api/v1/auth/oidc/config 返回 {"enabled": false}
缺少必需参数报错退出 仅传 --oidc-issuer-url 而不传 --oidc-client-id--oidc-redirect-url 进程输出错误信息并以非零码退出
无效 Issuer URL 报错退出 传入不可访问的 --oidc-issuer-url(如 http://localhost:99999 进程输出 OIDC 发现失败的错误信息并退出
完整参数正常启动 传入全部必需 OIDC 参数(issuer-url, client-id, redirect-url) 服务正常启动,日志无报错
默认 scopes 生效 不传 --oidc-scopes,直接发起 OIDC 登录 授权请求中的 scope 至少包含 openid profile(按默认值)
split-deploy 模式缺少 frontend-base-url 使用 --no-web 或不同的 --web-server-port,但不传 --oidc-frontend-base-url 进程输出 "--oidc-frontend-base-url is required" 并退出
OIDC_CLIENT_SECRET 环境变量 通过环境变量而非命令行传入 client secret 服务正常启动,OIDC 流程使用该 secret

二、OIDC SSO 登录 — 正常流程

# 测试场景 操作步骤 预期结果
OIDC 配置接口 GET /api/v1/auth/oidc/config OIDC 已启用时返回 {"enabled": true};未启用时返回 {"enabled": false}
发起登录重定向 GET /api/v1/auth/oidc/login 返回 302,Location 指向 IdP 的授权端点,URL 中包含 code_challenge(PKCE)、statenonce 参数
首次 OIDC 用户登录 完整 SSO 流程:点击 SSO 登录 → IdP 认证 → 回调 自动创建本地用户并加入 users 组,回调后 302 重定向到前端,session cookie 有效,GET /api/v1/auth/check_login_status 返回 200
已有用户 OIDC 登录 用户已存在(通过注册或之前的 OIDC 登录创建),再次通过 SSO 登录 直接查找到已有用户并登录,不创建重复账号
回调后重定向到前端 未设 --oidc-frontend-base-url 回调成功后重定向到 /
回调后重定向到自定义前端 设置 --oidc-frontend-base-url http://localhost:3000 回调成功后重定向到 http://localhost:3000
自定义 username claim 使用 --oidc-username-claim email 启动 从 ID Token 的 email 字段提取用户名
嵌套 username claim 使用 --oidc-username-claim realm_access.roles.0 启动 从嵌套 JSON 路径正确提取用户名
禁用 PKCE 使用 --oidc-disable-pkce 启动 登录重定向 URL 中不包含 code_challenge 参数,token 交换请求中不含 PKCE verifier
自定义 scopes 使用 --oidc-scopes profile,email,groups 启动 登录重定向 URL 的 scope 参数中包含 openid(自动补充)、profileemailgroups

三、OIDC SSO 登录 — 异常与安全

# 测试场景 操作步骤 预期结果
OIDC 未启用时访问登录端点 未配置 OIDC 参数时 GET /api/v1/auth/oidc/login 返回 400 {"message": "OIDC is not enabled"}
☑️ CSRF state 篡改 手动构造回调 URL,将 state 参数替换为随机值 返回 400 {"message": "CSRF state mismatch"}
☑️ 缺少 code 参数 手动构造回调 URL,不携带 code 参数 返回 400 {"message": "Missing authorization code"}
☑️ 缺少 state 参数 手动构造回调 URL,不携带 state 参数 返回 400 {"message": "Missing state parameter in callback"}
☑️ IdP 返回错误 IdP 拒绝认证(如用户取消),回调携带 error=access_denied 返回 400,日志记录具体 error 和 error_description
☑️ Session 过期 发起登录后等待 session 过期(>1天),再用保存的回调 URL 访问 返回 400,提示 CSRF token 或 nonce 缺失
☑️ PKCE verifier 丢失 启用 PKCE 的情况下,session 中的 pkce_verifier 被清除后回调 返回 400 {"message": "PKCE was enabled but verifier is missing from session"}
☑️ 无效 authorization code 使用过期或伪造的 code 参数进行回调 返回 500,日志记录 token exchange 失败详情
☑️ Nonce 不匹配 使用与登录发起阶段不同的 nonce(或伪造 ID Token)进行回调 返回 401 {"message": "ID token verification failed"}
☑️ ID Token 缺失 IdP token endpoint 仅返回 access token,不返回 id_token 返回 500 {"message": "No ID token in response"}
☑️ at_hash 不匹配 篡改 access token 或使用与 id_token 不匹配的 access token 返回 401 {"message": "Access token hash mismatch"}
username claim 不存在 --oidc-username-claim 指定一个 ID Token 中不存在的字段名 返回 400 {"message": "Could not extract username from token claims"}
username claim 类型错误 --oidc-username-claim 指向非字符串字段(如对象/数组) 返回 400 {"message": "Could not extract username from token claims"}
☑️ 回调重放攻击 成功回调后,尝试重放相同的回调 URL 返回 400(CSRF token 已被 cleanup_oidc_session 清除)

四、心跳自动创建用户

# 测试场景 操作步骤 预期结果
默认不自动创建 不传 --allow-auto-create-user,用一个未注册的用户名作为 token 连接 easytier-core 心跳返回 "User not found" 错误
启用后自动创建 --allow-auto-create-user,用一个未注册的用户名作为 token 连接 自动创建用户并加入 users 组,后续心跳正常处理
已存在用户不重复创建 --allow-auto-create-user,用一个已注册的用户名连接 直接查找到已有用户,不触发创建逻辑
自动创建用户权限正确 通过自动创建的用户登录 Web 界面 用户具有 users 组的默认权限,可正常使用

五、用户注册回归

# 测试场景 操作步骤 预期结果
正常注册 通过前端注册页面注册新用户 注册成功,用户加入 users 组,可正常登录
重复用户名注册 注册一个已存在的用户名 返回错误(唯一约束),不会 panic
禁用注册 启动时传 --disable-registration,尝试注册 返回 403 Forbidden
密码登录不受影响 配置 OIDC 后,仍然使用用户名/密码登录 登录正常,OIDC 与密码登录互不干扰
修改密码 使用已登录用户修改密码 修改成功,旧 session 失效,需重新登录

六、前端行为

# 测试场景 操作步骤 预期结果
OIDC 未启用时隐藏 SSO 按钮 访问未配置 OIDC 的服务器登录页 登录页面仅显示用户名/密码表单和注册按钮,不显示 SSO 按钮
OIDC 启用时显示 SSO 按钮 访问已配置 OIDC 的服务器登录页 登录页面显示 "SSO 登录" 按钮(severity="info" 蓝色按钮)
切换 API Host 后动态刷新 在 API Host 输入框中切换到一个未启用 OIDC 的服务器 SSO 按钮消失;切换回已启用 OIDC 的服务器后 SSO 按钮重新出现
点击 SSO 按钮跳转 点击 "SSO 登录" 按钮 浏览器跳转到 {apiHost}/api/v1/auth/oidc/login,apiHost 已保存至 localStorage
SSO 登录后回跳正常 完成 SSO 流程后浏览器回到前端 前端页面加载正常,用户处于已登录状态
快速切换 API Host 的竞态保护 在两个 host 间快速切换(一个启用 OIDC,一个未启用)并观察按钮状态 最终状态以“当前输入框 host”查询结果为准,不会被旧请求覆盖(if (apiHost.value !== host) return
API Host 不可达时 SSO 按钮 在 API Host 输入框中填入不可达的地址 SSO 按钮不显示(getOidcConfig 静默返回 {enabled: false}
注册页面不显示 SSO 按钮 切换到注册页面 注册表单中不包含 SSO 按钮(v-if="!isRegistering" 限制)
中英文切换 切换语言后检查 SSO 按钮文案 中文显示 "SSO 登录",英文显示 "SSO Login"

七、多 IdP 兼容性

# IdP 备注
Auth0 需要使用 --oidc-username-claim=nickname
Kanidm -

@KKRainbow
Copy link
Copy Markdown
Member

心跳自动创建用户:新增 --allow-auto-create-user 命令行标志,当 easytier-core 以未知用户名连接时,可自动为其拨备本地账号。

这个感觉不是很有必要

@xzzpig
Copy link
Copy Markdown
Contributor Author

xzzpig commented Feb 27, 2026

心跳自动创建用户:新增 --allow-auto-create-user 命令行标志,当 easytier-core 以未知用户名连接时,可自动为其拨备本地账号。

这个感觉不是很有必要

这个是配合无状态web使用的:现在配置文件下发后可以由core本地存储,用户登录可以用oidc,那么这种情况下服务器db文件就可以不做持久化存储,仅临时缓存用。
但由于现在core连到web时要求web中一定要有对应的用户,因此加了这参数允许此时自动创建用户。

@xzzpig xzzpig marked this pull request as ready for review February 28, 2026 03:22
@xzzpig xzzpig requested a review from KKRainbow February 28, 2026 03:22
@KKRainbow
Copy link
Copy Markdown
Member

跨域 Cookie (SameSite) 限制:SessionManagerLayer 配置为 SameSite::Lax,如果 API 与 Frontend 部署在完全不同的域名下(非同父域),OIDC 回调后浏览器不会携带 Cookie,导致用户始终处于未登录状态。当前假定通过反代将前后端置于同一域名下;若需完全跨域部署,后续需增加 SameSite=None + Secure=true 的可选配置。

cookie 感觉得找个机会干掉。。都用 jwt 之类的通过 url 传参,就没这么多跨域问题了

@xzzpig
Copy link
Copy Markdown
Contributor Author

xzzpig commented Feb 28, 2026

跨域 Cookie (SameSite) 限制:SessionManagerLayer 配置为 SameSite::Lax,如果 API 与 Frontend 部署在完全不同的域名下(非同父域),OIDC 回调后浏览器不会携带 Cookie,导致用户始终处于未登录状态。当前假定通过反代将前后端置于同一域名下;若需完全跨域部署,后续需增加 SameSite=None + Secure=true 的可选配置。

cookie 感觉得找个机会干掉。。都用 jwt 之类的通过 url 传参,就没这么多跨域问题了

可以的,我晚点开个新PR改下试试

@KKRainbow KKRainbow merged commit ff24332 into EasyTier:main Mar 3, 2026
43 checks passed
@xzzpig xzzpig deleted the web-oidc branch March 6, 2026 14:07
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.

2 participants