懒猫ENV查看器
原创应用。查看懒猫的所有环境变量,还集成了OIDC。
安装次数
点赞
应用评论
催更次数
桌面端



应用描述
原创应用。查看懒猫的所有环境变量,还集成了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 验证链中最早的一道闸门。** 如果这一关被绕过,后面所有校验都失去意义。 ---  ## 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 里的每一个声明。** ---  ## 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 的安全性,不在某一个点上。 而在于:**这些问题一个都没有被跳过。** --- ### 本章小结(不讲技术,只讲直觉) > 登录看起来像一个动作, > 但在盒子里,它其实是一条验证链的起点。 > > 在你理解这条验证链之前, > “登录成功”这四个字,本身是不完整的。 

懒猫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 的安全性,不来自“我信任懒猫”, > 而来自“我验证过这次结果”。** 

# 懒猫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` 字段**。只要签名验证通过,它就是懒猫原始发出的样子。 ---  ## 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 对照。 

懒猫微服开发篇(四):懒猫微服如何使用 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 登录”** 或 **“微信登录”** 。  跳转到认证中心,一般都会提示你是否确认登录,某某应用将要获取登录的权限,查看你的信息。在懒猫微服里这直接点击 Grant Access 即可。在其他的 IDP 中,会让你输入账号密码登录,并同意授权该网站访问你的基本信息(如邮箱、昵称)。  当 IDP 验证完的身份后,返回一个 **JWT(JSON Web Token)**,其中包含你的身份信息。当网站验证 JWT 后,确认你的身份,并让你登录成功。我们也可以在 jwt.io 和 jwt.ms 这个网站去做解码。 我解码了其中一个 token,我们可以看到里面的信息,可以看到加密算法,颁发机构,过期时间,用户信息什么的,  这个是一般登录的流程,比如首次用户名和密码登录成功之后会返回一个 JWT,然后后续把这个 JWT 当做 bear token 来请求后面的资源。我们的 OIDC 和这个原理类似,只不过稍微复杂一些。  在 `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 也是用的这种。  懒猫微服也是用的授权码模式, 所以跳转的时候我们抓浏览器请求会看到: ``` 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 来访问资源。  以上是基于懒猫的 OpenID Connect (OIDC)的理论讲解的部分,后面我们会进行实操,手把手创建可以接入 OIDC 的应用。 备注:关于部分 OIDC 的图文来自 Authing 文档。 https://docs.authing.cn/v2/concepts/oidc/choose-flow.html 

懒猫微服排查篇(二):上架应用后,pip 安装报错 HASH 不一致
懒猫 ENV 查看器第二版更新的时候,审核人员和我说遇到了这个错误。说来也奇怪,都用了 docker 了,也会遇到依赖的问题。  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的回调。  随后,在 AWS Cognito 控制台中新建一个 OIDC 提供商,填入对应的 `client_id` 和 `secret`。此时,Cognito 实际上成了懒猫 SSO 的一个“客户端”。  配置的时候Cognito提示无法解析懒猫域名,所以这里把URL分开来填写:  ### 渐入:深入联邦身份原理 然后尝试代码如下,因为Cognito做了中间层,所以这里的信息是Cognito的,然后登陆的页面有一个选项可以跳转到懒猫SSO。  理想很丰满,现实很骨感。当我尝试通过 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 的接口才能够正常工作。  但由于懒猫 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的凭证交换。  输入用户名和密码之后出现这个页面就对了:  登陆之后就换到claim信息了,可以看到打印出来了邮箱。  ### 总结 没白折腾,确实还挺抽象的,抽空又复习了Oauth和OpenID Connect的底层原理,通过使用懒猫SSO,我的技术栈又升级了。

懒猫微服进阶心得(十):本地开发,如何接入懒猫微服的 OpenID Connect (OIDC)
我们知道懒猫的 OpenID Connect (OIDC) 无需在后台申请,商店里的应用在运行的时候会自动申请,但是本地测试的时候就不太方便。 一般是需要用其他的 IDP 作为测试环境,因为 OIDC 的协议是通用的,不像 OAuth 这么百花齐放。 以我的“家庭任务通知”APP 为例,讲解下在开发模式下接入懒猫微服的 OpenID Connect (OIDC)。 --- ### 添加 OIDC 登录逻辑 首先前端需要有一个 OIDC 的登录按钮,然后做好 OIDC 的逻辑:  --- ### 使用懒猫 ENV 查看器导出本地配置 从应用商店安装我写的“懒猫 ENV 查看器”,导出 `env.example` 文件,导出项目之后重命名为 `.env`。这样就可以把商店里的 ENV 复制到本地的开发环境。 不过需要注意的是:**应用名字和回调函数还是原来的,不要轻易去改。遇到问题再手动调试。** https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env ---  ### 登录后出现回调 URL 报错 登录之后我们就看到了这个页面:  点击“授予权限”,会报错。这个是由于回调 URL 不匹配的问题,还是会访问 ENV 查看器的 URL:  --- ### 手动修改回调 URL 然后我们手动把上边的 URL 改成我们自己的回调路由就可以了,如果想自动化,你也可以写一个油猴脚本。  --- 这样就可以完成本地的 OIDC 授权流程啦。开发的时候不用搭 IDP,也能走懒猫的登录流程。是不是很方便?
懒猫评分/评论
5.0
1 条评论
应用信息
新功能
版本历史记录"1. 优化了整个应用的 UI,现在非常精美\n2. 提供了env.example 的下载\n3. 提供了env export 的 bash/zsh 命令的导出功能\n4. 修复了清华源个别下载有问题的情况"
忘机山人
6/24/2025
能够查看环境变量,不用自己大image echo了