一文读懂Oauth

332 阅读21分钟

基础概念

OAuth 2.0 是一组规范,允许开发人员轻松地将其用户的身份验证和授权委托给他人。

模式

本地登录和注册

将身份认证外包到一个安全、可靠且符合标准的认证系统。

在这种模式下,将拥有 OAuth 服务器和应用程序。可能没有编写 OAuth 服务器,但可以控制它。事实上,这种模式通常感觉就像用户通过本机表单直接注册或登录到您的应用程序,根本没有委托(自己搭了一套认证服务)。

图片.png

第三方登录和注册(联合登录)

避免存储任务凭据,不想对密码负责。

image-20240604163727608转存失败,建议直接上传图片文件

图片.png

第一方登录和注册(与联合登录相反)

正在构建一个平台,并希望允许其他开发人员请求权限以调用您平台上的 API 或服务,即第三方认证服务,比如:Google、Facebook等。

企业登录和注册(与联合登录有点差异)

与第三方登录与注册类似,当用户在登录的时候,会通过邮箱或者其他的信息感知到是某个企业的邮箱,然后请求对应企业的认证服务。

第三方服务授权

已经集成了用户存储,并且只需要代表您的用户访问第三方服务。

比如,当Jenkins打包的时候,需要在github上获取代码,此时需要获取github账号的权限,然后才能拉取代码。又或者本地git进行push、pull之类的操作,需要github的授权。

图片.png

第一方服务授权

正在构建一个平台,并希望允许其他开发人员请求权限以调用您平台上的 API 或服务。

机器对机器的的身份验证和授权

建立无用户参与的服务与服务通信。

机器对机器授权 OAuth 模式与我们之前介绍的模式不同。这种模式根本不涉及用户。相反,它允许一个应用程序与另一个应用程序进行交互。通常情况下,这是后端服务通过 API 相互通信。

设备登录和授权

尝试让用户从另一台设备登录。

备登录和注册模式用于在没有像键盘这样的丰富输入设备的设备上登录(或注册)用户帐户。在这种情况下,用户将设备连接到他们的帐户,通常是为了确保他们的帐户处于活动状态并且设备被允许使用它(共享单车)。

图片.png 共享单车原理

Oauth授权

授权是用于从授权服务器获取访问令牌的身份验证流程。授权封装了生成令牌所使用的流程、数据流和规则。

授权码授权

1. 登录、注册按钮

首先,用户点击登录、第三方登录链接进行登录。此时有两种方式实现:

  1. 直接在前端将url设置成任务服务的url(不建议:不安全、url太长)
  2. 设置url为后端url,通过后端重定向到认证服务

2. 重定向授权服务

后端在收到前端的请求后将重定向请求到对应的认证服务,以此同时需要携带一些参数。Oauth2规范的的参数有:

  • client_id: 这标识着您正在登录的应用程序。在 OAuth 中,这被称为 client 。这个值将由 OAuth 服务器提供给您。

  • redirect_url: 这是您的应用程序中的 URL,OAuth 服务器将在用户登录后将用户重定向到该 URL。此 URL 必须在 OAuth 服务器上注册,并且必须指向您应用程序中的一个控制器(而不是静态页面),因为在调用此 URL 后,您的应用程序必须执行其他工作。

  • state: 从技术上讲,此参数是可选的,但对于防止各种安全问题非常有用。此参数由 OAuth 服务器回显到您的应用程序。它可以是您需要在 OAuth 工作流程中持久化的任何内容。如果您对此参数没有其他需求,建议将其设置为一个大的随机字符串。如果您需要在工作流程中持久化数据,建议对数据进行 URL 编码,并附加一个随机字符串。

  • response_type: 这应该始终设置为 code 以获取此授权。这告诉 OAuth 服务器您正在使用授权码授权。

  • scope: 这也是一个可选参数。该参数是一个以空格分隔的字符串列表。如果您计划在应用程序中使用刷新令牌,您可能还需要在此列表中包含 offline 范围

    • profile: 告诉 OAuth 服务器在返回的令牌(访问令牌和/或 Id 令牌)中包含所有标准的 OpenID Connect 声明
    • offline_access: 告诉 OAuth 服务器从 Token 端点生成并返回一个刷新令牌
    • openid: 告诉 OAuth 服务器使用 OpenID Connect 来处理 OAuth 工作流程。这还会告诉 OAuth 服务器从令牌端点返回一个 Id 令牌
    • email: 告诉 OAuth 服务器在返回的令牌(访问令牌和/或 Id 令牌)中包含用户的电子邮件
    • address: 告诉 OAuth 服务器在返回的令牌(访问令牌和/或 Id 令牌)中包含用户的地址
    • phone: 告诉 OAuth 服务器在返回的令牌(访问和/或 Id 令牌)中包含用户的电话号码
  • code_challenge: 这是一个可选参数,但提供了对 PKCE 的支持。当没有后端可以处理授权码授予的最后步骤时,这将非常有用。这被称为“公共客户端”。没有太多应用程序没有后端的情况,但如果您有类似移动应用程序的东西,并且无法利用服务器端后端进行 OAuth,您必须实现 PKCE 以保护您的应用程序免受安全问题的影响。围绕 PKCE 的安全问题超出了本指南的范围,但您可以在网上找到许多关于它们的文章。OAuth 2.1 草案也推荐使用 PKCE。

  • code_challenge_method: 这是一个可选参数,但如果您实现了 PKCE,您必须指定您的 PKCE code_challenge 参数是如何创建的。它可以是 plain 或 S256 。我们绝不建议使用除了 S256 之外的任何东西,它使用 SHA-256 安全哈希算法用于 PKCE。

  • nonce: 这是一个可选参数,用于 OpenID Connect。 nonce 参数将包含在 OAuth 服务器生成的 Id 令牌中。我们可以在检索 Id 令牌时进行验证。这将在后面讨论。

为了方便的处理scope、nonce。经常把必要的信息存储到:安全的且仅限http的cookies、session中。

代码演示:

登录接口:

    // ...
    router.get('/login', (req, res, next) => {
      const state = generateAndSaveState(req, res);
      const codeChallenge = generateAndSaveCodeChallenge(req, res);
      const nonce = generateAndSaveNonce(req, res);
    // ...

generate*实现:

存储到session

    const crypto = require('crypto');
    // ...
    // Helper method for Base 64 encoding that is URL safe
    function base64URLEncode(str) {
      return str.toString('base64')
        .replace(/+/g, '-')
        .replace(///g, '_')
        .replace(/=/g, '');
    }
    ​
    function sha256(buffer) {
      return crypto.createHash('sha256')
        .update(buffer)
        .digest();
    }
    ​
    function generateAndSaveState(req) {
      const state = base64URLEncode(crypto.randomBytes(64));
      req.session.oauthState = state;
      return state;
    }
    ​
    function generateAndSaveCodeChallenge(req) {
      const codeVerifier = base64URLEncode(crypto.randomBytes(64));
      req.session.oauthCode = codeVerifier;
      return base64URLEncode(sha256(codeVerifier));
    }
    ​
    function generateAndSaveNonce(req) {
      const nonce = base64URLEncode(crypto.randomBytes(64));
      req.session.oauthNonce = nonce;
      return nonce;
    }
    // ...

存储到cookie

    // ...
    function generateAndSaveState(req, res) {
      const state = base64URLEncode(crypto.randomBytes(64));
      res.cookie('oauth_state', state, {httpOnly: true, secure: true});
      return state;
    }
    ​
    function generateAndSaveCodeChallenge(req, res) {
      const codeVerifier = base64URLEncode(crypto.randomBytes(64));
      res.cookie('oauth_code_verifier', codeVerifier, {httpOnly: true, secure: true});
      return base64URLEncode(sha256(codeVerifier));
    }
    ​
    function generateAndSaveNonce(req, res) {
      const nonce = base64URLEncode(crypto.randomBytes(64));
      res.cookie('oauth_nonce', nonce, {httpOnly: true, secure: true});
      return nonce;
    }
    // ...

cookie加密(非必要):

    // ...
    const password = 'setec-astronomy'
    const key = crypto.scryptSync(password, 'salt', 24);
    const iv = crypto.randomBytes(16);
    ​
    function encrypt(value) {
      const cipher = crypto.createCipheriv('aes-192-cbc', key, iv);
      let encrypted = cipher.update(value, 'utf8', 'hex');
      encrypted += cipher.final('hex');
      return encrypted + ':' + iv.toString('hex');
    }
    ​
    function generateAndSaveState(req, res) {
      const state = base64URLEncode(crypto.randomBytes(64));
      res.cookie('oauth_state', encrypt(state), {httpOnly: true, secure: true});
      return state;
    }
    ​
    function generateAndSaveCodeChallenge(req, res) {
      const codeVerifier = base64URLEncode(crypto.randomBytes(64));
      res.cookie('oauth_code_verifier', encrypt(codeVerifier), {httpOnly: true, secure: true});
      return base64URLEncode(sha256(codeVerifier));
    }
    ​
    function generateAndSaveNonce(req, res) {
      const nonce = base64URLEncode(crypto.randomBytes(64));
      res.cookie('oauth_nonce', encrypt(nonce), {httpOnly: true, secure: true});
      return nonce;
    }
    // ...

3. 登录

用户将被带到 OAuth 服务器进行登录或注册。从技术上讲,OAuth 服务器可以以任何需要的方式管理登录和注册过程。在某些情况下,登录可能不是必要的,因为用户可能已经通过 OAuth 服务器进行了身份验证,或者可以通过其他方式进行身份验证(智能卡、硬件设备等)。

4. 重定向并获取token

用户登录后,OAuth 服务器将浏览器重定向回应用程序。重定向的确切位置由我们在上面的 URL 中传递的 redirect_uri 参数控制。

此时会添加一些参数,如下:

  • code: 这是用户登录后 OAuth 服务器创建的授权码。我们将使用此代码交换令牌
  • state: 这是我们传递给 OAuth 服务器的 state 参数的相同值。这将被回显到应用程序,以便应用程序可以验证 code 来自正确的位置
https://app.twgtl.com/oauth-callback?code=123456789&state=foobarbaz

请记住,浏览器将向此 URL 发出 HTTP GET 请求。为了安全地完成 OAuth 授权码授权,您应编写服务器端代码来处理此 URL 上的参数。这样做将使您能够安全地交换授权 code 参数以获取令牌。

然后就是获取token。此时我们需要知道认证服务获取token的接口地址,通过使用code来与认证服务交换获取token。

我们需要使用表单编码值向令牌端点发出 HTTP POST 请求,以获取一些参数。以下是我们需要发送到令牌端点的参数:

  • code: 这是我们用来交换令牌的授权码。
  • client_id: 这标识着我们的应用程序
  • client_secret: 这是 OAuth 服务器提供的秘钥。这绝对不应该公开,只能存储在服务器上的应用程序中。
  • code_verifier: 这是我们上面创建的代码验证器值,可以存储在会话中或者存储在 cookie 中。
  • grant_type: 这将始终是值 authorization_code ,以让 OAuth 服务器知道我们正在向其发送授权码。
  • redirect_uri: 这是我们发送给上面 OAuth 服务器的重定向 URI。它必须是完全相同的值

测试代码:

调用令牌端点,验证 state 参数是否正确,以及应该存在于 id_token 中的 nonce。它还恢复保存的 codeVerifier ,并将其传递给令牌端点,以完成 PKCE 过程。

    // Dependencies
    const express = require('express');
    const crypto = require('crypto');
    const axios = require('axios');
    const FormData = require('form-data');
    const common = require('./common');
    const config = require('./config');
    ​
    // Route and OAuth variables
    const router = express.Router();
    const clientId = config.clientId;
    const clientSecret = config.clientSecret;
    const redirectURI = encodeURI('http://localhost:3000/oauth-callback');
    const scopes = encodeURIComponent('profile offline_access openid');
    ​
    // Crypto variables
    const password = 'setec-astronomy'
    const key = crypto.scryptSync(password, 'salt', 24);
    const iv = crypto.randomBytes(16);
    ​
    router.get('/oauth-callback', (req, res, next) => {
      // Verify the state
      const reqState = req.query.state;
      const state = restoreState(req, res);
      if (reqState !== state) {
        res.redirect('/', 302); // Start over
        return;
      }
    ​
      const code = req.query.code;
      const codeVerifier = restoreCodeVerifier(req, res);
      const nonce = restoreNonce(req, res);
    ​
      // POST request to Token endpoint
      const form = new FormData();
      form.append('client_id', clientId);
      form.append('client_secret', clientSecret)
      form.append('code', code);
      form.append('code_verifier', codeVerifier);
      form.append('grant_type', 'authorization_code');
      form.append('redirect_uri', redirectURI);
      axios.post('https://login.twgtl.com/oauth2/token', form, { headers: form.getHeaders() })
        .then((response) => {
          const accessToken = response.data.access_token;
          const idToken = response.data.id_token;
          const refreshToken = response.data.refresh_token;
    ​
          if (idToken) {
            let user = common.parseJWT(idToken, nonce); // parses the JWT, extracts the none, compares the value expected with the value in the JWT.
              if (!user) {
                console.log('Nonce is bad. It should be ' + nonce + ' but was ' + idToken.nonce);
                res.redirect(302,"/"); // Start over
                return;
              }
          }
    ​
    ​
          // Since the different OAuth modes handle the tokens differently, we are going to
          // put a placeholder function here. We'll discuss this function in the following
          // sections
          handleTokens(accessToken, idToken, refreshToken, req, res);
        }).catch((err) => {console.log("in error"); console.error(JSON.stringify(err));});
    });
    ​
    ​
    function restoreState(req) {
      return req.session.oauthState; // Server-side session
    }
    ​
    function restoreCodeVerifier(req) {
      return req.session.oauthCode; // Server-side session
    }
    ​
    function restoreNonce(req) {
      return req.session.oauthNonce; // Server-side session
    }
    ​
    module.exports = app;
    ​

common.parseJWT 抽象了 JWT 解析和验证。它期望公钥以 JWKS 格式发布在一个众所周知的位置,并验证受众、发行者和到期时间,以及签名。这段代码可用于访问令牌,这些令牌没有 nonce ,以及具有的 Id 令牌。

    const axios = require('axios');
    const FormData = require('form-data');
    const config = require('./config');
    const { promisify } = require('util');
    ​
    const common = {};
    ​
    const jwksUri = 'https://login.twgtl.com/.well-known/jwks.json';
    ​
    const jwt = require('jsonwebtoken');
    const jwksClient = require('jwks-rsa');
    const client = jwksClient({
      strictSsl: true, // Default value
      jwksUri: jwksUri,
      requestHeaders: {}, // Optional
      requestAgentOptions: {}, // Optional
      timeout: 30000, // Defaults to 30s
    });
    ​
    common.parseJWT = async (unverifiedToken, nonce) => {
      const parsedJWT = jwt.decode(unverifiedToken, {complete: true});
      const getSigningKey = promisify(client.getSigningKey).bind(client);
      let signingKey = await getSigningKey(parsedJWT.header.kid);
      let publicKey = signingKey.getPublicKey();
      try {
        const token = jwt.verify(unverifiedToken, publicKey, { audience: config.clientId, issuer: config.issuer });
        if (nonce) {
          if (nonce !== token.nonce) {
            console.log("nonce doesn't match "+nonce +", "+token.nonce);
            return null;
          }
        }
        return token;
      } catch(err) {
        console.log(err);
        throw err;
      }
    }
    ​
    // ...module.exports = common;
    ​

此时,我们已经完全完成了 OAuth。我们成功地交换了授权码以获取令牌,这是 OAuth 授权码授权的最后一步。

让我们快速看一下上面的 3 个 restore 函数以及它们如何用于处理 cookies 和加密 cookies。如果我们要将这些函数用于存储在 cookies 中的值,实现方式如下:

    function restoreState(req, res) {
      const value = req.cookies.oauth_state;
      res.clearCookie('oauth_state');
      return value;
    }
    ​
    function restoreCodeVerifier(req, res) {
      const value = req.cookies.oauth_code_verifier;
      res.clearCookie('oauth_code_verifier');
      return value;
    }
    ​
    function restoreNonce(req, res) {
      const value = req.cookies.oauth_nonce;
      res.clearCookie('oauth_nonce');
      return value;
    }
    ​

对于安全性要求高的还需要加密cookie:

    const password = 'setec-astronomy'
    const key = crypto.scryptSync(password, 'salt', 24);
    ​
    function decrypt(value) {
      const parts = value.split(':');
      const cipherText = parts[0];
      const iv = Buffer.from(parts[1], 'hex');
      const decipher = crypto.createDecipheriv('aes-192-cbc', key, iv);
      let decrypted = decipher.update(cipherText, 'hex', 'utf8');
      decrypted += decipher.final('utf8');
      return decrypted;
    }
    ​
    function restoreState(req, res) {
      const value = decrypt(req.cookies.oauth_state);
      res.clearCookie('oauth_state');
      return value;
    }
    ​
    function restoreCodeVerifier(req, res) {
      const value = decrypt(req.cookies.oauth_code_verifier);
      res.clearCookie('oauth_code_verifier');
      return value;
    }
    ​
    function restoreNonce(req, res) {
      const value = decrypt(req.cookies.oauth_nonce);
      res.clearCookie('oauth_nonce');
      return value;
    }
    ​

Tokens令牌

在实践中,访问令牌通常是 JWTs。另一方面,OpenID Connect(OIDC)要求 id_token 是 JWT。

我们会获取如下token:

  • access_token: 这是一个令牌,其中包含有关用户的信息,包括他们的 ID、权限以及我们可能需要的来自 OAuth 服务器的其他任何信息。通常情况下,但并非总是,它是一个 JWT。
  • id_token: 这是一个 JWT,其中包含有关用户的公共信息,例如他们的姓名。这个令牌通常可以安全地存储在非安全的 cookie 或本地存储中,因为它不能用于代表用户调用 API。
  • refresh_token: 这是一个不透明令牌(不是 JWT),可用于创建新的访问令牌。访问令牌会过期,可能需要根据您的需求进行续订(例如,您希望访问令牌持续多久以及您希望用户保持登录状态多久)。

JWT 是包含有关用户信息并且可以签名的 JSON 对象。JSON 对象的键称为“声明”。JWT 会过期,但在此之前可以将其提交给 API 和其他资源以获取访问权限。请将它们的生命周期保持短暂,并像保护其他凭据(如 API 密钥)一样保护它们。由于它们是经过签名的,可以验证 JWT 以确保未被篡改。JWT 具有一些标准声明。这些声明包括:

  • aud: audience。JWT 的预期受众。通常这是一个标识符,您的应用程序应该验证该值是否符合预期。
  • exp: expire。JWT 的过期时间。这是以自纪元时(1970 年 1 月 1 日 UTC)以来的秒数存储的。
  • iss: identifire for that which system created the JWT。创建 JWT 的系统的标识符。通常这是在 OAuth 服务器中配置的一个值。您的应用程序应该验证这个声明是否正确。
  • nbf: not before。JWT 有效的时间点。它代表“不早于”。这是以自纪元时(1970 年 1 月 1 日 UTC)以来的秒数存储的。
  • sub: JWT 的主题。通常情况下,这是用户的 ID。

用户和令牌信息

Oauth认证服务需要提供两个接口分别用于获取用户、token的信息。Introspection 端点的目的是返回有关访问令牌本身的信息。UserInfo 端点返回授予访问令牌的用户的信息。

  • Introspection: 是 OAuth 2.0 规范的扩展,使用JWT 返回有关令牌的信息。
  • UserInfo: 此端点是 OIDC 规范的一部分,返回有关用户的信息。

Introspection

我们将使用 Introspect 获取有关访问令牌的信息。我们可以使用从此返回的信息来确保访问令牌仍然有效,或获取前一部分中涵盖的标准 JWT 声明。除了返回 JWT 声明外,此端点还返回一些额外的声明,您可以在应用程序中利用这些额外的声明。这些额外的声明包括:

  • active: 确定令牌是否仍然有效和有效。 active 的含义取决于 OAuth 服务器,但通常意味着服务器发出了它,据服务器所知,它尚未被撤销,也尚未过期。
  • scope: 在登录过程中传递给 OAuth 服务器的范围列表,随后用于创建令牌。
  • client_id: 在登录过程中传递给 OAuth 服务器的 client_id 值。
  • username: 用户的用户名。这很可能是他们登录时使用的用户名,但也可能是其他内容。
  • token_type: 令牌的类型。通常,这是 Bearer ,表示该令牌属于并描述了控制它的用户。

测试代码:

    async function (accessToken, clientId, expectedAud, expectedIss) {
    ​
      const form = new FormData();
      form.append('token', accessToken);
      form.append('client_id', clientId); // FusionAuth requires this for authentication
    ​
      try {
        const response = await axios.post('https://login.twgtl.com/oauth2/introspect', form, { headers: form.getHeaders() });
        if (response.status === 200) {
          const data = response.data;
          if (!data.active) {
            return false; // if not active, we don't get any other claims
          }
          return expectedAud === data.aud && expectedIss === data.iss;
        }
      } catch (err) {
        console.log(err);
      }
    ​
      return false;
    }
    ​

UserInfo

如果我们需要从 OAuth 服务器获取有关用户的其他信息,我们可以使用 UserInfo 端点。该端点接受访问令牌并返回有关用户的一些明确定义的声明。从技术上讲,该端点是 OIDC 规范的一部分,但大多数 OAuth 服务器都实现了它,因此您很可能可以安全地使用它。

这里是由 UserInfo 端点返回的声明:

  • sub: 用户的唯一标识符。

  • name: 用户的全名。

  • given_name: 用户的名字。

  • family_name: 用户的姓氏。

  • middle_name: 用户的中间名。

  • nickname: 用户的昵称(例如,Joseph 的昵称是 Joe)

  • preferred_username: 用户在您的应用程序中使用的首选用户名。

  • profile: 指向用户个人资料页面的 URL。

  • picture: 指向用户个人资料图片的 URL。

  • website: 指向用户网站(即其博客)的 URL。

  • email: 用户的电子邮件地址。

  • email_verified: 一个布尔值,用于确定用户的电子邮件地址是否已验证。

  • gender: 描述用户性别的字符串。

  • birthdate: 用户的出生日期,格式为 ISO 8601:2004 YYYY-MM-DD 的字符串。

  • zoneinfo: 用户所在的时区。

  • locale: 用户首选的区域设置,以小写的 ISO 639-1 Alpha-2 语言代码和大写的 ISO 3166-1 Alpha-2 [ISO3166‑1] 国家代码,用短横线分隔。

  • phone_number: 用户的电话号码。

  • phone_number_verified: 一个布尔值,用于确定用户的电话号码是否已验证。

  • address: 包含用户地址信息的 JSON 对象。子声明如下:

    • formatted: 用户的地址,完整格式的字符串。
    • street_addredd: 用户的街道地址组件。
    • locality: 用户所在的城市。
    • region: 用户的州、省或地区。
    • postal_code: 用户的邮政编码或邮政编码。
    • country: 用户的国家。
  • update_at: 用户个人资料上次更新的时间,表示从 Epoch UTC 开始的秒数。

使用授权码授予进行本地登录和注册

如果您正在实施本地登录和注册模式,则您的应用程序正在使用 OAuth 来登录用户。这意味着在 OAuth 工作流程完成后,用户应该已登录,并且浏览器应该被重定向到您的应用程序,或者原生应用程序应该具有用户信息并呈现适当的视图。

在用户登录后,需要存储token。此时有两种方式:

  • Cookies
  • Server-side Session

存储在cookie

示例代码:

    function handleTokens(accessToken, idToken, refreshToken, req, res) {
      // Write the tokens as cookies
      res.cookie('access_token', accessToken, {httpOnly: true, secure: true});
      res.cookie('id_token', idToken); // Not httpOnly or secure
      res.cookie('refresh_token', refreshToken, {httpOnly: true, secure: true});
    ​
      // Redirect to the To-do list
      res.redirect('/todos', 302);
    }
    ​

应用程序后端已将浏览器重定向到用户的待办事项列表。它还将访问令牌、ID 令牌和刷新令牌作为 cookie 发送回浏览器。浏览器现在将在每次发出请求时将这些 cookie 发送到后端。这些请求可能是用于 JSON API 或标准 HTTP 请求(即 GET 或 POST )。这种解决方案的美妙之处在于,我们的应用程序知道用户已登录,因为这些 cookie 存在。我们不必管理它们,因为浏览器为我们完成了所有工作。

id_token 之所以比access_tokenrefresh_token 处理得不那么安全,是有原因的。 id_token 绝不应该用于访问受保护的资源;它只是应用程序获取有关用户的只读信息的一种方式。例如,如果您希望您的 SPA 更新用户界面以通过姓名向用户问候,那么 id_token 是可用的。

些 cookie 也充当我们的会话。一旦 cookie 消失或失效,我们的应用程序就会知道用户已经退出登录。

示例代码:

前端请求后端获取待办事项

    // include axios
    ​
    axios.get('/api/todos')
      .then(function (response) {
        buildUI(response.data);
        buildClickHandler();
      })
      .catch(function(error) {
        console.log(error);
      });
    ​
    function buildUI(data) {
      // build our UI based on the todos returned and the id_token
    }
    ​
    function buildClickHandler() {
      // post to API when ToDo is done
    }
    ​

在 axios.get 调用中没有任何令牌发送代码。这是 cookie 方法的优势之一。只要我们从同一域调用 API,cookie 就会免费发送。如果您需要将 cookie 发送到不同的域,请确保检查您的 CORS 设置。

服务端处理的代码:


    // Dependencies
    const express = require('express');
    const common = require('./common');
    const config = require('./config');
    const axios = require('axios');
    ​
    // Router & constants
    const router = express.Router();
    ​
    router.get('/', (req, res, next) => {
      common.authorizationCheck(req, res).then((authorized) => {
        if (!authorized) {
          res.sendStatus(403);
          return;
        }
    ​
        const todos = common.getTodos();
        res.setHeader('Content-Type', 'application/json');
        res.end(JSON.stringify(todos));
      }).catch((err) => {
        console.log(err);
      });
    });
    ​
    module.exports = router;
    ​

这是 authorizationCheck 方法

const axios = require('axios');
    const FormData = require('form-data');
    const config = require('./config');
    const { promisify } = require('util');
    ​
    common.authorizationCheck = async (req, res) => {
      const accessToken = req.cookies.access_token;
      if (!accessToken) {
        return false;
      }
      try {
        let jwt = await common.parseJWT(accessToken);
        return true;
      } catch (err) {
        console.log(err);
        return false;
      }
    }
    ​
    common.parseJWT = async (unverifiedToken, nonce) => {
      const parsedJWT = jwt.decode(unverifiedToken, {complete: true});
      const getSigningKey = promisify(client.getSigningKey).bind(client);
      let signingKey = await getSigningKey(parsedJWT.header.kid);
      let publicKey = signingKey.getPublicKey();
      try {
        const token = jwt.verify(unverifiedToken, publicKey, { audience: config.clientId, issuer: config.issuer });
        if (nonce) {
          if (nonce !== token.nonce) {
            console.log("nonce doesn't match "+nonce +", "+token.nonce);
            return null;
          }
        }
        return token;
      } catch(err) {
        console.log(err);
        throw err;
      }
    }
    module.exports = common;
    ​

存储在session

我们将创建一个服务器端会话,并将所有令牌存储在那里。这种方法还会将一个 cookie 写回浏览器,但这个 cookie 只存储会话 id。这样做可以让我们的服务器端代码在每个请求期间查找用户的会话。

这是创建服务器端会话并将用户重定向到他们的待办事项列表的代码:


    var expressSession = require('express-session');
    app.use(expressSession({resave: false, saveUninitialized: false, secret: 'setec-astronomy'}));
    ​
    function handleTokens(accessToken, idToken, refreshToken, req, res) {
      // Store the tokens in the session
      req.session.accessToken = accessToken;
      req.session.idToken = idToken;
      req.session.refreshToken = refreshToken;
    ​
      // Redirect to the To-do list
      res.redirect('/todos', 302);
    }

让我们将上面的 API 代码更新为使用服务器端会话而不是 cookies:

    common.authorizationCheck = async (req, res) => {
      const accessToken = req.session.accessToken;
      if (!accessToken) {
        return false;
      }
      try {
        let jwt = await common.parseJWT(accessToken);
        return true;
      } catch (err) {
        console.log(err);
        return false;
      }
    }
    ​

刷新访问token

我们需要更新我们的代码来处理刷新访问令牌。在这种情况下,客户端,即浏览器,是知道请求失败的正确位置。它可能因为任何原因而失败,比如网络连接问题。但也可能是因为访问令牌已过期而失败。在浏览器代码中,我们应该检查错误并尝试刷新令牌,如果失败是由于过期造成的。

buildAttemptRefresh 是一个返回错误处理函数的函数。我们使用这个结构,这样我们可以在调用 API 时随时尝试刷新。如果刷新尝试成功,将调用 after 函数。如果刷新尝试失败,我们将用户发送回主页进行重新验证。

    const buildAttemptRefresh = function(after) {
      return (error) => {
        axios.post('/refresh', {})
        .then(function (response) {
          after();
        })
        .catch(function (error) {
          console.log("unable to refresh tokens");
          console.log(error);
          window.location.href="/";
        });
      };
    }
    ​
    // extract this to a function so we can pass it in as the 'after' parameter
    const getTodos = function() {
      axios.get('/api/todos')
        .then(function (response) {
          buildUI(response.data);
          buildClickHandler();
        })
        .catch(console.log);
    }
    ​
    axios.get('/api/todos')
      .then(function (response) {
        buildUI(response.data);
        buildClickHandler();
      })
      .catch(buildAttemptRefresh(getTodos));
    ​
    function buildUI(data) {
      // build our UI based on the todos
    }
    ​
    function buildClickHandler() {
      // post to API when ToDo is done
    }
    ​

由于 refresh_token 是一个 HTTPOnly cookie,JavaScript 无法调用刷新端点来获取新的访问令牌。我们的客户端 JavaScript 需要访问刷新令牌值才能这样做,但由于跨站脚本的问题,我们不允许这样做。相反,客户端调用一个服务器端路由,然后尝试使用 cookie 值刷新令牌;它可以访问该值。之后,服务器将作为 cookie 发送新值,浏览器代码可以重试 API 调用。

下面是refresh 服务器端路由,它访问刷新令牌并尝试刷新访问和 Id 令牌。

​
router.post('/refresh', async (req, res, next) => {
  const refreshToken = req.cookies.refresh_token;
  if (!refreshToken) {
    res.sendStatus(403);
    return;
  }
  try {
    const refreshedTokens = await common.refreshJWTs(refreshToken);
​
    const newAccessToken = refreshedTokens.accessToken;
    const newIdToken = refreshedTokens.idToken;
​
    // update our cookies
    console.log("updating our cookies");
    res.cookie('access_token', newAccessToken, {httpOnly: true, secure: true});
    res.cookie('id_token', newIdToken); // Not httpOnly or secure
    res.sendStatus(200);
    return;
  } catch (error) {
    console.log("unable to refresh");
    res.sendStatus(403);
    return;
  }
​
});
​
module.exports = router;
​

这是实际执行令牌刷新的 refreshJWT 代码:

common.refreshJWTs = async (refreshToken) => {
  console.log("refreshing.");
  // POST refresh request to Token endpoint
  const form = new FormData();
  form.append('client_id', clientId);
  form.append('grant_type', 'refresh_token');
  form.append('refresh_token', refreshToken);
  const authValue = 'Basic ' + Buffer.from(clientId +":"+clientSecret).toString('base64');
  const response = await axios.post('https://login.twgtl.com/oauth2/token', form, {
    headers: {
      'Authorization' : authValue,
      ...form.getHeaders()
    }
  });
​
  const accessToken = response.data.access_token;
  const idToken = response.data.id_token;
  const refreshedTokens = {};
  refreshedTokens.accessToken = accessToken;
  refreshedTokens.idToken = idToken;
  return refreshedTokens;
}
​