打开 “懒猫微服客户端” 下载应用

懒猫ENV查看器

原创应用。查看懒猫的所有环境变量,还集成了OIDC。

100 次下载
0 次点赞
1 条评论
0 次催更
原创应用
100

安装次数

0

点赞

1

应用评论

0

催更次数

桌面端

应用描述

原创应用。查看懒猫的所有环境变量,还集成了OIDC。

相关攻略

第三章:PKCE:懒猫的 Authorization Code 为什么不能被偷用

https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env 在第二章的结尾,我们刻意停在了一个被大多数流程图忽略的位置: ``` https://env.alice.heiyu.space/callback?code=abc123 ``` 这是懒猫 Authorization Code Flow 中一个看起来非常不起眼的瞬间。 浏览器回跳,URL 里多了一个参数。 ENV 查看器继续往下走,拿 `code` 换 token。 但如果你站在 ENV 查看器的立场仔细想一想,就会发现一个问题几乎是 unavoidable 的: > **这个 code 是暴露在浏览器环境里的。 > 那它为什么还敢被当作“凭证”使用?** 这一章,我们只做一件事: > **证明:即使在“懒猫自动注入 CLIENT_SECRET”这种看似有密的场景下,没有 PKCE,Authorization Code Flow 在家庭服务器环境里也并不安全。** --- ## 3.1 Authorization Code 到底是什么 在开始谈 PKCE 之前,我们必须先明确一件事: > **Authorization Code 本身不是 token,也不是身份声明。** 它只是一个**中间凭证**,用来完成两件事之间的衔接: * 浏览器参与的授权阶段(`/sys/oauth/auth`) * 容器与懒猫 SSO 直接通信的 token 交换阶段(`/sys/oauth/token`) 在理想设计中,它应该满足三个特性: 1. 短期有效(懒猫 SSO 默认就是几十秒) 2. 只能使用一次(用过即作废,重试会拿到 `invalid_grant`) 3. **只能被特定 Client 使用** 前两个特性,单靠懒猫 SSO 就能保证。 第三个特性,问题就来了。 --- ## 3.2 一个经常被低估的事实:家里的浏览器也是不可信的 在 Authorization Code Flow 中,**code 一定会经过浏览器**。 这不是实现问题,而是协议设计的必然结果。 而浏览器环境在懒猫这种场景下意味着什么? * URL 会被浏览器历史记录、同步到云端 * 如果你在盒子前挂了自建 nginx / openresty,**访问日志里会留下 code** * JavaScript 可以读地址栏(跨 App 的第三方脚本如果被加载进来,能读到) * 浏览器插件可以读取页面内容 * Referer 可能被发送到第三方 * 家人用的浏览器你不可能审计 * 盒子开了公网访问后,任何经过的中间设备都有可能留下痕迹 换句话说: > **只要 code 出现在 URL 里,就默认处在“可能被窃取”的环境中。** 家庭内网不是纯净室,懒猫官方做了很多努力(证书、加密、子域隔离), 但**不能**替你解决“code 被读到之后怎么办”。 如果你此刻的直觉是: > “那 code 被偷了是不是就完了?” 恭喜你,你已经走到 PKCE 之前所有安全设计者走到的那个位置。 --- ## 3.3 没有 PKCE 会发生什么(不是假设,是攻击模型) 我们来构造一个完全现实的攻击场景,不引入任何极端条件。 ### 场景 * 盒子主人 Alice 装了一个稍微不那么干净的浏览器扩展(它请求了 `<all_urls>` 权限) * ENV 查看器使用 Authorization Code Flow * 假设**没有 PKCE** ### 正常流程 1. Alice 在 Grant Access 页点同意 2. 浏览器回跳: ``` https://env.alice.heiyu.space/callback?code=abc123&state=xyz ``` 3. ENV 查看器后端用 `abc123` 换 token ### 攻击者(那个扩展)只需要做一件事 > **在第 2 步,读到这个 code** 扩展不需要: * 窃取密码 * 破解加密 * 控制懒猫 SSO 它只需要获得: ``` abc123 ``` 然后把这个 code 连同它偷到的 `CLIENT_SECRET`(如果 App 是纯 SPA,secret 也在前端代码里), 自己向懒猫 SSO 发起: ``` POST /sys/oauth/token code=abc123 client_id=xu.deploy.env client_secret=... ``` 如果没有 PKCE,**懒猫 SSO 没有任何技术手段区分**: * 这是 ENV 查看器的后端 * 还是一个拿着同样 client 凭证的攻击者 于是,一个结论非常残酷: > **在没有 PKCE 的情况下,懒猫 Authorization Code 本质上是“谁拿到 code + client 凭证,谁能用”。** --- ## 3.4 为什么 `LAZYCAT_AUTH_OIDC_CLIENT_SECRET` 解决不了这个问题 到这里,很多懒猫 App 开发者会下意识想到: > “我在 manifest 里读 `LAZYCAT_AUTH_OIDC_CLIENT_SECRET` 了啊,应该算 Confidential Client 吧?” 这是一个**只在后端场景成立的直觉**。 ### 对有后端容器的懒猫 App * `CLIENT_SECRET` 通过环境变量注入到容器 * 换 token 的 POST 发生在容器内,不经过浏览器 * 攻击者拿不到 secret 👉 这种情况下,secret 确实有意义。 ### 对纯前端 SPA 型懒猫 App * `CLIENT_SECRET` 要么在构建期打进前端 bundle * 要么在运行期从某个 `/config` 接口传给前端 * 等价于公开 * 攻击者同样可以使用 于是,对于纯前端 App: > **`CLIENT_SECRET` 在安全意义上等于不存在。** 即使是有后端的 App,只要你的 `CLIENT_SECRET` 不小心被日志打出来(懒猫玩家经常开 debug)、或者写到了可被别的容器读到的文件里,它的保密性就已经被打穿。 这正是 PKCE 存在的历史背景,也是它在懒猫 SSO 里**不能省略**的直接原因。 --- ## 3.5 PKCE 的核心思想(不是参数,而是绑定) PKCE(Proof Key for Code Exchange)不是“多加一个校验字段”。 它解决的是一个非常明确的问题: > **如何证明: > “来换 token 的这个请求, > 就是当初发起授权的那个 Client 实例?”** 注意这里的关键词是: * 不是“哪个用户”(那是 Alice 的事) * 不是“哪个 IdP”(那是懒猫 SSO 的事) * 也不是“哪个应用包名”(那是 `client_id` 的事) * 而是:**哪个正在运行的 Client 实例** 即使两个人都在用 ENV 查看器,他们是**两个独立的 Client 实例**,PKCE 让他们彼此的 code 也不可互换。 --- ## 3.6 PKCE 在懒猫上的完整流程(一步都不能省) 现在我们重新走一遍懒猫 Authorization Code Flow,但这次加上 PKCE。 懒猫 SSO discovery 里明确支持: ```json "code_challenge_methods_supported": ["S256", "plain"] ``` **必须用 `S256`**,`plain` 只是兼容古董 App,新项目不要用。 --- ### Step 1:Client 本地生成一个秘密 在发起授权之前,ENV 查看器先在本地生成一个高熵随机值: ``` code_verifier = random(43~128 字符, [A-Z a-z 0-9 - . _ ~]) ``` 这个值有两个重要特性: 1. 从不发送给懒猫 SSO(此时) 2. 从不出现在浏览器 URL 中 它是真正的“秘密”,存在服务端 session 或安全的客户端存储里。 --- ### Step 2:Client 派生一个公开承诺 ENV 查看器对这个 secret 做一次变换: ``` code_challenge = BASE64URL(SHA256(code_verifier)) ``` 这个 challenge: * 可以公开 * 不能反推 verifier * 与 verifier 有确定性数学关系 这一步,本质上是一个 **commitment**。 --- ### Step 3:授权请求中只发送 challenge 浏览器跳转到懒猫 SSO 时,参数变成了: ``` GET https://alice.heiyu.space/sys/oauth/auth? response_type=code &client_id=xu.deploy.env &redirect_uri=https%3A%2F%2Fenv.alice.heiyu.space%2Fcallback &scope=openid%20email%20profile &state=... &nonce=... &code_challenge=yyy &code_challenge_method=S256 ``` 注意: * 浏览器里只有 `code_challenge` * `code_verifier` 仍然只存在于 ENV 查看器本地(session / 后端内存) --- ### Step 4:懒猫 SSO 将 code 与 challenge 绑定懒猫 SSO 在生成 Authorization Code 时,会记录: ``` authorization_code: value = abc123 client_id = xu.deploy.env bound_challenge = yyy bound_method = S256 ``` 从这一刻起,这个 code 就不再是“自由凭证”。 --- ### Step 5:换 token 时提交 verifier(关键验证) ENV 查看器请求 token 时,除了原本的参数,再带一个 `code_verifier`: ```http POST https://alice.heiyu.space/sys/oauth/token Content-Type: application/x-www-form-urlencoded Authorization: Basic base64(xu.deploy.env:<secret>) grant_type=authorization_code &code=abc123 &redirect_uri=https%3A%2F%2Fenv.alice.heiyu.space%2Fcallback &code_verifier=zzz ``` 懒猫 SSO 执行验证: ``` SHA256(code_verifier) == bound_challenge ? ``` * ❌ 不相等 → 直接 `invalid_grant` * ✅ 相等 → 继续发 token --- ## 3.7 PKCE 实际验证的是什么 我们用一句话把 PKCE 的验证逻辑说清楚: > **PKCE 验证的不是“你是谁”, > 而是“你是不是当初那个发起授权的人”。** 它解决的是: * code 被偷 * code 被重放 * code 被跨 Client 使用 * 两个同名 App 实例之间的 code 串用 而它**不解决**: * token 是否伪造(那是 JWKS + 签名的活) * token 是否过期 * token 是否发给正确的 Client(那是 `aud` 的活) 这些问题,会在后面的章节里由 JWT 签名和 ID Token Validation 解决。 --- ## 3.8 为什么 PKCE 对懒猫 App 是“必须”,不是“增强” 在懒猫 SSO 的实践中,可以明确给出一个结论: > **在懒猫场景下, > 没有 PKCE 的 Authorization Code Flow 是不安全的。** 这不是建议,而是逻辑必然。 因为: * 盒子可能暴露公网,`heiyu.space` 子域对全世界可见 * 同一个盒子上会跑很多 App,共享同一个懒猫 SSO * 家人的设备、家里的浏览器你不可能都信得过 * 部分 App 是纯前端 SPA,`CLIENT_SECRET` 等于裸奔 * Authorization Code 一定会暴露在 URL 里 PKCE 是唯一能够在这些前提下**恢复 Client 绑定能力**的机制。 懒猫 SSO 支持 `S256`,你只要用就对了。**所有主流 OIDC 客户端库默认都打开 PKCE,不要主动关掉。** --- ## 3.9 PKCE 在验证链中的位置 现在我们可以把 PKCE 放回到整条懒猫 SSO 验证链中: | 阶段 | 验证目标 | | ----------- | --------------------------------------- | | PKCE | Authorization Code 是否被原始 Client 使用 | | JWT 验签 | token 是否由这台盒子的懒猫 SSO 签发 | | iss / aud | token 是否适用于当前 App(`xu.deploy.env`) | | exp / nonce | token 是否仍然有效、是否对应这次登录 | | scope / groups | token 能做什么、用户是不是盒子主人 | 你会发现一个重要事实: > **PKCE 是懒猫 SSO 验证链中最早的一道闸门。** 如果这一关被绕过,后面所有校验都失去意义。 --- ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/131aadfd-600f-4770-86fe-9ea7a470601d.png "image.png") ## 3.10 本章结论(明确且不可妥协) 我们可以用几句话结束这一章: 1. Authorization Code 一定会暴露在浏览器环境,懒猫也不例外 2. 家庭网络 + NAS暴露公网,是现实中的常见组合 3. 没有 PKCE,code 等价于“谁拿到谁能用” 4. 懒猫注入的 `CLIENT_SECRET` 无法单独替代 PKCE 5. PKCE 把 code 与 Client 实例进行了数学绑定 6. 因此,PKCE 不是增强,而是懒猫 SSO 的 **成立条件** --- ### 本章小结(一句话) > **PKCE 的存在,说明懒猫 SSO 从一开始就假设: > 浏览器和家庭网络,永远不值得无条件信任。**

懒猫SSO第四章 JWT:声明,而不是加密

在第三章结束时,我们已经解决了一个关键问题: https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env > **懒猫的 Authorization Code 不会被随意偷用。** 通过 PKCE,ENV 查看器终于可以确信一件事: > **来 `/sys/oauth/token` 换 token 的请求,确实来自当初那个发起授权的 Client 实例。** 于是,流程自然向前推进。 ENV 查看器拿到了懒猫 SSO 返回的一组 token,其中最重要的一个是: ``` id_token ``` 在实际懒猫 SSO 返回里,它看起来像这样: ``` eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2In0. eyJpc3MiOiJodHRwczovL2FsaWNlLmhlaXl1LnNwYWNlL3N5cy9vYXV0aCIsInN1YiI6ImNpWUtIaGVpeXUiLCJhdWQiOiJ4dS5kZXBsb3kuZW52IiwiZXhwIjoxNzE2NDE5NzM0LCJpYXQiOjE3MTYzMzMzMzQsIm5vbmNlIjoieXl5IiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiQWxpY2UiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhbGljZSIsImdyb3VwcyI6WyJhZG1pbiJdLCJhdF9oYXNoIjoidGFxdl8uLiJ9. XK9k3... ``` 很多懒猫 App 在这一刻会做一件非常“自然”的事: > **把 token 贴到 [jwt.io](https://jwt.io) → 复制里面的 `email` → 建 session → 登录完成。** 但在我们继续之前,必须先解决一个极其关键、却经常被误解的问题: > **这个 token 到底是不是“加密的”?** --- ## 4.1 一个常见但危险的直觉 当人们第一次看到懒猫 SSO 吐出来的 JWT 时,几乎都会产生同一个直觉: > “这看起来像一段乱码, > 应该是加密过的吧?” 这种直觉非常危险。 因为它会直接导致一个错误的安全假设: > **“既然 token 是加密的,那把它放 URL、放 localStorage、打到 container log 里,都没事。”** 但事实是: > **JWT(更准确地说,懒猫 SSO 用的 JWS)默认不是加密的。** --- ## 4.2 JWT 的三段结构,意味着什么 一个标准的 JWT 由三部分组成: ``` header.payload.signature ``` 这三部分并不是“加密块”,而是: * header:算法、密钥标识(懒猫里是 `alg=RS256`, `kid=<某个 ID>`) * payload:一组声明(claims),包括 `iss` `sub` `aud` `exp` `email` `groups` …… * signature:对前两部分的数字签名 前两部分只是 **Base64URL 编码**。 这意味着一件非常重要的事情: > **任何人,只要拿到 token,都可以解码 header 和 payload。** 不需要密钥。 不需要破解。 不需要权限。 你随手在 jwt.io 上把懒猫 ID Token 粘进去,能看到: ```json { "iss": "https://alice.heiyu.space/sys/oauth", "sub": "ciYKHheiyu", "aud": "xu.deploy.env", "exp": 1716419734, "iat": 1716333334, "nonce": "yyy", "email": "alice@example.com", "email_verified": true, "name": "Alice", "preferred_username": "alice", "groups": ["admin"], "at_hash": "taqv_.." } ``` 全是明文。 --- ## 4.3 那为什么懒猫还敢把身份信息放在 token 里? 这是一个必须正面回答的问题。 如果 JWT 是明文的,那岂不是意味着: * 盒子主人的邮箱是公开的? * 用户所在的 `groups`(比如 `admin`)是公开的? * `preferred_username` 是公开的? 答案是: > **是的,这些信息对“任何拿到 token 的人”都是可读的。** 但这里隐藏着一个非常重要的区分: > **可读(readable) ≠ 可伪造(forgeable)** JWT 的设计,从一开始就没有试图解决“保密性”问题。 它要解决的是两个完全不同的问题: 1. **完整性**:内容有没有被改过(比如 `groups: ["admin"]` 有没有被塞进去) 2. **真实性**:这些声明是不是由**这台盒子的懒猫 SSO**发出的 这两个目标,**不需要加密**。 --- ## 4.4 JWT 的核心语义:声明(Claims) 现在我们可以重新定义 JWT 的本质了: > **ID Token 不是一段加密数据, > 而是一组“被懒猫 SSO 签名的声明”。** 每一个字段,都是一个声明: * `sub`:这个用户在懒猫 SSO 里的稳定 ID * `iss`:`https://alice.heiyu.space/sys/oauth`,说明是**这台盒子**签的 * `aud`:`xu.deploy.env`,说明是给 ENV 查看器的 * `exp`:声明什么时候失效 * `email` / `preferred_username` / `groups`:懒猫账号的基本信息懒猫 SSO 的安全模型是: > **“你可以看,但你不能改; > 你可以拿到,但你不能伪造。”** --- ## 4.5 为什么“签名”比“加密”更重要 我们来对比两个世界: ### 如果 ID Token 是加密的,但没有签名 * 攻击者看不到内容 * 但一旦解密,就无法确认 `groups: ["admin"]` 是不是原本就有 * 也无法确认是不是“这台盒子”发的 在一个多用户、多应用共享一个懒猫 SSO 的系统里,这是灾难。 --- ### 如果 ID Token 是签名的,但不加密(懒猫的现实情况) * 攻击者可以看到 `email`、`preferred_username`、`groups` * 但任何修改都会导致签名失效 * 没有懒猫 SSO 的私钥,无法生成“合法声明” 这恰恰符合家庭服务器这种 SSO 场景的需求。 --- ## 4.6 一个重要的设计判断: ### 懒猫并不假设 token 内容是秘密懒猫 SSO 的设计者非常清楚一件事: > **token 终究会出现在不完全可信的环境中。** 例如: * 浏览器 localStorage / sessionStorage * 前端 JavaScript * App 容器的日志系统(懒猫玩家热衷开 debug) * 你自己用 curl 调试时复制到终端 * Authorization 头经过 nginx / openresty 的访问日志 在这样的前提下,试图“依赖 token 的保密性”是脆弱的。 于是,懒猫遵循 OIDC 的设计,做了一个非常明确的选择: > **不把安全性建立在“别人看不到”上, > 而建立在“别人改不了、造不出”上。** --- ## 4.7 那为什么还要 HTTPS? 到这里,可能会有人产生另一个误解: > “既然 JWT 不靠加密,那懒猫给我装证书让 `heiyu.space` 走 HTTPS 还有什么用?” 这是对职责边界的混淆。 * HTTPS 负责: * 防窃听(防止邻居抓包看到 token) * 防中间人篡改(防止 WiFi 被劫持后替换 token) * 防 code 泄漏(URL 不走 HTTPS 的话,连 code 都保不住) * JWT 签名负责: * 防伪造(防止别的盒子上的 OP 签出来的 token 被误用) * 防内容被修改(防止 `groups: ["admin"]` 被塞进来) 它们解决的是**不同层面的问题**。 > **HTTPS 保护的是“传输”, > JWT 保护的是“声明”。** 懒猫两者都上,缺一不可。 --- ## 4.8 为什么懒猫 ID Token 默认不加密 在懒猫 SSO 中,ID Token 是: * JWS(JSON Web Signature),`alg=RS256` * **不是** JWE(JSON Web Encryption) 这不是因为“懒猫 SSO 做不到 JWE”,而是因为: 1. ENV 查看器、Memos、Vaultwarden……所有 App 都需要读 payload,不能每个都维护一把加密密钥 2. 读取行为本身不是安全风险 3. 安全风险来自“错误地信任声明” 如果你在这里误以为: > “只要 token 是加密的,就可以少校验 `iss` / `aud` / `nonce`” 那么你将直接跳过懒猫 SSO 最重要的安全设计。 --- ## 4.9 JWT 能解决什么,不能解决什么 在继续深入之前,我们必须明确 JWT 的边界。 ### JWT(懒猫 ID Token)能解决的: * 内容有没有被篡改 * 声明是不是由**某个 OP** 签发 ### JWT 不能解决的: * 声明是不是发给 `xu.deploy.env`(要靠 `aud`) * 声明是否仍然适用于当前请求(要靠 `exp` / `nonce`) * 声明是否被重放 * 声明是否来自**正确的** issuer(要靠 `iss`) * `groups: ["admin"]` 意味着什么业务权限(这是你的业务层) 这些问题,**全部在签名之外**。 --- ## 4.10 一个关键过渡: ### 从“能不能信 token”到“该不该信这些声明” 到目前为止,我们只解决了一件事: > **这个 token 是真的。** 但“真的”,并不等于“适用”。 一个合法签名的懒猫 ID Token,仍然可能是: * 发给盒子上**另一个 App** 的(`aud` 不是你) * 用在错误场景的(比如本应只给 Memos 的 token 被 ENV 查看器错误接受) * 已经过期的 * 被重复使用的 * 来自**另一台盒子**的懒猫 SSO(在多盒子、或者第三方 OIDC 对接的高级玩法里) 所以,从下一章开始,我们要进入 JWT 的**下一层意义**: > **验证的不再是 token 本身, > 而是 token 里的每一个声明。** --- ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/262ceaf9-e3cb-44ea-9f83-ab7efb56986a.png "image.png") ## 4.11 本章结论(必须非常明确) 我们可以用几句话,结束这一章: 1. 懒猫 ID Token 默认不是加密的,是 JWS(`RS256`) 2. JWT 的安全性来自签名,而不是隐藏 3. ID Token 是一组“被签名的声明” 4. 可读不是问题,错误信任才是问题 5. 把 token 贴到 jwt.io 解码**没有任何安全风险**——但你不能只靠那里看到的数据就登录用户 --- ### 本章小结(一句话) > **懒猫 ID Token 的设计目标不是“不让你看到”, > 而是“让你无法伪造”。**

懒猫SSO第一章: 一次“看起来很普通”的懒猫登录

> 在懒猫微服这种家庭服务器系统里,登录是一个被严重低估的动作。 > 因为它看起来实在太简单了。 https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env 你打开 `https://env.alice.heiyu.space/`,看到一个“使用懒猫账号登录”的按钮,点一下,浏览器跳到一个写着 **Grant Access** 的页面,你点同意,页面又跳回应用,头像出现在右上角。 几秒钟之内,一切完成。 如果我们只从“用户体验”的角度看,这个过程几乎没有任何值得讨论的地方。 懒猫把它做得越无感越好。 但如果你从**安全和系统设计**的角度看,这个过程恰恰是整个盒子里**风险最高、假设最多、也最容易被误解的部分**。 在这一章里,我们什么都不“深挖”。 我们只做一件事: > **完整走一遍一次标准的懒猫 SSO 登录流程, > 然后在每一个“看起来理所当然”的地方,埋下问题。** 这些问题,会在后面的章节里逐一被拆解。 --- ## 1.1 一个具体而普通的场景 我们先固定一个非常具体的例子,后面所有章节都会围绕它展开。 * 用户:`alice`,她是这台盒子的主人,邮箱 `alice@example.com` * 盒子名:`alice`,对外域名是 `alice.heiyu.space` * Client:**懒猫 ENV 查看器**,包名是 `xu.deploy.env` * manifest 里设置了 `subdomain: env` 和 `oidc_redirect_path: /callback` * 所以它自己的外部地址是 `https://env.alice.heiyu.space/` * 登录方式:Authorization Code Flow + PKCE(懒猫 SSO 只支持这一种 response_type) * IdP:懒猫 SSO(盒子自带的身份服务) * issuer:`https://alice.heiyu.space/sys/oauth` Alice 在浏览器里打开 ENV 查看器,看到一个熟悉的按钮: > **“使用懒猫账号登录”** 她点击了这个按钮。 --- ## 1.2 第一步:浏览器被重定向 点击按钮后,ENV 查看器的后端并没有自己处理登录。 它做的第一件事是: > **把浏览器重定向到一个“外部的地方”** 具体来说,是跳到懒猫 SSO 的授权端点: ``` GET https://alice.heiyu.space/sys/oauth/auth?... ``` 注意:域名前缀是 `alice.heiyu.space`,**而不是** `env.alice.heiyu.space`。 也就是说,浏览器**离开了应用自己的子域**,跳到了**盒子的主域**。 从浏览器视角看,只发生了一件事: ``` GET /authorize-like URL → 跳转到懒猫 SSO ``` 页面变了,子域变了,UI 也变成了懒猫那套统一的 Grant Access。 到这里,一切都非常合理。 ENV 查看器并不保存用户密码,也不直接验证身份。 它把这件事**外包**给盒子里的懒猫 SSO。 这也是懒猫 SSO 存在的意义: > **盒子里有一个角色专门负责“我是谁”, > 所有 App 都从它那里获取身份,而不是各自重新实现一遍登录。** --- ## 1.3 第二步:用户在懒猫 SSO 完成登录 Alice 可能早就在懒猫官方客户端登录过,session 还在, 那这一步她只会看到一个很短的 Grant Access 确认页,点一下同意就走。 也可能 session 过期了,她需要重新输入密码、或者走 2FA。 这些细节对 ENV 查看器来说是**不可见的**。 从 ENV 查看器的角度看,懒猫 SSO 做了三件事: 1. 验证用户身份 2. 询问用户是否同意把部分身份信息返回给 Client(基于 `scope`) 3. 准备一个“结果”,用于告诉 Client: > **“这个用户已经通过验证了”** 关键在于: **ENV 查看器并没有参与其中任何一步。** 它只能等待结果。 --- ## 1.4 第三步:浏览器“带着结果”返回 Client 登录完成后,懒猫 SSO 并不会直接把结果“发”给 ENV 查看器。 它做的仍然是一个浏览器跳转: ``` 302 Redirect → https://env.alice.heiyu.space/callback?code=abc123&state=xyz ``` 浏览器地址栏里,多了两个参数: * `code=abc123` * `state=xyz` 这个 `code`,就是所谓的 **Authorization Code**。 从流程上看,这是一个非常自然的设计: * 懒猫 SSO 不直接把最终凭证交给浏览器 * 只给一个短期、一次性的“中间凭证” * ENV 查看器再用这个凭证去后台换取真正的结果懒猫 SSO 对 authorization code 的有效期卡得很短(典型是几十秒),而且**一次性**——用过一次就作废。 到这里为止,大多数介绍 OIDC 的文章,都会轻描淡写地说一句: > “然后 Client 用 code 换取 token” 但我们先不要急着往下走。 我们先停在这里,问一个**非常基础、但经常被忽略的问题**。 --- ## 1.5 第一个被忽略的问题: ### 这个 code 是不是“安全的”? 此刻,这个 `code`: * 出现在浏览器 URL 中 * 经过了浏览器 * 经过了家庭网络 * 可能被 nginx / openresty 的日志记录(懒猫里反代很常见) * 可能被浏览器扩展读到 * 可能在 Referer 头里泄漏给其他请求 但在流程描述中,我们**默认**了一个前提: > **只有 ENV 查看器的后端能用这个 code。** 问题是: **这个前提成立吗?** 在这一章里,我们不回答这个问题。 但请你记住它。 因为这正是 **PKCE** 出现的原因——懒猫 SSO 把它写进了 `code_challenge_methods_supported: ["S256", "plain"]`,不是装饰。 --- ## 1.6 第四步:Client 用 code 换取 token ENV 查看器的后端(运行在盒子里的一个容器内)向懒猫 SSO 发起请求: ``` POST https://alice.heiyu.space/sys/oauth/token grant_type=authorization_code code=abc123 redirect_uri=https://env.alice.heiyu.space/callback code_verifier=... + client_secret_basic 认证(从 LAZYCAT_AUTH_OIDC_CLIENT_ID / _SECRET 读) ``` 注意这一步有一个非常重要的特点: > **它不经过浏览器,是容器到容器的内部 HTTPS 请求。** 懒猫 SSO 校验这个 code,然后返回一组 token: * `access_token` * `id_token` * `refresh_token`(需要请求了 `offline_access`) 这一步,标志着**真正的身份材料**已经被发出。 也是从这里开始,大多数应用会认为: > **“登录已经完成了。”** 但如果你站在 ENV 查看器的立场,这个结论其实来得太早。 --- ## 1.7 一个看起来“理所当然”的假设 现在,ENV 查看器手里拿到了一个 `id_token`。 它是一个字符串,看起来像这样: ``` eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2In0. eyJpc3MiOiJodHRwczovL2FsaWNlLmhlaXl1LnNwYWNlL3N5cy9vYXV0aCIsInN1YiI6ImFsaWNlIn0. XK9k3... ``` 文档告诉你: * 这是一个 JWT * 里面包含用户身份信息(`sub`、`email`、`preferred_username`、`groups`……) * 是懒猫 SSO 签发的,算法是 `RS256` 很多系统在这里会直接做一件事: > **解析 token → 取出 `email` 或 `preferred_username` → 创建 session** 这一步在功能上是可行的。 盒子里 99% 的情况下也确实不会出事。 但在安全上,它隐藏了**一整条未经验证的假设链**。 我们把这些假设逐条写出来: 1. 这个 token 真的是这个盒子的身份服务 发的 2. 中途没有被人修改 3. 是发给 `xu.deploy.env` 这个 Client 用的 4. 没有过期 5. 没有被重放 6. 返回结果对应的是 Alice 刚才那次登录,而不是她上一次登录残留的 token 7. `preferred_username` 没有被伪造成 `admin` 如果你发现: > **流程中并没有任何一步“自动保证”这些假设成立** 那你已经走在正确的方向上了。 家庭服务器的环境没有你想象的那么“封闭”—— 浏览器、内网其他设备、被邀请来的家人的手机,都不是纯净的执行环境。 --- ## 1.8 本章真正要问的问题 到目前为止,我们只是完整走了一遍 Happy Path。 没有攻击者、没有恶意插件、没有家里误装的奇怪扩展、没有被劫持的 WiFi。 但即便在这种“理想情况”下,ENV 查看器也只是**被动地接收结果**。 于是,本章最后我们只留下一个问题: > **ENV 查看器凭什么相信: > 这个 id_token 里写的 “alice”, > 就真的是刚刚那个在 Grant Access 页面点同意的 alice?** 这个问题,不能靠“协议说可以”。 也不能靠“懒猫帮我注入了 CLIENT_SECRET”。 更不能靠“反正是我家,没人攻击我”。 它只能靠**一套明确、可验证、可失败的逻辑链**来回答。 --- ## 1.9 接下来会发生什么 在接下来的章节中,我们会做三件事: 1. **在 token 出现之前,解决“code 会不会被偷用”的问题** —— 这就是 PKCE,也是懒猫 SSO 在 discovery 里明确支持 `S256` 的原因 2. **在 token 出现之后,解决“token 是不是可信”的问题** —— JWT、`RS256`、公钥、JWKS(`/sys/oauth/keys`) 3. **在使用 token 之前,解决“我该不该信这个声明”的问题** —— `iss`、`aud`、`exp`、`nonce`、`groups` 等校验懒猫 SSO 的安全性,不在某一个点上。 而在于:**这些问题一个都没有被跳过。** --- ### 本章小结(不讲技术,只讲直觉) > 登录看起来像一个动作, > 但在盒子里,它其实是一条验证链的起点。 > > 在你理解这条验证链之前, > “登录成功”这四个字,本身是不完整的。 ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/83b8505c-eb61-4814-b8e8-6ab797edd9d6.png "image.png")

懒猫SSO 第二章:为什么 Client 不能信懒猫“返回结果”

在第一章里,我们刻意做了一件事: **完整走完了一次“正常的懒猫 SSO 登录流程”,但没有解释任何安全细节。** https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env 如果你回看那个流程,会发现一个非常危险的现象: > 整个过程中,ENV 查看器几乎没有“主动验证”任何东西。 它只是: * 把浏览器重定向到 `/sys/oauth/auth` * 等待浏览器跳回 `/callback` * 拿到一个 `code` * 换回一组 token 然后,很多懒猫 App 就直接宣布: > **“登录成功。”** 这件事之所以看起来合理,是因为我们在脑海中默认了一整套“看不见的前提”。 而这一章的目的,就是把这些前提**全部显性化**。 --- ## 2.1 一个被默认的信任模型 当 ENV 查看器收到一个 `id_token` 时,通常会发生如下对话(虽然没有写在任何代码里): * 懒猫 SSO 说: > “这个 token 代表用户 alice。” * ENV 查看器心里想: > “好,那她就是 alice,还是盒子主人,那我给她管理员视图。” 这个逻辑的问题不在于“不合理”,而在于: > **它跳过了所有“为什么我应该相信你”的问题。** 在安全系统里,任何跳过“为什么”的设计,都会在后面以事故的形式补回来。 而在家庭服务器这种**主人和被邀请家人共用一个盒子**的场景下, “我该不该把 Alice 当成管理员”这个判断,一旦出错,后果不可逆。 --- ## 2.2 把隐含假设全部摊开 我们先不谈攻击者。 只从逻辑完整性出发,把 ENV 查看器在“信任登录结果”时,**隐含接受的假设列出来**: 1. 返回的 token 真的是**这台盒子上的** 懒猫 SSO 发的 2. token 在传输过程中没有被修改 3. token 不是别人伪造的 4. token 是发给 `xu.deploy.env` 这个 Client 用的,而不是发给盒子上**另一个应用**的 5. token 还在有效期内 6. token 对应的是刚才那次登录,而不是别的历史请求 7. 浏览器没有被篡改 8. 家庭内网没有被劫持 你会发现一个事实: > **流程中,并没有任何一步“天然保证”这些假设成立。** 换句话说: > **流程跑通 ≠ 假设成立** --- ## 2.3 为什么“看起来没问题”的系统依然不安全 很多懒猫 App 开发者第一次接触这个问题时,会产生一个非常自然的反应: > “这些假设在家庭内网里几乎总是成立的吧?我家又没有 APT。” 这正是问题的核心。 安全系统设计的第一原则是: > **不要基于“通常如此”来建立信任。** 尤其在懒猫这种场景下,“通常”其实比你想的更脆弱: * 盒子可能被暴露公网(很多人开了 `heiyu.space` 的公网访问) * 盒子上跑着**多个应用**,它们共享同一个懒猫 SSO * 被邀请的家人带进来的设备你无法控制 * 一些“炫技”级玩法(自签 SSL、自建反代、第三方 OIDC 对接)会破坏默认安全假设 OIDC 的设计,建立在一个非常保守的前提之上: > **假设浏览器、网络、中间环境,全部是不可信的。** 懒猫 SSO 继承了这个前提,你作为 App 开发者也必须继承。 --- ## 2.4 Client 真的“什么都没验证”吗? 到这里,你可能会反问一句: > “那 HTTPS 呢?我的 `alice.heiyu.space` 是懒猫官方给我签的证书, > 不是已经很安全了吗?” 这是一个非常关键的问题。 HTTPS 确实解决了很多问题,但它解决的是: * 浏览器 ↔ 懒猫 SSO 之间的**传输安全** * 容器 ↔ 懒猫 SSO 之间的**传输安全** * 防止明文被窃听 * 防止简单的中间人篡改 但 HTTPS **不解决**下面这些问题: * 返回的 token 是不是给 `xu.deploy.env` 的,还是给盒子上别的 App 的 * token 是否来自**这台盒子**的懒猫 SSO,而不是攻击者在另一台盒子上部署的懒猫 SSO * token 是否是旧的、被重放的 * 浏览器环境是否泄露了中间 code 也就是说: > **HTTPS 保护的是“通道”, > 懒猫 SSO 必须解决的是“内容”。** --- ## 2.5 为什么 Client 不能“信懒猫就好了” 另一个常见的直觉是: > “我信任懒猫这个盒子,那懒猫返回的结果我就直接信。” 这句话在逻辑上是**不完整的**。 因为它混淆了两个不同层面的“信任”: 1. **信任懒猫 SSO 这个身份源** 2. **信任某个具体结果来自这个懒猫 SSO、且适用于当前 App** 即使你 100% 信任懒猫 SSO 本身,也不能跳过第二层。 原因很简单: > **同一个懒猫 SSO 同时为盒子上所有 App 服务。** 如果 ENV 查看器不明确验证: * “这是不是发给我(`xu.deploy.env`)的?” * “这是不是 Alice 刚才那次登录的结果?” 那么一个**合法但不相关的结果**,就可能被错误使用。 比如:另一个 App(哪怕是善意的)拿到了懒猫 SSO 发给它自己的 token, 阴差阳错传给了 ENV 查看器, ENV 查看器如果不检查 `aud`,就会**用一个不属于自己的 token 完成登录**。 这不是科幻。在懒猫这种“应用彼此能互联”的架构下,`aud` 检查是**基础操作**。 --- ## 2.6 一个非常重要的转折点: ### 信任不是一个状态,而是一个过程 到目前为止,我们其实已经得到一个非常重要的结论: > **“登录成功”不是一个瞬间事件, > 而是一个逐步建立的信任过程。** 这个过程不是靠“感觉安全”,而是靠一连串明确的验证步骤。 我们可以把它抽象成一句话: > **懒猫 SSO 的核心不是“身份声明”, > 而是“身份声明的验证链”。** 懒猫只是把这条链的**起点和工具**替你准备好了(注入环境变量、生成 client_secret、暴露 discovery), 但**链上的每一环**都还是你自己负责执行的。 --- ## 2.7 验证链从哪里开始? 如果你回到第一章的流程,会发现一个关键事实: > 在 token 出现之前, > ENV 查看器已经接收过一个“关键输入”。 那就是: ``` https://env.alice.heiyu.space/callback?code=abc123&state=xyz ``` 这个 `code`,决定了 ENV 查看器能否拿到后续的一切。 于是,一个新的问题浮现出来: > **如果 code 本身被滥用, > 后面再严格的 token 校验,还有意义吗?** 这不是一个修辞问题。 这是 **PKCE** 出现的直接原因,也是懒猫 SSO 为什么在 discovery 里明确声明 `code_challenge_methods_supported: ["S256", "plain"]` 的原因。 --- ## 2.8 本章的结论(明确而不留余地) 我们可以用几句话,把这一章的核心结论说清楚: 1. ENV 查看器不能因为“流程跑通”而信任登录结果 2. 所有信任都必须来自**显式验证** 3. 验证必须覆盖流程的每一个关键输入 4. 验证链必须从 **token 出现之前** 就开始 5. “懒猫帮我注入了 CLIENT_SECRET”不是跳过验证的理由 换句话说: > **如果你只验证 token, > 那你已经晚了一步。** --- ## 2.9 接下来我们要做什么 在下一章中,我们将回到那个被忽略的问题: > **Authorization Code 暴露在浏览器里, > 为什么在懒猫场景下还敢用?** 我们会看到: * code 被拦截在家庭网络里不罕见(家里装了奇怪插件、内网多设备、公网暴露) * 纯前端 SPA 型懒猫 App 无法保密任何 secret * 懒猫虽然给你注入了 `CLIENT_SECRET`,但在某些场景下它实际上是 public 的 * PKCE 如何把一个“谁拿到都能用”的 code,变成“只能被最初那个 Client 使用” 这是 **懒猫 SSO 验证链的第一道真正闸门**。 --- ### 本章小结(一句话) > **懒猫 SSO 的安全性,不来自“我信任懒猫”, > 而来自“我验证过这次结果”。** ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/9886d21d-ab96-45eb-b87b-ccecfbbd8cc9.png "image.png")

# 懒猫SSO第五章 签名:Client 凭什么相信懒猫的这些声明

在第四章,我们已经澄清了一件非常重要的事: https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env > **懒猫 ID Token 不是加密的。 > 它是一组“被懒猫 SSO 签名的声明”。** 这句话看起来很简单,但它隐含了一个巨大的问题: > **如果任何人都能看到 `groups: ["admin"]`, > ENV 查看器又凭什么相信这些声明是真的?** 换句话说: > **签名到底在“证明”什么?** 这一章,我们就只回答这一件事。 --- ## 5.1 从一句最容易被误解的话开始 在很多讨论中,你可能见过这样一句话: > “懒猫 SSO 用私钥加密,Client 用公钥解密。” 这句话**在懒猫 SSO / OIDC 语境下是错误的**。 它把两件完全不同的事情混在了一起: * **加密(Encryption)** * **签名(Signature)** 如果你不在这里把这两个概念彻底分开,后面所有关于 JWT、JWKS(`/sys/oauth/keys`)、ID Token 校验逻辑的理解都会偏掉。 --- ## 5.2 加密 vs 签名:这是两套安全模型 我们先用最严格、最不含糊的方式区分这两件事。 ### 加密(Encryption) 目标只有一个: > **让别人看不到内容** 典型模型是: * 发送方用接收方的公钥加密 * 只有接收方能用私钥解密 这是 **保密性(Confidentiality)**。 --- ### 签名(Signature) 目标完全不同: > **证明“这是谁发的”,以及“中途有没有被改”** 典型模型是: * 声明方(懒猫 SSO)用自己的私钥签名 * 任何人(ENV 查看器、Memos、Vaultwarden……)都可以用公钥验证 这是 **真实性(Authenticity)+ 完整性(Integrity)**。 --- ### 懒猫 ID Token 用的是哪一个? > **只用签名。** 懒猫 SSO 在 discovery 里写得明明白白: ```json "id_token_signing_alg_values_supported": ["RS256"] ``` 没有 `alg_values_supported` 里的加密算法字段,也没有 JWE 相关的任何东西。 懒猫的设计从一开始就**不试图隐藏内容**。 它只试图证明一件事: > **“这组声明,确实是由 `https://alice.heiyu.space/sys/oauth` 这个懒猫 SSO 发出的,而且没有被修改。”** --- ## 5.3 签名要解决的核心问题 站在 ENV 查看器的角度,签名必须同时解决三个问题: 1. **来源问题** > 这些声明是不是**这台盒子的** 懒猫 SSO 发的?(不是别的盒子、不是自建的假 IdP) 2. **篡改问题** > 中途有没有人把 `groups: ["family"]` 改成 `groups: ["admin"]`? 3. **不可伪造问题** > 攻击者能不能自己造一个“看起来合法”的 token,直接塞给 ENV 查看器? 如果签名无法同时解决这三个问题,那整个懒猫 SSO 的安全模型就会崩塌—— 毕竟所有 App 都信懒猫,懒猫只要一处可伪造,全盒子的应用都可以被绕过。 --- ## 5.4 签名并不是“对内容做点处理” 我们来看一个懒猫 ID Token 的真实签名结构(`RS256`): ``` header.payload.signature ``` 其中: * `header.payload` 是**完全可读的** * `signature` 是唯一的安全锚点 签名不是“把 payload 加密一下”, 而是一个**数学等式的结果**。 --- ## 5.5 签名在数学上到底做了什么(不绕弯) 我们用最直白、但不偷懒的方式,把懒猫 ID Token 的签名过程拆开。 ### Step 1:确定要被保护的数据 在 JWT 中,被签名的数据是: ``` base64url(header) + "." + base64url(payload) ``` 注意三点: 1. 是 **编码后的字符串** 2. 顺序固定 3. 任意一个字符改变,结果都会变 --- ### Step 2:对数据做 Hash(摘要) `RS256` 里的 `256` 对应 SHA-256。 对这段字符串做一次哈希运算: ``` hash = SHA256(data) ``` 哈希的意义只有一个: > **把任意长度的数据,映射成一个固定长度、不可逆、对变化极其敏感的摘要。** 只要原始数据有 1 bit 变化,hash 就完全不同。 --- ### Step 3:用懒猫 SSO 的私钥对 hash 进行签名 这一步才是真正的“签名”: ``` signature = RSA-Sign(hash, sso_private_key) ``` 这里的关键点是: * **只有懒猫 SSO(跑在盒子里的那个进程)**持有这个私钥 * 签名过程是单向的 * 没有这把私钥,无法伪造一个“能通过验证的 signature” 懒猫官方会在升级、Key Rotation 的时候轮换这把 key,但不会把它泄漏给任何 App。 如果你在 App 里“需要懒猫 SSO 的私钥”,那你已经做错了一件事。 --- ### Step 4:Client 用公钥验证 ENV 查看器拿到 token 后,会做对称的操作: 1. 用同样的方式计算 `hash_client = SHA256(base64url(header) + "." + base64url(payload))` 2. 从 `https://alice.heiyu.space/sys/oauth/keys` 拉到的 JWKS 里,找到 header.`kid` 对应的公钥 3. 用公钥验证 `signature` 是否对应这个 hash 如果验证通过,数学上意味着一件事: > **这个 signature 只能来自对应的私钥, > 而这个私钥只在盒子里的身份服务 手里。** --- ## 5.6 为什么不能伪造签名 这是整个懒猫 SSO 信任链的核心。 攻击者即使: * 完全拿到 header 和 payload(反正它们本来就明文) * 完全知道算法(懒猫 discovery 里大大方方写着 `RS256`) * 完全知道公钥(`/sys/oauth/keys` 对全世界公开) 他依然做不到一件事: > **生成一个能通过验证的 signature** 因为 RSA 签名算法的安全性,建立在一个非常强的前提上: > **从公钥推导私钥,在计算上是不可行的。** 这不是工程约定,而是现代密码学的基础假设。 --- ## 5.7 为什么“改 1 个字符”就会彻底失败 这是懒猫 ID Token 安全性的第二个关键点。 如果攻击者尝试修改 payload 中的任何字段,比如把: ```json "preferred_username": "alice", "groups": ["family"] ``` 改成: ```json "preferred_username": "alice", "groups": ["admin"] ``` 会发生什么? * `payload` 改变 * `base64url(header) + "." + base64url(payload)` 改变 * `hash` 改变 * 但 `signature` 没变(攻击者没有私钥重签) 结果只有一个: > **签名验证失败。** 这保证了: > **懒猫 SSO 声明一旦被签名,就不可被悄悄篡改。** 这也意味着:**你可以、并且应该信任 `groups` 字段**。只要签名验证通过,它就是懒猫原始发出的样子。 --- ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/2d1b5875-c4ec-493c-ab85-07bd2c898aa1.png "image.png") ## 5.8 签名解决的,只是“真实性”和“完整性” 到这里,我们必须再次强调签名的边界。 签名能证明: * 声明来自**某个** OP * 声明未被修改 但它**不能证明**: * 声明是不是发给 `xu.deploy.env` 的(那是 `aud`) * 声明是否已经过期(那是 `exp`) * 声明是否对应你这次登录(那是 `nonce`) * 声明是否被错误使用 * 声明来自的是**这台盒子**的懒猫 SSO 还是别的(那是 `iss`) 换句话说: > **签名只能回答“这是真的吗”, > 不能回答“这该不该信”。** --- ## 5.9 一个容易被忽略但致命的误解 很多懒猫 App 里会出现这种错误逻辑: > “既然签名验证通过了,那 token 就是安全的,我就可以直接读 `preferred_username` 登录用户了。” 这是**不成立的**。 在懒猫 SSO 里: * **签名验证是必要条件** * **但远远不是充分条件** 你可以把它理解成: > **签名只是“验明正身”, > 而不是“授权使用”。** ## 5.10 本章结论(必须钉死) 我们用几条不可模糊的结论结束这一章: 1. 懒猫 ID Token 使用的是数字签名(`RS256`),而不是加密 2. 签名保证来源真实性和内容完整性 3. 懒猫 SSO 的私钥决定“谁能签”,公钥(发布在 `/sys/oauth/keys`)决定“谁能验证” 4. 没有懒猫 SSO 的私钥,就无法伪造任何合法 token 5. 签名通过 ≠ token 可以被使用 --- ### 本章小结(一句话) > **签名证明“这些声明只能来自这台盒子的懒猫 SSO”, > 但不证明“你现在就该相信它们”。**

一篇讲清懒猫 SSO:从 /sys/oauth/auth 到 ID Token 校验与常见报错

> 懒猫 SSO(懒猫微服自带的 OIDC 身份服务)并不是懒猫微服给你贴的“登录按钮”, > 而是一套 **在你自己的家庭服务器这种“半可信环境”中,为所有应用建立统一身份信任的工程流程**。 > > 如果你只记住懒猫帮你注入了哪几个环境变量,却不理解每一步在防什么, > 这套 SSO 会“看起来能跑”,但迟早会在回调地址、token 校验、多用户等边界条件上出问题。 https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env 这篇文章从**端到端流程**出发,基于懒猫微服实际暴露的懒猫 SSO 端点,完整梳理: * 懒猫 SSO 里的角色与核心概念(Client = 你的懒猫 App / IdP = 懒猫 SSO / User = 懒猫账号) * `lzc-manifest.yml` 注入的那几个环境变量到底对应 OIDC 的哪些字段 * Authorization Code + PKCE 在懒猫里的完整时序 * scope、JWT / JWS、换 token、claims 校验 * 最常见、也最真实的报错与排查思路 下面所有例子都围绕同一个真实应用展开——**懒猫 ENV 查看器(`xu.deploy.env`)**,部署在一个叫 `alice` 的盒子上,对外域名是 `alice.heiyu.space`,回调路径是 `/callback`。 ## 1. 懒猫 SSO 的角色与核心概念(速读版) 在任何基于懒猫 SSO 的应用里,都至少有这三类角色: * **Client**:你的懒猫 App * 后端 Web / 有独立容器的应用 —— Confidential Client(`client_secret` 由懒猫注入到容器环境变量) * 纯前端 SPA —— 严格意义上是 Public Client,但因为懒猫把 `CLIENT_SECRET` 也注入进来了,在实操中介于两者之间(后面会专门讲这件事的边界) * **OP / IdP(OpenID Provider)**:懒猫 SSO,盒子上自带的身份服务 * 入口永远是 `https://<盒子名>.heiyu.space/sys/oauth` * **User**:懒猫账号(盒子主人 + 被邀请的家庭成员) 几组**必须分清的概念**: * **ID Token**:身份声明(你是谁,是不是盒子主人,邮箱多少) * **Access Token**:访问资源的授权凭证(能调哪些 API) * **Refresh Token**:用来续签,长生命周期,必须有 `offline_access` scope 才会下发 * **Scope**:Client 请求的授权范围(懒猫支持 `openid email groups profile offline_access`) * **Claims**:token 里的具体声明字段(懒猫 SSO 声明支持 `iss sub aud iat exp email email_verified locale name preferred_username at_hash`) * **JWT / JWS**: * JWT 是格式 * JWS 是“签名后的 JWT”(懒猫 ID Token 的常态,签名算法是 `RS256`) 一句话记忆: > **懒猫 SSO = OAuth 2.0 + 身份层(ID Token)+ 懒猫替你注入的 Client 凭证** ## 2. 端到端主流程(Authorization Code + PKCE) 懒猫 SSO discovery 里明确写了 `"response_types_supported": ["code"]`, 也就是说——**在懒猫上,除了授权码模式,别无选择**。 这也是现在最推荐、最安全的一种模式,适用于 Web、SPA、家庭内网应用。 我们按真实时序走一遍。 ### Step 1:Discovery(发现懒猫 SSO 的能力) 容器启动时,或应用首次处理登录时,会访问: ``` GET https://alice.heiyu.space/sys/oauth/.well-known/openid-configuration ``` 得到一份元数据,核心字段是这些(这段是**实际返回**,不是示意): ```json { "issuer": "https://alice.heiyu.space/sys/oauth", "authorization_endpoint": "https://alice.heiyu.space/sys/oauth/auth", "token_endpoint": "https://alice.heiyu.space/sys/oauth/token", "jwks_uri": "https://alice.heiyu.space/sys/oauth/keys", "userinfo_endpoint": "https://alice.heiyu.space/sys/oauth/userinfo", "device_authorization_endpoint": "https://alice.heiyu.space/sys/oauth/device/code", "introspection_endpoint": "https://alice.heiyu.space/sys/oauth/token/introspect", "grant_types_supported": [ "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange" ], "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "code_challenge_methods_supported": ["S256", "plain"], "scopes_supported": [ "openid", "email", "groups", "profile", "offline_access" ], "token_endpoint_auth_methods_supported": [ "client_secret_basic", "client_secret_post" ], "claims_supported": [ "iss", "sub", "aud", "iat", "exp", "email", "email_verified", "locale", "name", "preferred_username", "at_hash" ] } ``` **非常重要的一点**: > 后续所有校验,都会以这里返回的 `issuer` 为“信任锚点”。 在懒猫里,`issuer` 的规则是: ``` https://<盒子名>.heiyu.space/sys/oauth ``` **常见坑**: * 把 `issuer` 配成 `https://alice.heiyu.space/sys/oauth/`(尾部多斜杠)→ 后面 `iss` 校验直接失败 * 把 `issuer` 写成 `https://alice.heiyu.space/`(漏掉 `/sys/oauth`)→ discovery 拿不到 * 应用跑在容器里,**别**去硬编码域名,而是用懒猫注入的 `LAZYCAT_AUTH_OIDC_ISSUER_URI` --- ### Step 2:/sys/oauth/auth(浏览器跳转) 这是用户“看到懒猫授权页面”的那一步。 一个典型的跳转 URL(由 Client 拼出来,让浏览器跳过去): ``` GET https://alice.heiyu.space/sys/oauth/auth? response_type=code &client_id=xu.deploy.env &redirect_uri=https%3A%2F%2Fenv.alice.heiyu.space%2Fcallback &scope=openid%20email%20profile &state=... &nonce=... &code_challenge=... &code_challenge_method=S256 ``` 关键点说明: * `client_id` * 懒猫里 **等于应用包名**,比如 `xu.deploy.env`。容器里从 `LAZYCAT_AUTH_OIDC_CLIENT_ID` 读 * `redirect_uri` * 由 `lzc-manifest.yml` 里的 `application.oidc_redirect_path` 决定,懒猫会把它拼成 `https://<subdomain>.<盒子>.heiyu.space/<path>` * `openid` scope * **必须有**,否则退化成纯 OAuth,懒猫 SSO 不会下发 ID Token * `state` / `nonce` * CSRF + 重放防护,两者都是 Client 自己生成、自己校验 * `code_challenge` * PKCE 的 public 半(后面第三章详细讲) 这一跳之后,浏览器会被懒猫 SSO 接管,用户看到的是懒猫那套统一的“登录 / 授权确认”页面(Grant Access)。 **常见报错**: * `invalid_redirect_uri` * `redirect_uri` 不在白名单 —— 在懒猫里,白名单是用 `oidc_redirect_path` 隐式注册的,你没在 manifest 里写,就不会被允许 * `invalid_scope` * 忘了 openid,或请求了 `scopes_supported` 之外的 scope(比如自己脑补的 `admin`) * `unauthorized_client` * 应用在懒猫上根本没启用 OIDC(manifest 里没写 `oidc_redirect_path`) --- ### Step 3:回调(拿到 code) Grant Access 完成后,浏览器被重定向回: ``` https://env.alice.heiyu.space/callback?code=abc123&state=xyz ``` Client 必须做的第一件事: * **校验 `state`**(对上第 2 步生成的那个,对不上直接拒绝,不要进入 /token) **常见情况**: * `access_denied` * 用户点了拒绝 * `login_required` / `interaction_required` * 懒猫要求重新登录(比如 session 过期、换用户) --- ### Step 4:/sys/oauth/token(换 token) 这是 **Client 容器与懒猫 SSO 的直连请求**(从用户浏览器看不到)。 ```http POST https://alice.heiyu.space/sys/oauth/token Content-Type: application/x-www-form-urlencoded Authorization: Basic base64(client_id:client_secret) # client_secret_basic grant_type=authorization_code &code=abc123 &redirect_uri=https%3A%2F%2Fenv.alice.heiyu.space%2Fcallback &code_verifier=zzz ``` 其中: * `client_id` = `LAZYCAT_AUTH_OIDC_CLIENT_ID` * `client_secret` = `LAZYCAT_AUTH_OIDC_CLIENT_SECRET` * 懒猫 SSO 支持 `client_secret_basic` 和 `client_secret_post` 两种方式,任选 **PKCE 校验发生在这里**: ``` SHA256(code_verifier) == bound_challenge ? ``` 返回体典型形态: ```json { "access_token": "eyJ...", "id_token": "eyJ...", "refresh_token": "...", // 只有带了 offline_access 才会有 "token_type": "bearer", "expires_in": 86400 } ``` **最常见报错(真实世界第一名)**: * `invalid_grant` * code 过期(懒猫 SSO 默认几十秒) * code 已用过(授权码是**一次性**的) * redirect_uri 和第 2 步不一致(字符级精确匹配,包括结尾斜杠) * `code_verifier` 不匹配 👉 **90% 的懒猫 SSO 排障时间,都花在这里** --- ## 3. Token 到手以后:你必须做的校验 拿到 token ≠ 可以直接 `jwt.decode(id_token)["email"]` 然后登录用户。 ### 3.1 验签(JWS) ID Token 是 JWS,结构是: ``` header.payload.signature ``` 验签流程是固定的: 1. 从 header 取 `alg`(懒猫固定 `RS256`)、`kid` 2. 从 `jwks_uri`(`https://alice.heiyu.space/sys/oauth/keys`)拉 JWKS 3. 用 `kid` 找到公钥 4. 验证签名 **必须注意的反模式**: * ❌ 信任 token header 里的 `jku` / `x5u`(永远从 discovery 绑定的 `jwks_uri` 去拿) * ❌ 接受 `alg=none` * ❌ 用 `LAZYCAT_AUTH_OIDC_ISSUER_URI` 以外的地方“凑”出 jwks 地址 --- ### 3.2 Claims 校验(ID Token Validation) 这是懒猫 SSO **最核心、也最容易被省略的一步**。 **必须校验的 claims**: * `iss` * 必须等于 `https://alice.heiyu.space/sys/oauth`(即 `LAZYCAT_AUTH_OIDC_ISSUER_URI`) * `aud` * 必须包含 `LAZYCAT_AUTH_OIDC_CLIENT_ID`(也就是你的 App 包名,如 `xu.deploy.env`) * `exp` * 当前时间 < exp(允许 clock skew,家庭服务器时钟不准也算常见情况) * `nonce` * 必须等于你最初生成并保存在 session 里的 nonce * `azp` * 多 aud 场景下必须等于 client_id 一句非常重要的话: > **签名验证通过,只能说明 token 是懒猫 SSO 发的, > 不能说明 token 现在可以被你用。** --- ## 4. Scope 到底在干什么? Scope 经常被误解成“权限”。在懒猫里,它真正的语义是: > **Client 向懒猫 SSO 声明的授权意图范围** 懒猫 `scopes_supported` 就 5 个: * `openid` * 表示“我要做身份认证”,没有它就退化成纯 OAuth * `email` * 允许返回 `email`、`email_verified` * `profile` * 允许返回 `name`、`preferred_username`、`locale` * `groups` * 懒猫最有价值的一个 —— 能拿到用户属于哪些分组(比如“主账号”“家庭成员”),用来做业务层的角色判断 * `offline_access` * 允许下发 `refresh_token` **重要澄清**: * Scope ≠ 懒猫里的“用户角色” * Scope ≠ 业务权限 正确做法是: ``` Scope → claims(特别是 groups / preferred_username) → 你自己的业务权限判断 ``` --- ## 5. 各种授权模式怎么选? 在懒猫里这张表其实很短,因为懒猫 SSO 已经替你做了决定: | 模式 | 在懒猫里能不能用 | 用途 | | ------------------------- | ----------------- | ------------------ | | Authorization Code + PKCE | ✅ 默认 | Web / SPA / 内置应用 | | Client Credentials | ❌ 懒猫 SSO 没在 `grant_types_supported` 里开 | — | | Device Code | ✅ 有 `device_authorization_endpoint` | CLI / 智慧屏这类无浏览器场景 | | Refresh Token | ✅ 需要 `offline_access` | 会话续期 | | Token Exchange | ✅ 高级用法 | 跨应用换 token | | Implicit | ❌ | 已过时 | **经验法则**: > 在懒猫上,**默认就选 Authorization Code + PKCE**。其他模式有特殊需求再上。 --- ## 6. 端到端常见报错与排查思路 ### `invalid_grant` * 优先检查: * `redirect_uri` 是否和第 2 步**字符级一致**(尤其是端口、尾部斜杠、大小写) * code 是否被用过(很多人在 callback 路由上写了重试逻辑,第二次触发就一定 400) * PKCE verifier 是否跨 session 丢了(典型场景:callback 用了不同的进程 / cookie 丢了) ### `invalid_client` * `LAZYCAT_AUTH_OIDC_CLIENT_SECRET` 没读对(经常是容器没拿到环境变量) * client 认证方式错了:懒猫只支持 `client_secret_basic` 和 `client_secret_post`,别用 `none` ### `invalid_scope` * 忘了 openid * 请求了 `scopes_supported` 之外的 scope ### `unauthorized_client` * `lzc-manifest.yml` 里根本没写 `application.oidc_redirect_path`,懒猫没有为你这个包名注册 client ### `login_required` / `interaction_required` * 懒猫 session 过期、用户被切换,需要重新跳一次 `/sys/oauth/auth` --- ## 7. 一句话总结懒猫 SSO 的工程本质 如果你一路走完,会发现: * PKCE 防的是 **code 被偷** * 签名(JWKS)防的是 **token 被伪造** * `iss` / `aud` / `nonce` 防的是 **token 被误用** * Scope 控制的是 **授权边界** * `lzc-manifest.yml` 里那一行 `oidc_redirect_path`, 是你**加入懒猫信任链的唯一开关** 懒猫 SSO 的安全性,不在某一个字段, 而在**一整条不可跳过的验证链**。 --- ## 结语 > **懒猫 SSO 从来没有承诺“帮你搞定登录”, > 它只是把懒猫 SSO 封装成了“不用你填 client 表单”的形态, > 给了你一套: > 在家庭服务器这种半可信环境里, > 逐步建立有限信任的工程方法。** 只要你能回答: > “我为什么在这里相信这个懒猫账号?” 你就已经在**正确地使用懒猫 SSO**了。 后面九章,会把这张流程图拆开: * 第一章:一次“看起来很普通”的懒猫登录 * 第二章:为什么 Client 不能信懒猫“返回结果” * 第三章:PKCE 在懒猫场景下为什么不能省 * 第四章:ID Token 是声明,不是加密 * 第五章:签名——Client 凭什么相信懒猫的声明 * 第六章:JWKS——`/sys/oauth/keys` 到底在干什么 * 第七章:ID Token Validation——签名通过还不够 * 第八章:SDK 边界——懒猫注入的变量只是地基 * 第九章:Scope 与 Consent——懒猫的 `groups` 不是你的权限 * 第十章:失败案例与设计哲学 附录 A、B:Scope 在流程中的位置;state / nonce 对照。 ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/f495eb01-e6c6-4594-a71f-df698c8148a8.png "image.png")

懒猫微服开发篇(四):懒猫微服如何使用 OpenID Connect (OIDC)?(上)

OpenID Connect(OIDC)是一个基于 **OAuth 2.0** 的身份认证协议,允许用户使用一个账号(如 Google、微信、Microsoft 账号)登录多个不同的网站或应用,而无需重复注册。我们经常把他和**OAuth 2.0** 混为一谈, 它主要用于 **身份认证(Authentication)**,而 OAuth 2.0 主要用于 **授权(Authorization)**。简单来说: - **OAuth 2.0** → 让应用能访问你的数据(如获取微信头像),还要自己做用户管理。 - **OIDC** → 让应用能确认“你是谁”(如用微信账号登录) 下面以我的懒猫 ENV 查看器为例,来讲解这个登录流程。 当你在应用处点击登录就会重定向到登录中心,我们通常管这个叫做身份提供商(IDP),如果是其他的软件有可能是 **“使用 Google 登录”** 或 **“微信登录”** 。 ![image-20250702210408529](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/image-20250702210408529.png) 跳转到认证中心,一般都会提示你是否确认登录,某某应用将要获取登录的权限,查看你的信息。在懒猫微服里这直接点击 Grant Access 即可。在其他的 IDP 中,会让你输入账号密码登录,并同意授权该网站访问你的基本信息(如邮箱、昵称)。 ![image-20250702210333539](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/image-20250702210333539.png) 当 IDP 验证完的身份后,返回一个 **JWT(JSON Web Token)**,其中包含你的身份信息。当网站验证 JWT 后,确认你的身份,并让你登录成功。我们也可以在 jwt.io 和 jwt.ms 这个网站去做解码。 我解码了其中一个 token,我们可以看到里面的信息,可以看到加密算法,颁发机构,过期时间,用户信息什么的, ![image-20250702212449226](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/image-20250702212449226.png) 这个是一般登录的流程,比如首次用户名和密码登录成功之后会返回一个 JWT,然后后续把这个 JWT 当做 bear token 来请求后面的资源。我们的 OIDC 和这个原理类似,只不过稍微复杂一些。 ![7aed62612920d1042f175cb87c1f049e](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/7aed62612920d1042f175cb87c1f049e.png) 在 `OIDC` 协议中,会遇到三种 Token: `id_token`, `access_token` 和 `refresh_token`。 1. Access Token 用于基于 Token 的认证模式,允许应用访问一个资源 API。用户认证授权成功后,Authing 会签发 Access Token 给应用。应用后面就带着这个** Access Token** 访问资源 API。 2. ID Token 相当于用户的身份凭证,开发者的前端访问后端接口时可以携带 **ID Token**,**开发者服务器**可以校验用户的 **ID Token** 以确定用户身份,[验证](https://docs.authing.cn/v2/guides/faqs/how-to-validate-user-token.html)通过后返回相关资源。 AccessToken 和 IdToken 都是 JWT,**有效时间**通常较短。通常用户在获取资源的时候需要携带 AccessToken,当 AccessToken 过期后,用户需要获取一个新的 AccessToken。 3. Refresh Token 用于获取新的 AccessToken。这样可以缩短 AccessToken 的过期时间保证安全,同时又不会因为频繁过期重新要求用户登录。用户在初次认证时,Refresh Token 会和 AccessToken、IdToken 一起返回。应用携带 Refresh Token 向 Token 端点发起请求时,这个时候会续签 AccessToken 和 IdToken 与 ID token。 所以我们一般说的 JWT 就是 Access Token 的部分用于授权。而**ID Token** 用户标注用户信息,Refresh Token 用来续签 Access Token 。 在懒猫微服上使用 OIDC 有一个好处就是,不用在 IDP 上填写申请信息,在程序运行过程中可以直接注入相应的环境变量,这样我们直接用就可以了。相当于传统 IDP 需要填写应用名称,做分组控制而言,这个自动注入的 OIDC 开箱即用很方便。 一般是有这几个信息: 1. CLIENT_ID:从我的 app 来看,这个就是包名 2. CLIENT_SECRET: 这个是随机的 3. ISSUER_URI:https://微服域名/sys/oauth 4. TOKEN_URI:https://微服域名/sys/oauth/token 5. USERINFO_URI:https://微服域名/sys/oauth/userinfo 先说 ISSUER_URI,这个是 OIDC 的入口,其中.well-known/openid-configuration 里可以拿到各种 URL,算是 OIDC 的入口,即使环境变量中没给信息我们也可以在这里查看。比如用来校验 JWT 的 jwks_uri。 GET https://<微服域名>/sys/oauth/.well-known/openid-configuration 结果如下: ```json { "issuer": "https://<name>.heiyu.space/sys/oauth", "authorization_endpoint": "https://<name>.heiyu.space/sys/oauth/auth", "token_endpoint": "https://<name>.heiyu.space/sys/oauth/token", "jwks_uri": "https://<name>.heiyu.space/sys/oauth/keys", "userinfo_endpoint": "https://<name>.heiyu.space/sys/oauth/userinfo", "device_authorization_endpoint": "https://<name>.heiyu.space/sys/oauth/device/code", "introspection_endpoint": "https://<name>.heiyu.space/sys/oauth/token/introspect", "grant_types_supported": [ "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange" ], "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "code_challenge_methods_supported": ["S256", "plain"], "scopes_supported": [ "openid", "email", "groups", "profile", "offline_access" ], "token_endpoint_auth_methods_supported": [ "client_secret_basic", "client_secret_post" ], "claims_supported": [ "iss", "sub", "aud", "iat", "exp", "email", "email_verified", "locale", "name", "preferred_username", "at_hash" ] } ``` 至于回调 URL,这个是需要自己设置的部分。可能由于开发习惯导致每个应用的回调 URL 不一样。相对于在 IDP 中填写信息,在懒猫微服的 lzc-manifest.yml 中加这么一行即可。也只有设置了 application.oidc_redirect_path 之后,才能使用 OIDC 相关的环境变量。 ```yml application.oidc_redirect_path: /callback ``` 可以看看我的懒猫 ENV 查看器的设置。通过 oidc_redirect_path 设置回调地址,然后使用 environment 字段还这是需要的环境变量。 ```yml lzc-sdk-version: 0.1 name: 懒猫ENV查看器 package: xu.deploy.env version: 0.0.2 description: license: https://choosealicense.com/licenses/mit/ homepage: author: xu application: subdomain: env oidc_redirect_path: /callback routes: - /=exec://5005,./lzcapp/pkg/content/run.sh environment: - LAZYCAT_AUTH_OIDC_CLIENT_ID=${LAZYCAT_AUTH_OIDC_CLIENT_ID} - LAZYCAT_AUTH_OIDC_CLIENT_SECRET=${LAZYCAT_AUTH_OIDC_CLIENT_SECRET} - LAZYCAT_AUTH_OIDC_AUTH_URI=${LAZYCAT_AUTH_OIDC_AUTH_URI} - LAZYCAT_AUTH_OIDC_TOKEN_URI=${LAZYCAT_AUTH_OIDC_TOKEN_URI} - LAZYCAT_AUTH_OIDC_USERINFO_URI=${LAZYCAT_AUTH_OIDC_USERINFO_URI} - LAZYCAT_AUTH_OIDC_ISSUER_URI=${LAZYCAT_AUTH_OIDC_ISSUER_URI} ``` 然后我们来看 OIDC 的几种授权模式。 | 应用类型 | 授权模式 | | :------------- | :----------------- | | 有后端场景 | 授权码模式 | | SPA,无后端 | 隐式模式 | | 应安全存储密钥 | 密码模式 | | 服务器之间 | Client Credentials | 这个是 Authing 推荐的选择方式,不过据我的经验来讲,就 Web 开发而言大多还是选择隐式授权的居多。看的出来懒猫的 OIDC 也是用的这种。 ![7c23a4d3-edef-4873-af13-ac75a1ab1094](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/7c23a4d3-edef-4873-af13-ac75a1ab1094.png) 懒猫微服也是用的授权码模式, 所以跳转的时候我们抓浏览器请求会看到: ``` https://url/callback?code=xxxx ``` 其实一个良好的 OIDC 流程是这样的: 0. 当访问没有权限的路由的时候,在路由守卫中重定向到登录页面。 1. 当用户登录的时候,跳转到对应的的 IDP 控制页面,然后输入用户凭证。这个时候会走 IDP 的认证。 2. 认证之后会颁发一次性 code(授权码模式),如果是简单的密码模式,那么就会直接返回 Access Token,ID token 以及 refresh token。 3. 使用授权码 code 换取 AccessToken、IdToken 以及 refresh token。授权码模式的好处是,把真正的令牌藏在后端交换,只暴露一次性 code,从而极大降低令牌泄露和被滥用的风险。 4. 最后我们再用 AccessToken 来访问资源。 ![98e71384-dfcb-426b-9f6e-3190f4e09701](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/98e71384-dfcb-426b-9f6e-3190f4e09701.png) 以上是基于懒猫的 OpenID Connect (OIDC)的理论讲解的部分,后面我们会进行实操,手把手创建可以接入 OIDC 的应用。 备注:关于部分 OIDC 的图文来自 Authing 文档。 https://docs.authing.cn/v2/concepts/oidc/choose-flow.html ![8766dbf9c00ecb70a04a269ed9106ffa.jpg](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/45a88278-cd01-4584-a27b-9955cb5dbc5a.jpg "8766dbf9c00ecb70a04a269ed9106ffa.jpg")

懒猫微服排查篇(二):上架应用后,pip 安装报错 HASH 不一致

懒猫 ENV 查看器第二版更新的时候,审核人员和我说遇到了这个错误。说来也奇怪,都用了 docker 了,也会遇到依赖的问题。 ![6c779ad7537d89eb4bd9e40c0d69b7d8.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/9bf75c39-b39e-4d35-8031-45f96fae5e3a.png "6c779ad7537d89eb4bd9e40c0d69b7d8.png") https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env > ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE Expected sha256 4ceb... Got 5519987f... 因为 pip 在校验阶段就失败,后面的 Flask 等依赖都没装上,于是程序启动时报 ModuleNotFoundError: No module named 'flask'。 最后我还是替换掉了腾讯云。以清华源为主,其他源为辅: ```bash #!/bin/sh # 切换到当前目录 cd "$(dirname "$0")" sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories apk update apk add python3 py3-pip # 设一条主索引(可选) pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple # 给同一个键追加多条 extra-index-url pip config set global.extra-index-url https://mirrors.aliyun.com/pypi/simple/ pip config set global.extra-index-url https://repo.huaweicloud.com/repository/pypi/simple/ pip config set global.extra-index-url https://mirrors.cloud.tencent.com/pypi/simple/ pip install -r ./requirements.txt --break-system-packages python3 app.py ``` 网上还有几种办法,后面再遇到的时候可以再尝试: 1. --no-cache-dir 2. pip cache purge 3. pip install --trusted-host=pypi.org --trusted-host=files.pythonhosted.org example_package 4. rm ~/.cache/pip -rf 5. 关闭机器代理 参考链接: https://stackoverflow.com/questions/71435874/pip-these-packages-do-not-match-the-hashes-from-the-requirements-file

懒猫微服进阶心得(十七):懒猫SSO对接外部OpenID Connect的尝试

在之前的探索中,我们已经实现了通过 gRPC 自主注册懒猫 SSO 应用,并成功集成了符合 OpenID Connect (OIDC) 协议的应用。今天我突发奇想:既然大家都是标准的 OIDC 协议,我能不能把“懒猫SSO”外挂到其他的身份提供商(IDP)里,作为一种身份联邦(Identity Federation)来使用? https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env 如果这一步能走通,意味着我们可以实现用户系统的共享。说干就干,我选择了 **AWS Cognito** 作为认证中间层,尝试把懒猫SSO集成进去。 ### 初探:环境配置与客户端注册 AWS Cognito 支持添加第三方 OIDC IDP。首先,我们需要在懒猫 SSO 中为 Cognito 注册一个“身份”。 老规矩还是先使用懒猫SSO的API注册应用,当然这里的配置仍然保存在内存中,重启会丢失,所以就算一个拓宽的使用场景。使用 `grpcurl` 调用 `CreateClient` 接口,关键点在于配置 Cognito 的回调地址: ```bash ./grpcurl -plaintext -d '{ "client": { "id": "congnito", "secret": "congnito-secret", "name": "New Flask App", "redirect_uris": [ "https://<your-cognito-domain>.auth.us-west-2.amazoncognito.com/oauth2/idpresponse", "http://localhost:8080/auth/callback" ] } }' 172.18.0.2:5557 api.Dex/CreateClient ``` 这样我们就配置好了Congito的回调,当然还有本地的localhost和127.0.0.1的回调。 ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/add138a7-86da-4a33-97c5-f632ec224ce1.png "image.png") 随后,在 AWS Cognito 控制台中新建一个 OIDC 提供商,填入对应的 `client_id` 和 `secret`。此时,Cognito 实际上成了懒猫 SSO 的一个“客户端”。 ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/3f1a773a-00f7-4ee7-9b72-6494765f9da2.png "image.png") 配置的时候Cognito提示无法解析懒猫域名,所以这里把URL分开来填写: ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/54b4fa53-7a0c-4063-b884-1b85662af926.png "image.png") ### 渐入:深入联邦身份原理 然后尝试代码如下,因为Cognito做了中间层,所以这里的信息是Cognito的,然后登陆的页面有一个选项可以跳转到懒猫SSO。 ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/203cd540-899d-48e3-96e1-9817fee43db7.png "image.png") 理想很丰满,现实很骨感。当我尝试通过 Cognito 页面跳转懒猫 SSO 登录时,程序报错了。 ``` from flask import Flask, redirect, url_for, session, jsonify from authlib.integrations.flask_client import OAuth from functools import wraps import os app = Flask(__name__) app.secret_key = os.urandom(24) oauth = OAuth(app) oauth.register( name='sso', client_id='', client_secret='', server_metadata_url='https://cognito-idp.us-west-2.amazonaws.com/us-west-xxx/.well-known/openid-configuration', client_kwargs={'scope': 'openid email'}, ) def login_required(f): @wraps(f) def decorated(*args, **kwargs): if 'user' not in session: return redirect(url_for('login')) return f(*args, **kwargs) return decorated @app.route('/') def index(): user = session.get('user') if user: return f'Hello, {user.get("email", user.get("name", "unknown"))}. <a href="/profile">Profile</a> | <a href="/logout">Logout</a>' return 'Welcome! Please <a href="/login">Login</a>.' @app.route('/login') def login(): return oauth.dex.authorize_redirect( url_for('authorize', _external=True), identity_provider='COGNITO' ) @app.route('/auth/callback') def authorize(): token = oauth.dex.authorize_access_token() session['user'] = token.get('userinfo') session['token_info'] = { 'access_token': token.get('access_token'), 'id_token': token.get('id_token'), 'token_type': token.get('token_type'), 'expires_at': token.get('expires_at'), } return redirect(url_for('index')) @app.route('/profile') @login_required def profile(): return jsonify(userinfo=session['user'], token=session.get('token_info')) @app.route('/logout') def logout(): session.pop('user', None) return redirect(url_for('index')) if __name__ == '__main__': app.run(host='0.0.0.0', port=8080, debug=True) ``` 经过数天的排查,我定位到了问题的核心:**网络隔离与双向通信。** Cognito 作为一个公有云服务,在执行 OIDC 协商的时需要访问懒猫 SSO 的接口才能够正常工作。 ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/7f07fa20-01e5-464a-9a16-19d9f73f52bf.png "image.png") 但由于懒猫 SSO 部署在私有微服环境下,虽然我的懒猫微服能够访问互联网,但是Cognito 的服务器缺无法解析我的私有域名,更无法穿透内网进行通信,导致请求超时,最终我在日志中翻到了HTTP 400。 ### 曲中:另辟蹊径的“重定向”方案 于是不甘心,想了一个折中的办法,是不是可以把这个跳转逻辑放到浏览器里来做呢? 想了两个办法: 1. 直接让浏览器代替Cognito的跳转,多次尝试无果 2. 在Cognito返回失败的时候捕获error,然后在本地302跳转到懒猫SSO 幸运的是方案2是工作的,虽然有种欺骗的味道,但是似乎是达成了公有的IDP外挂懒猫IDP的假象。首先把Cognito的注册都关掉,但是保留登陆功能,这样就没有人可以通过Cognito进行登陆,然后就只能点击懒猫SSO登陆,这个时候就会重新协商OpenID Connect协议,当我开启懒猫微服客户端的时候,我可以解析域名,别人哪怕能够跳转也无法做域名解析,所以很安全,其他人无法注册和登录Cognito,也无法解析懒猫微服域名。 ``` from flask import Flask, redirect, url_for, session, request from authlib.integrations.flask_client import OAuth from authlib.integrations.base_client.errors import OAuthError import os import logging logging.basicConfig(level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s') log = logging.getLogger(__name__) app = Flask(__name__) app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-prod') oauth = OAuth(app) oauth.register( name='cognito', client_id='', client_secret='', server_metadata_url='https://cognito-idp.us-west-2.amazonaws.com/us-west-xxx/.well-known/openid-configuration', client_kwargs={'scope': 'openid email'} ) oauth.register( name='dex', client_id='Congnito', client_secret='Congnito-secret', server_metadata_url='https://x.heiyu.space/sys/oauth/.well-known/openid-configuration', client_kwargs={'scope': 'openid email'} ) @app.route('/') def index(): user = session.get('user') if user: return f'Hello, {user.get("email", user.get("sub"))}. <a href="/logout">Logout</a>' return 'Welcome! <a href="/login">Login</a>' @app.route('/login') def login(): session['provider'] = 'cognito' redirect_uri = url_for('callback', _external=True) return oauth.cognito.authorize_redirect(redirect_uri) @app.route('/auth/callback') def callback(): provider = session.get('provider', 'cognito') log.info(f'Callback - provider: {provider}, args: {dict(request.args)}') # Cognito 失败,fallback 到 懒猫SSO 直连 if provider == 'cognito' and request.args.get('error'): log.warning(f'Cognito failed: {request.args.get("error")}, falling back to Dex') session['provider'] = 'dex' redirect_uri = url_for('callback', _external=True) return oauth.dex.authorize_redirect(redirect_uri) try: client = oauth.cognito if provider == 'cognito' else oauth.dex token = client.authorize_access_token() session['user'] = token.get('userinfo') session.pop('provider', None) return redirect(url_for('index')) except OAuthError: if provider == 'cognito': log.warning('Cognito token exchange failed, falling back to Dex') session['provider'] = 'dex' redirect_uri = url_for('callback', _external=True) return oauth.dex.authorize_redirect(redirect_uri) raise @app.route('/logout') def logout(): session.pop('user', None) return redirect(url_for('index')) if __name__ == '__main__': app.run(debug=True, port=8080) ``` 别急,如果你看到了这个页面只能说是域名跳转成功,并不是OIDC的凭证交换。 ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/46bcb057-90ac-483c-a434-b0c72c0c7a4e.png "image.png") 输入用户名和密码之后出现这个页面就对了: ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/d741b625-f163-48ad-8366-c999780dd899.png "image.png") 登陆之后就换到claim信息了,可以看到打印出来了邮箱。 ![image.png](https://lzc-playground-1301583638.cos.ap-chengdu.myqcloud.com/guidelines/459/f0c47b39-825c-4977-bbd0-0e2aa8c26ebb.png "image.png") ### 总结 没白折腾,确实还挺抽象的,抽空又复习了Oauth和OpenID Connect的底层原理,通过使用懒猫SSO,我的技术栈又升级了。

懒猫微服进阶心得(十):本地开发,如何接入懒猫微服的 OpenID Connect (OIDC)

我们知道懒猫的 OpenID Connect (OIDC) 无需在后台申请,商店里的应用在运行的时候会自动申请,但是本地测试的时候就不太方便。 一般是需要用其他的 IDP 作为测试环境,因为 OIDC 的协议是通用的,不像 OAuth 这么百花齐放。 以我的“家庭任务通知”APP 为例,讲解下在开发模式下接入懒猫微服的 OpenID Connect (OIDC)。 --- ### 添加 OIDC 登录逻辑 首先前端需要有一个 OIDC 的登录按钮,然后做好 OIDC 的逻辑: ![image-20250626104429835](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/image-20250626104429835.png) --- ### 使用懒猫 ENV 查看器导出本地配置 从应用商店安装我写的“懒猫 ENV 查看器”,导出 `env.example` 文件,导出项目之后重命名为 `.env`。这样就可以把商店里的 ENV 复制到本地的开发环境。 不过需要注意的是:**应用名字和回调函数还是原来的,不要轻易去改。遇到问题再手动调试。** https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env --- ![Snipaste_2025-06-26_13-41-30](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/Snipaste_2025-06-26_13-41-30.png) ### 登录后出现回调 URL 报错 登录之后我们就看到了这个页面: ![image-20250626104445501](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/image-20250626104445501.png) 点击“授予权限”,会报错。这个是由于回调 URL 不匹配的问题,还是会访问 ENV 查看器的 URL: ![image-20250626104502680](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/image-20250626104502680.png) --- ### 手动修改回调 URL 然后我们手动把上边的 URL 改成我们自己的回调路由就可以了,如果想自动化,你也可以写一个油猴脚本。 ![image-20250626104618044](https://raw.githubusercontent.com/cloudsmithy/picgo-imh/master/image-20250626104618044.png) --- 这样就可以完成本地的 OIDC 授权流程啦。开发的时候不用搭 IDP,也能走懒猫的登录流程。是不是很方便?

懒猫评分/评论

5.0

1 条评论

能够查看环境变量,不用自己大image echo了

应用信息

最新版本

0.0.2

更新日期

6/30/2025

预估安装占用

1.15 MB

不支持平台

--

来源

--

提供者

忘机山人

兼容性

设备需装有 LzcOS 0.0.1 或更高版本

"1. 优化了整个应用的 UI,现在非常精美\n2. 提供了env.example 的下载\n3. 提供了env export 的 bash/zsh 命令的导出功能\n4. 修复了清华源个别下载有问题的情况"