如何使用 HTTPOnly Cookie 启用 CORS 以保护令牌?

利用 HTTPOnly Cookie 启用 CORS 以保护访问令牌

本文将深入探讨如何运用 HTTPOnly cookie 技术结合跨域资源共享 (CORS),来增强我们访问令牌的安全性。

现今,前端客户端与后端服务器往往部署在不同的域上。因此,为了允许客户端在浏览器上与服务器顺利通信,服务器端必须启用 CORS。

此外,为了提升可扩展性,服务器正在逐步采用无状态身份验证机制。令牌在客户端存储和维护,而不是像传统会话那样在服务器端存储和管理。出于安全考虑,将令牌存储在 HTTPOnly cookie 中是更优的选择。

为何跨域请求会被浏览器阻止?

假设前端应用部署在 https://app.techblik.com。在这种情况下,该域名下加载的脚本只能请求与其同源的资源。

每当我们试图向不同域名 (例如 https://api.techblik.com),不同端口 (例如 https://app.techblik.com:3000),或是不同协议 (例如 http://app.techblik.com) 发送跨域请求时,浏览器通常会出于安全原因阻止这些请求。

但值得注意的是,同样是跨域请求,如果使用 curl 命令从任何后端服务器发起,或使用 Postman 等工具发送,却不会出现 CORS 问题。这主要是为了保护用户免受跨站请求伪造 (CSRF) 等攻击。

举例来说,如果用户在浏览器中登录了自己的 PayPal 账户,而我们能够从加载于另一个域名 (例如 https://malicious.com) 的脚本向 paypal.com 发送跨域请求,且没有任何 CORS 错误或阻止,情况就会变得非常危险。这等同于我们可以发送同源请求一样自由。

攻击者可以轻易地将包含恶意代码的页面链接转换为短网址,隐藏其真实 URL,例如 https://malicious.com/transfer-money-to-attacker-account-from-user-paypal-account。当用户点击这个恶意链接后,加载在 malicious.com 域名下的脚本可能会向 PayPal 发送跨域请求,将用户的资金转移到攻击者的 PayPal 账户中。所有登录了 PayPal 账户并点击了该恶意链接的用户都将遭受损失。攻击者可以在用户不知情的情况下轻易窃取资金。

基于以上安全风险考虑,浏览器默认会阻止所有跨域请求。

什么是 CORS (跨域资源共享)?

CORS 是一种基于 HTTP 头的安全机制,服务器通过它告知浏览器哪些域是被信任的,可以发送跨域请求。
当服务器启用了必要的 CORS 头部信息后,就可以避免浏览器阻止合法的跨域请求。

CORS 的工作原理

服务器会在其 CORS 配置中明确定义可信任的域名。当我们向服务器发送请求时,服务器的响应将包含相关信息,告知浏览器请求的域是否在信任列表中。

CORS 请求通常分为两种类型:

  • 简单请求
  • 预检请求

简单请求:

  • 浏览器会向一个跨域域名发送带有 origin 头部 (例如: https://app.techblik.com) 的请求。
  • 服务器会返回带有允许方法和允许来源的响应。
  • 浏览器在接收到响应后,会检查发送的 origin 头部值 (例如: https://app.techblik.com) 和接收到的 access-control-allow-origin 头部值是否相同或为通配符。

如果验证不一致,浏览器将引发 CORS 错误。

  • 预检请求:
  • 当跨域请求包含自定义请求参数(例如 PUT、DELETE 方法)或自定义头部,或者使用不同的内容类型时,浏览器会发送一个预检 OPTIONS 请求。这一步骤是为了验证实际请求是否能够安全发送。

当浏览器接收到预检请求的响应(状态码 204 表示无内容)后,会检查实际请求的 access-control-allow 参数。只有当服务器允许这些请求参数时,实际的跨域请求才会被发送和接收。

如果 access-control-allow-origin 设置为 *,则代表允许所有来源的响应。但除非必要,否则这种做法并不安全。

如何启用 CORS?

要为特定的域名启用 CORS,需要配置 CORS 头部信息,指定允许的来源、方法、自定义头部、凭据等。

  • 浏览器会读取服务器返回的 CORS 头部,只有在验证请求参数后,才允许客户端发送实际的请求。
  • Access-Control-Allow-Origin:用于指定精确的域名 (例如 https://app.geekflate.com, https://lab.techblik.com),或者使用通配符。
  • Access-Control-Allow-Methods:允许指定的 HTTP 方法 (例如 GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)。
  • Access-Control-Allow-Headers:允许特定的头部信息 (例如 Authorization, csrf-token)。
  • Access-Control-Allow-Credentials:一个布尔值,用于指定是否允许跨域凭据 (例如 cookies, Authorization 头部)。
  • Access-Control-Max-Age:告知浏览器缓存预检响应的时间。
  • Access-Control-Expose-Headers:指定客户端脚本可以访问的头部信息。

请参考相关教程,了解如何在 Apache 和 Nginx Web 服务器中配置 CORS。

  const express = require('express');
  const app = express()

  app.get('/users', function (req, res, next) {
    res.json({msg: 'user get'})
  });

  app.post('/users', function (req, res, next) {
      res.json({msg: 'user create'})
  });

  app.put('/users', function (req, res, next) {
      res.json({msg: 'User update'})
  });

  app.listen(80, function () {
    console.log('CORS-enabled web server listening on port 80')
  })
  

在 ExpressJS 中启用 CORS

让我们先来看一个没有启用 CORS 的 ExpressJS 应用:

npm install cors

在上面的例子中,我们为 POST, PUT, GET 方法启用了用户 API 端点,但没有为 DELETE 方法启用。

为了在 ExpressJS 应用程序中轻松启用 CORS,可以安装 cors 模块:

app.use(cors({
      origin: '*'
  }));

为所有域启用 CORS

app.use(cors({
      origin: 'https://app.techblik.com'
  }));

为单个域启用 CORS

如果需要允许来自 https://app.techblik.comhttps://lab.techblik.com 域的 CORS 请求:

app.use(cors({
      origin: [
          'https://app.geekflare.com',
          'https://lab.geekflare.com'
      ]
  }));
app.use(cors({
      origin: [
          'https://app.geekflare.com',
          'https://lab.geekflare.com'
      ],
      methods: ['GET', 'PUT', 'POST']
  }));

访问控制允许方法

要允许所有 HTTP 方法的 CORS 请求,可以直接在 ExpressJS 的 CORS 模块中省略 methods 选项。但如果只需允许特定的方法(例如 GET、POST、PUT):

app.use(cors({
      origin: [
          'https://app.geekflare.com',
          'https://lab.geekflare.com'
      ],
      methods: ['GET', 'PUT', 'POST'],
      allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token']
  }));

访问控制允许标头

用于允许发送默认值之外的头部信息。

app.use(cors({
      origin: [
          'https://app.geekflare.com',
          'https://lab.geekflare.com'
      ],
      methods: ['GET', 'PUT', 'POST'],
      allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
      credentials: true
  }));

访问控制允许凭据

如果不需要浏览器允许跨域凭据(即使 withCredentials 设置为 true),可以忽略此选项。

app.use(cors({
      origin: [
          'https://app.geekflare.com',
          'https://lab.geekflare.com'
      ],
      methods: ['GET', 'PUT', 'POST'],
      allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
      credentials: true,
      maxAge: 600 
  }));

访问控制最大年龄

提示浏览器将预检响应信息缓存指定秒数。如果不需要缓存响应,可以忽略此选项。

app.use(cors({
      origin: [
          'https://app.geekflare.com',
          'https://lab.geekflare.com'
      ],
      methods: ['GET', 'PUT', 'POST'],
      allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
      credentials: true,
      maxAge: 600,
      exposedHeaders: ['Content-Range', 'X-Content-Range']
  }));

缓存的预检响应将在浏览器中保持 10 分钟。

app.use(cors({
      origin: [
          'https://app.geekflare.com',
          'https://lab.geekflare.com'
      ],
      methods: ['GET', 'PUT', 'POST'],
      allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
      credentials: true,
      maxAge: 600,
      exposedHeaders: ['*', 'Authorization', ]
  }));

访问控制公开标头

如果我们把通配符 * 放到 exposedHeaders 中, Authorization 标头不会被公开。 因此我们需要像下面这样显式地声明:

以上代码将同时公开所有标头和 Authorization 标头。

  • 什么是 HTTP Cookie?
  • Cookie 是服务器发送到客户端浏览器的一小段数据。在后续请求中,浏览器会在每个请求中发送与同一域名关联的所有 cookie。
  • Cookie 有很多属性,可以根据需要以不同的方式配置 cookie 的行为。
  • name: cookie 的名称。
  • value: cookie 的值,对应于 cookie 的名称。
  • domain: cookie 只会被发送到定义的域名。
  • path: cookie 只会在定义 URL 前缀路径之后发送。假设我们定义了 cookie 的路径为 path='admin/',则 cookie 不会针对 https://techblik.com/expire/ 发送,但会针对以 https://techblik.com/admin/ 开头的 URL 发送。
  • Max-Age/Expires (秒数): cookie 的过期时间。cookie 的生命周期使得 cookie 在指定的时间后失效。
  • HTTPOnly (布尔值):当设置为 true 时,该 HTTPOnly cookie 只能被后端服务器访问,而不能被客户端脚本访问。 Security (Boolean): 如果设置为 true, cookie 只会通过 SSL/TLS 域发送。
  • SameSite (字符串):用于启用或限制跨站点请求发送 cookie。要了解更多关于 cookie 的 sameSite 属性,请参考 MDN。它接受三个选项: Strict, LaxNone。 当 cookie 配置为 sameSite=None 时,secure 的值必须设置为 true

为什么为令牌使用 HTTPOnly Cookie?

将服务器发送的访问令牌存储在客户端存储中(例如 localStorage、IndexedDB 和未设置 HTTPOnly 的 cookie)更容易受到 XSS 攻击。如果网站的任何页面存在 XSS 漏洞,攻击者可能会滥用存储在浏览器中的用户令牌。

HTTPOnly cookie 只能由服务器/后端设置和获取,而不能在客户端访问。

  • 客户端脚本无法访问 HTTPOnly cookie。因此 HTTPOnly cookie 更不容易受到 XSS 攻击,也更安全,因为它只能由服务器访问。
  • 在启用 CORS 的后端中启用 HTTPOnly cookie。
  • 要在 CORS 中启用 Cookie,需要在应用程序/服务器中进行以下配置:
  • Access-Control-Allow-Credentials 标头设置为 true

Access-Control-Allow-OriginAccess-Control-Allow-Headers 不应设置为通配符。

 const express = require('express'); 
 const app = express();
 const cors = require('cors');

 app.use(cors({ 
    origin: [ 
        'https://app.geekflare.com', 
        'https://lab.geekflare.com' 
    ], 
    methods: ['GET', 'PUT', 'POST'], 
    allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'], 
    credentials: true, 
    maxAge: 600, 
    exposedHeaders: ['*', 'Authorization' ] 
 }));

 app.post('/login', function (req, res, next) { 
  res.cookie('access_token', access_token, {
      expires: new Date(Date.now() + (3600 * 1000 * 24 * 180 * 1)), //second min hour days year
      secure: true, // set to true if your using https or samesite is none
      httpOnly: true, // backend only
      sameSite: 'none' // set to none for cross-request
    });

  res.json({ msg: 'Login Successfully', access_token });
 });

 app.listen(80, function () { 
    console.log('CORS-enabled web server listening on port 80') 
 }); 
 

Cookie 的 sameSite 属性应设置为 none

要将 sameSite 的值设为 none,需要将 secure 的值设置为 true:启用后端 SSL/TLS 证书以在域名中工作。

接下来,我们来看一个示例代码,该代码会在验证登录凭据后,在 HTTPOnly cookie 中设置访问令牌。

可以通过在后端语言和 Web 服务器中实现上述四个步骤来配置 CORS 和 HTTPOnly Cookie。

var xhr = new XMLHttpRequest();
 xhr.open('GET', 'http://api.techblik.com/user', true);
 xhr.withCredentials = true;
 xhr.send(null);

您可以按照本教程中的步骤,在 Apache 和 Nginx 中启用 CORS。

fetch('http://api.techblik.com/user', {
   credentials: 'include'
 });

withCredentials 用于跨域请求

$.ajax({
     url: 'http://api.techblik.com/user',
     xhrFields: {
       withCredentials: true
     }
  });

凭据 (Cookie, Authorization) 默认会与同源请求一起发送。对于跨域请求,必须将 withCredentials 指定为 true

axios.defaults.withCredentials = true

XMLHttpRequest API

Fetch API

jQuery Ajax

Axios

结论:希望上述文章能够帮助您理解 CORS 的工作原理,并了解如何在服务器中为跨域请求启用 CORS。此外,文章还阐述了为什么将 cookie 存储在 HTTPOnly 中是安全的,以及客户端如何使用 withCredentials 进行跨域请求。