Files

227 lines
9.3 KiB
Markdown
Raw Permalink Normal View History

2026-07-13 10:08:46 +08:00
---
tags: [sso, oidc, jwt, auth]
create time: 2026-07-13 10:03
---
# OpenID Connect (OIDC)
## 概述
2026-07-13 11:11:37 +08:00
在 [[sso/sso-oauth2|OAuth 2.0]] 中我们知道,授权码模式拿到的 `access_token` 只能调 API,但**不知道登录的用户是谁**——OAuth 2.0 只解决「授权」,不解决「认证」。
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
**OIDC(OpenID Connect)** 在 OAuth 2.0 之上补了认证这一层。它增加了一个 **ID Token**,里面直接写着「登录的用户是张三,邮箱是 zhangsan@example.com」。
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
> [!info] 一个类比
> OAuth 2.0 像门禁系统——它验证你有权限进入大楼,但不记录你是谁。OIDC 在门禁旁边加了一个人脸识别摄像头——既验证权限,又知道是谁进来了。
OIDC 是当前最主流的 SSO 协议。Keycloak、Auth0、Okta、Azure AD、Google 等都支持。
## OIDC vs OAuth 2.0:到底多了什么?
2026-07-13 10:08:46 +08:00
| 维度 | OAuth 2.0 | OIDC |
|------|-----------|------|
2026-07-13 11:11:37 +08:00
| 解决什么问题 | 授权(你能访问什么) | 认证 + 授权(你是谁 + 你能访问什么) |
| 返回的 Token | `access_token`, `refresh_token | **+ `id_token`**(用户身份信息) |
| 用户信息怎么拿 | 需要额外调 `/userinfo` 接口 | `id_token` 自带基本信息,`/userinfo` 补充详细信息 |
| scope 要求 | 自定义 | 必须包含 `openid`,标准 scope:`profile`、`email`、`address`、`phone` |
| 协议规范 | RFC 6749 | OpenID Connect Core 1.0 |
## 先理解 JWT
ID Token 是一个 **JWT(JSON Web Token)**,所以先搞清楚 JWT 是什么。
JWT 是一种**自包含的签名令牌**,由三部分用 `.` 连接组成:
```
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMzQ1In0.签名内容
├── Header (Base64) ──┤├── Payload (Base64) ───┤├─ Signature ─┤
```
**三部分分别是什么:**
| 部分 | 内容 | 说明 |
|------|------|------|
| **Header** | 算法和类型 | `{"alg": "RS256", "typ": "JWT"}` — 说明用什么算法签名 |
| **Payload** | 用户信息和元数据 | `{"sub": "user-123", "name": "张三"}` — **这是 Claim(声明)** |
| **Signature** | 签名 | 用 IdP 的私钥对前两部分签名,防止被篡改 |
> [!warning] JWT 的 Payload 不是加密的!
> Base64 只是编码,不是加密。任何人都能解码 Payload 看到内容。所以**不要在 JWT 里放密码、手机号等敏感信息**。JWT 的安全靠的是签名——你可以读,但改不了(改了签名就对不上)。
2026-07-13 10:08:46 +08:00
## 核心流程
2026-07-13 11:11:37 +08:00
OIDC 的流程和 OAuth 2.0 授权码模式**几乎一样**,区别只有两点:
1. scope 必须包含 `openid`
2. Token 响应里多了 `id_token`
2026-07-13 10:08:46 +08:00
```mermaid
sequenceDiagram
2026-07-13 11:11:37 +08:00
participant U as 用户 (浏览器)
participant App as 业务应用 (Client/RP)
participant IdP as 认证中心 (OpenID Provider)
U->>App: 1. 点击「登录」
App->>U: 2. 浏览器跳转到 IdP
Note right of App: 比 OAuth 2.0 多了 scope=openid
U->>IdP: 3. 在 IdP 登录并授权
IdP->>U: 4. 浏览器跳回,携带 code
2026-07-13 10:08:46 +08:00
U->>App: 5. 回调
2026-07-13 11:11:37 +08:00
App->>IdP: 6. 后端用 code 换 Token
IdP-->>App: 7. 返回 access_token + id_token + refresh_token
Note right of IdP: id_token 是新增的!<br/>它包含用户的身份信息
App->>App: 8. 验证 id_token 的签名和关键字段
2026-07-13 10:08:46 +08:00
App-->>U: 9. 登录成功
```
2026-07-13 11:11:37 +08:00
> [!tip] 术语对照
> OIDC 里把 Client 叫 **RP(Relying Party,依赖方)**,把授权服务器叫 **OpenID Provider(OP)**。本质和 OAuth 2.0 的 Client / Authorization Server 是一回事,只是换了个名字。本文统一用「应用」和「IdP」。
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
## ID Token 详解
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
ID Token 是一个 JWT,包含了用户的身份信息。下面是实际的 ID Token 解码后的样子:
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
### Header
2026-07-13 10:08:46 +08:00
```json
{
2026-07-13 11:11:37 +08:00
"alg": "RS256", // 签名算法:RSA + SHA256
2026-07-13 10:08:46 +08:00
"typ": "JWT",
2026-07-13 11:11:37 +08:00
"kid": "key-2026-07" // 密钥 ID,用于查找对应的公钥
2026-07-13 10:08:46 +08:00
}
```
2026-07-13 11:11:37 +08:00
### Payload(Claims)
**Claim(声明)** 就是一个键值对,表示关于用户的某个事实,比如「名字叫张三」就是一个 Claim。
2026-07-13 10:08:46 +08:00
```json
{
2026-07-13 11:11:37 +08:00
"iss": "https://idp.example.com", // 签发者:谁签发了这个 Token
"sub": "user-12345", // 主题:用户的唯一标识
"aud": "your-client-id", // 受众:这个 Token 是给哪个应用的
"exp": 1752374580, // 过期时间(Unix 时间戳)
"iat": 1752374280, // 签发时间
"nonce": "abc123", // 防重放的随机值
"name": "张三", // 以下是用户信息
2026-07-13 10:08:46 +08:00
"email": "zhangsan@example.com",
"email_verified": true,
"picture": "https://cdn.example.com/avatar.jpg"
}
```
### 必须校验的 Claims
> [!danger] 不校验 = 门户大开
2026-07-13 11:11:37 +08:00
> 收到 ID Token 后,必须逐项校验以下字段。跳过任何一个都可能被攻击者伪造身份。
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
| Claim | 怎么校验 | 不校验会怎样 |
2026-07-13 10:08:46 +08:00
|-------|----------|-------------|
2026-07-13 11:11:37 +08:00
| `iss` | 必须等于你配置的 IdP 地址 | 攻击者自己搭一个假 IdP 签发 Token |
| `aud` | 必须包含你的 `client_id` | Token 被别的应用偷去用 |
| `exp` | 当前时间必须 < `exp` | 过期 Token 仍能登录 |
| `nonce` | 必须和你发起请求时存的值一致 | 攻击者截获 Token 后重放使用 |
| `signature` | 用 IdP 的公钥验证签名 | Token 内容被篡改你发现不了 |
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
### Go 校验示例
2026-07-13 10:08:46 +08:00
```go
2026-07-13 11:11:37 +08:00
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
ctx := context.Background()
// 1. 通过 OIDC Discovery 自动获取 IdP 的所有端点和公钥
// 这会访问 https://idp.example.com/.well-known/openid-configuration
2026-07-13 10:08:46 +08:00
provider, err := oidc.NewProvider(ctx, "https://idp.example.com")
2026-07-13 11:11:37 +08:00
if err != nil {
log.Fatal("无法连接 IdP:", err)
}
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
// 2. 创建 ID Token 校验器(自动校验 iss、aud、签名)
2026-07-13 10:08:46 +08:00
verifier := provider.Verifier(&oidc.Config{
ClientID: "your-client-id",
})
2026-07-13 11:11:37 +08:00
// 3. 校验 rawIDToken(从 Token 响应中拿到的 id_token 字符串)
// 校验内容:签名有效性、iss、aud、exp
2026-07-13 10:08:46 +08:00
idToken, err := verifier.Verify(ctx, rawIDToken)
2026-07-13 11:11:37 +08:00
if err != nil {
log.Fatal("ID Token 校验失败:", err)
}
// 4. 校验 nonce(手动校验,库不会帮你做)
if idToken.Nonce != expectedNonce {
log.Fatal("nonce 不匹配,可能是重放攻击")
}
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
// 5. 提取用户信息
2026-07-13 10:08:46 +08:00
var claims struct {
2026-07-13 11:11:37 +08:00
Name string `json:"name"`
Email string `json:"email"`
2026-07-13 10:08:46 +08:00
}
if err := idToken.Claims(&claims); err != nil {
2026-07-13 11:11:37 +08:00
log.Fatal("解析 Claims 失败:", err)
2026-07-13 10:08:46 +08:00
}
2026-07-13 11:11:37 +08:00
fmt.Printf("登录用户:%s (%s)\n", claims.Name, claims.Email)
2026-07-13 10:08:46 +08:00
```
2026-07-13 11:11:37 +08:00
> [!tip] 库是怎么自动拿到 IdP 公钥的?
> `oidc.NewProvider()` 会访问 IdP 的 Discovery 端点(`/.well-known/openid-configuration`),从中获取 `jwks_uri`(公钥端点),再拉取公钥并缓存。你只需要提供 IdP 的 URL,其他全自动。
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
## OIDC Discovery:自动发现 IdP 配置
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
每个 OIDC Provider 都暴露一个**发现端点**,让客户端自动获取所有配置,不需要手动填写各种 URL:
2026-07-13 10:08:46 +08:00
```
GET https://idp.example.com/.well-known/openid-configuration
```
返回:
2026-07-13 11:11:37 +08:00
2026-07-13 10:08:46 +08:00
```json
{
2026-07-13 11:11:37 +08:00
"issuer": "https://idp.example.com", // IdP 身份标识
"authorization_endpoint": "https://idp.example.com/oauth/authorize", // 登录授权页面
"token_endpoint": "https://idp.example.com/oauth/token", // 换 Token 的接口
"userinfo_endpoint": "https://idp.example.com/userinfo", // 获取用户详情的接口
"jwks_uri": "https://idp.example.com/.well-known/jwks.json", // 公钥端点,用于验签
"scopes_supported": ["openid", "profile", "email"], // 支持的 scope
"response_types_supported": ["code"], // 支持的响应类型
"id_token_signing_alg_values_supported": ["RS256", "ES256"] // 支持的签名算法
2026-07-13 10:08:46 +08:00
}
```
2026-07-13 11:11:37 +08:00
> **客户端只需要知道 IdP 的 URL**,其他所有端点地址都通过 Discovery 自动获取。这是 OIDC 比 SAML 接入成本低的一个重要原因。
2026-07-13 10:08:46 +08:00
## 常见陷阱与最佳实践
2026-07-13 11:11:37 +08:00
### 必须校验 ID Token 签名
用 IdP 的公钥验证签名。`coreos/go-oidc` 会自动从 JWKS 端点获取和缓存公钥,不需要你手动管理。**不要只 Base64 解码就信任 Payload——签名没验证等于没验证。**
### nonce 是防重放的关键
流程:
1. 用户点击登录时,生成随机 `nonce` 存入 session
2. 把 `nonce` 放进授权请求
3. IdP 会把 `nonce` 原样写进 ID Token
4. 回调时比对 Token 中的 `nonce` 和 session 中的是否一致
如果攻击者截获了你的 ID Token,想在另一个浏览器重放,`nonce` 会对不上(因为 session 里存的不一样)。
### 不要在 ID Token 里放敏感信息
ID Token 可能被前端 JavaScript 直接读取(取决于你的架构)。密码、手机号、身份证号等不要放进去。需要这些信息时,用 `access_token` 调 `/userinfo` 接口获取。
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
### 密钥轮换(Key Rollover)
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
IdP 会定期更换签名密钥。客户端库需要通过 `kid`(Key ID)匹配正确的公钥。`coreos/go-oidc` 会自动处理,但**不要硬编码公钥**。
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
### Google 的 `hd` 参数(Google 专属)
2026-07-13 10:08:46 +08:00
2026-07-13 11:11:37 +08:00
> [!warning] 这是 Google 的扩展参数,不是 OIDC 标准
> Google OIDC 支持 `hd`(hosted domain)参数,限制只允许特定域名(如 `company.com`)的账号登录。企业 SSO 场景务必加上,否则用户可能用个人 Gmail 登录。