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

忘机山人

发布于27天前
博客图片修整中,看不了可以先搜索公众号“忘机山人”看。
在第三章结束时,我们已经解决了一个关键问题:


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=`)
* 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://dl.playground.lazycat.cloud/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 的设计目标不是“不让你看到”,
> 而是“让你无法伪造”。**

评论

0

暂无评论

说点什么呢~
收藏
0
0
0