正在加载,请稍候…

OAuth 2.0 与 OpenID Connect 完全指南:现代认证机制的工作原理

深入解析 OAuth 2.0 授权流程和 OpenID Connect(OIDC):授权码、PKCE、隐式模式、客户端凭证,以及 JWT 如何融入其中,附带真实

OAuth 2.0 与 OpenID Connect 完全指南:现代认证机制的工作原理

为什么“使用 Google 登录”比看起来更复杂

每次你点击“继续使用 Google”或“使用 GitHub 登录”时,背后都会发生一次精心编排的握手。OAuth 2.0 是实现这一功能的授权协议,而 OpenID Connect (OIDC) 是构建在其之上的身份层。它们共同驱动着网络上几乎所有现代认证流程。

本指南将厘清其中的困惑。OAuth 和 OIDC 经常被误解——即使是每天使用它们的开发者也是如此。

OAuth 2.0 与 OpenID Connect 完全指南:现代认证机制的工作原理 插图

OAuth 2.0:授权,而非认证

需要理解的最重要一点是:OAuth 2.0 是一个授权协议,而不是认证协议。 它旨在让用户授予第三方应用程序对其资源的有限访问权限——而无需共享密码。

经典示例:一个照片编辑应用想要访问你的 Google Photos。你不想将 Google 密码交给该应用。OAuth 允许 Google 向该应用颁发一个有时间限制的访问令牌,该令牌仅具有对照片的只读访问权限。

四个 OAuth 2.0 角色

角色 描述 示例
资源所有者 (Resource Owner) 拥有数据的用户
客户端 (Client) 请求访问的应用 照片编辑应用
授权服务器 (Authorization Server) 认证后颁发令牌 Google 的认证服务器
资源服务器 (Resource Server) 托管受保护数据 Google Photos API

授权码流程(最常见)

这是你应该用于带有后端的 Web 应用和移动应用的流程。

用户 → 客户端应用 → 授权服务器 → 用户登录 → 重定向并携带授权码
→ 客户端用授权码交换令牌 → 客户端用令牌调用资源服务器

逐步说明:

# 步骤 1:客户端将用户重定向到授权服务器
GET https://accounts.google.com/o/oauth2/v2/auth?
  response_type=code&
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https://yourapp.com/callback&
  scope=openid%20email%20profile&
  state=RANDOM_STATE_VALUE

# 步骤 2:用户认证并批准
# 授权服务器重定向回来:
GET https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_STATE_VALUE

# 步骤 3:用授权码交换令牌(服务器到服务器,绝不在浏览器中)
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://yourapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET

# 响应:
{
  "access_token": "ya29.a0AfH6...",
  "id_token": "eyJhbGciOiJSUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 3599,
  "refresh_token": "1//0gLh..."
}

state 参数至关重要——它可以防止 CSRF 攻击。生成一个随机值,存储在会话中,并在回调到达时验证它是否匹配。

PKCE:适用于 SPA 和移动应用的授权码流程

单页应用和移动应用无法安全地存储 client_secret(它对用户可见)。PKCE(Proof Key for Code Exchange,发音为“pixy”)解决了这个问题。

// 生成 PKCE code_verifier(随机字符串)
const codeVerifier = generateRandomString(64);
// sha256("verifier") → base64url
const codeChallenge = base64url(sha256(codeVerifier));

// 步骤 1:授权请求包含 code_challenge
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// ... 其他参数

// 步骤 2:令牌交换包含 code_verifier
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authCode,
    code_verifier: codeVerifier, // 证明我们发起了请求
    // 不需要 client_secret!
  })
});

PKCE 之所以有效,是因为拦截了授权码的攻击者无法在没有 code_verifier 的情况下交换令牌,而 code_verifier 只有合法客户端拥有。

OAuth 2.0 与 OpenID Connect 完全指南:现代认证机制的工作原理 插图

其他 OAuth 2.0 授权类型

客户端凭证(机器对机器)

用于没有用户参与的服务器到服务器 API 调用:

// 你的服务直接使用自己的凭证进行认证
const response = await fetch('https://api.example.com/oauth/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: process.env.CLIENT_ID,
    client_secret: process.env.CLIENT_SECRET,
    scope: 'read:data write:data'
  })
});

const { access_token } = await response.json();

// 使用令牌调用 API
await fetch('https://api.example.com/data', {
  headers: { Authorization: `Bearer ${access_token}` }
});

刷新令牌流程

访问令牌是短期的。当它们过期时,使用刷新令牌:

async function getValidToken(refreshToken) {
  const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET
    })
  });
  
  const data = await response.json();
  if (!response.ok) {
    throw new Error('刷新失败: ' + data.error);
  }
  
  return data.access_token;
}

OpenID Connect:为 OAuth 添加认证

OAuth 只表示“这是调用此 API 的访问令牌”。它没有说明用户是谁。OpenID Connect 添加了一个 ID Token——一个包含用户身份声明的 JWT。

当你将 openid 添加到 OAuth 作用域时,授权服务器会返回一个 ID Token 以及访问令牌:

// ID Token 是一个包含用户声明的 JWT
// 解码它(但务必先验证签名!)
const idTokenPayload = {
  "iss": "https://accounts.google.com",  // 颁发者
  "sub": "110169484474386276334",          // 主题(用户 ID)
  "aud": "YOUR_CLIENT_ID",                 // 受众
  "exp": 1516239022,                       // 过期时间
  "iat": 1516235422,                       // 颁发时间
  "nonce": "abc123",                       // 重放保护
  
  // 带有 profile 作用域的标准 OIDC 声明:
  "name": "Jane Doe",
  "email": "jane@example.com",
  "picture": "https://...",
  "email_verified": true
}

OAuth 2.0 与 OpenID Connect 完全指南:现代认证机制的工作原理 插图

验证 ID Token

切勿在不验证的情况下信任 ID Token:

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://accounts.google.com/.well-known/openid-configuration')
  // Jose 会自动从发现文档中获取实际的 jwks_uri
);

async function verifyIdToken(idToken) {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: 'https://accounts.google.com',
    audience: YOUR_CLIENT_ID,
  });
  
  // 检查 nonce 以防止重放攻击
  if (payload.nonce !== sessionNonce) {
    throw new Error('Nonce 不匹配');
  }
  
  return payload; // 可信的用户信息
}

OIDC 发现文档

每个 OIDC 提供者都会在 /.well-known/openid-configuration 暴露一个发现文档:

curl https://accounts.google.com/.well-known/openid-configuration | jq .

{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "token_endpoint": "https://oauth2.googleapis.com/token",
  "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
  "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
  "scopes_supported": ["openid", "email", "profile"],
  "response_types_supported": ["code", "token", "id_token"],
  ...
}

使用发现文档而不是硬编码端点——它们可能会更改。

常见安全错误

1. 将令牌存储在 localStorage 中

// ❌ 容易受到 XSS 攻击
localStorage.setItem('access_token', token);

// ✅ 使用 httpOnly cookie(JavaScript 无法访问)
// 或者存储在内存中,并在 httpOnly cookie 中使用刷新令牌

2. 未验证 state 参数

// ❌ CSRF 漏洞
router.get('/callback', async (req) => {
  const { code } = req.query;
  await exchangeCode(code); // 未检查 state!
});

// ✅ 始终验证 state
router.get('/callback', async (req) => {
  const { code, state } = req.query;
  if (state !== req.session.oauthState) {
    return res.status(400).send('State 不匹配');
  }
  await exchangeCode(code);
});

3. 使用隐式流程(已弃用) 隐式流程将令牌直接返回在 URL 片段中——它们会出现在浏览器历史记录和服务器日志中。始终使用授权码 + PKCE 替代。

4. 未检查令牌过期

function isTokenExpired(token) {
  const { exp } = JSON.parse(atob(token.split('.')[1]));
  return Date.now() >= exp * 1000;
}

使用 Passport.js 的实际实现

import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: '/auth/google/callback',
  scope: ['openid', 'email', 'profile']
}, async (accessToken, refreshToken, profile, done) => {
  // profile 包含来自 Google 的已验证用户信息
  let user = await User.findOne({ googleId: profile.id });
  
  if (!user) {
    user = await User.create({
      googleId: profile.id,
      email: profile.emails[0].value,
      name: profile.displayName,
    });
  }
  
  return done(null, user);
}));

// 路由
app.get('/auth/google', passport.authenticate('google'));

app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => res.redirect('/dashboard')
);

OAuth 与 API 密钥与会话的对比

方法 最适合 安全性 用户体验
OAuth 2.0 第三方访问、SSO 高(有限作用域) 无缝
API 密钥 服务器到服务器、M2M 中(无过期) 简单
会话 Cookie 自有 Web 应用 高(httpOnly) 透明
JWT(无状态) 微服务 中(难以撤销) 可扩展

→ 使用 JWT 解析器 解码和检查 OAuth ID Token 和访问令牌。