正在加载,请稍候…

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 和访问令牌。