Files

227 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [sso, oidc, jwt, auth]
create time: 2026-07-13 10:03
---
# OpenID Connect (OIDC)
## 概述
在 [[sso/sso-oauth2|OAuth 2.0]] 中我们知道,授权码模式拿到的 `access_token` 只能调 API,但**不知道登录的用户是谁**——OAuth 2.0 只解决「授权」,不解决「认证」。
**OIDC(OpenID Connect)** 在 OAuth 2.0 之上补了认证这一层。它增加了一个 **ID Token**,里面直接写着「登录的用户是张三,邮箱是 zhangsan@example.com」。
> [!info] 一个类比
> OAuth 2.0 像门禁系统——它验证你有权限进入大楼,但不记录你是谁。OIDC 在门禁旁边加了一个人脸识别摄像头——既验证权限,又知道是谁进来了。
OIDC 是当前最主流的 SSO 协议。Keycloak、Auth0、Okta、Azure AD、Google 等都支持。
## OIDC vs OAuth 2.0:到底多了什么?
| 维度 | OAuth 2.0 | OIDC |
|------|-----------|------|
| 解决什么问题 | 授权(你能访问什么) | 认证 + 授权(你是谁 + 你能访问什么) |
| 返回的 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 的安全靠的是签名——你可以读,但改不了(改了签名就对不上)。
## 核心流程
OIDC 的流程和 OAuth 2.0 授权码模式**几乎一样**,区别只有两点:
1. scope 必须包含 `openid`
2. Token 响应里多了 `id_token`
```mermaid
sequenceDiagram
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
U->>App: 5. 回调
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 的签名和关键字段
App-->>U: 9. 登录成功
```
> [!tip] 术语对照
> OIDC 里把 Client 叫 **RP(Relying Party,依赖方)**,把授权服务器叫 **OpenID Provider(OP)**。本质和 OAuth 2.0 的 Client / Authorization Server 是一回事,只是换了个名字。本文统一用「应用」和「IdP」。
## ID Token 详解
ID Token 是一个 JWT,包含了用户的身份信息。下面是实际的 ID Token 解码后的样子:
### Header
```json
{
"alg": "RS256", // 签名算法:RSA + SHA256
"typ": "JWT",
"kid": "key-2026-07" // 密钥 ID,用于查找对应的公钥
}
```
### Payload(Claims)
**Claim(声明)** 就是一个键值对,表示关于用户的某个事实,比如「名字叫张三」就是一个 Claim。
```json
{
"iss": "https://idp.example.com", // 签发者:谁签发了这个 Token
"sub": "user-12345", // 主题:用户的唯一标识
"aud": "your-client-id", // 受众:这个 Token 是给哪个应用的
"exp": 1752374580, // 过期时间(Unix 时间戳)
"iat": 1752374280, // 签发时间
"nonce": "abc123", // 防重放的随机值
"name": "张三", // 以下是用户信息
"email": "zhangsan@example.com",
"email_verified": true,
"picture": "https://cdn.example.com/avatar.jpg"
}
```
### 必须校验的 Claims
> [!danger] 不校验 = 门户大开
> 收到 ID Token 后,必须逐项校验以下字段。跳过任何一个都可能被攻击者伪造身份。
| Claim | 怎么校验 | 不校验会怎样 |
|-------|----------|-------------|
| `iss` | 必须等于你配置的 IdP 地址 | 攻击者自己搭一个假 IdP 签发 Token |
| `aud` | 必须包含你的 `client_id` | Token 被别的应用偷去用 |
| `exp` | 当前时间必须 < `exp` | 过期 Token 仍能登录 |
| `nonce` | 必须和你发起请求时存的值一致 | 攻击者截获 Token 后重放使用 |
| `signature` | 用 IdP 的公钥验证签名 | Token 内容被篡改你发现不了 |
### Go 校验示例
```go
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
provider, err := oidc.NewProvider(ctx, "https://idp.example.com")
if err != nil {
log.Fatal("无法连接 IdP:", err)
}
// 2. 创建 ID Token 校验器(自动校验 iss、aud、签名)
verifier := provider.Verifier(&oidc.Config{
ClientID: "your-client-id",
})
// 3. 校验 rawIDToken(从 Token 响应中拿到的 id_token 字符串)
// 校验内容:签名有效性、iss、aud、exp
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
log.Fatal("ID Token 校验失败:", err)
}
// 4. 校验 nonce(手动校验,库不会帮你做)
if idToken.Nonce != expectedNonce {
log.Fatal("nonce 不匹配,可能是重放攻击")
}
// 5. 提取用户信息
var claims struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := idToken.Claims(&claims); err != nil {
log.Fatal("解析 Claims 失败:", err)
}
fmt.Printf("登录用户:%s (%s)\n", claims.Name, claims.Email)
```
> [!tip] 库是怎么自动拿到 IdP 公钥的?
> `oidc.NewProvider()` 会访问 IdP 的 Discovery 端点(`/.well-known/openid-configuration`),从中获取 `jwks_uri`(公钥端点),再拉取公钥并缓存。你只需要提供 IdP 的 URL,其他全自动。
## OIDC Discovery:自动发现 IdP 配置
每个 OIDC Provider 都暴露一个**发现端点**,让客户端自动获取所有配置,不需要手动填写各种 URL:
```
GET https://idp.example.com/.well-known/openid-configuration
```
返回:
```json
{
"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"] // 支持的签名算法
}
```
> **客户端只需要知道 IdP 的 URL**,其他所有端点地址都通过 Discovery 自动获取。这是 OIDC 比 SAML 接入成本低的一个重要原因。
## 常见陷阱与最佳实践
### 必须校验 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` 接口获取。
### 密钥轮换(Key Rollover)
IdP 会定期更换签名密钥。客户端库需要通过 `kid`(Key ID)匹配正确的公钥。`coreos/go-oidc` 会自动处理,但**不要硬编码公钥**。
### Google 的 `hd` 参数(Google 专属)
> [!warning] 这是 Google 的扩展参数,不是 OIDC 标准
> Google OIDC 支持 `hd`(hosted domain)参数,限制只允许特定域名(如 `company.com`)的账号登录。企业 SSO 场景务必加上,否则用户可能用个人 Gmail 登录。