Skip to content
微信公众号

不可忽视的前端安全 - 单页应用鉴权设计 ​

安全是计算机科学永远无法忽视的话题。随着互联网的发展,安全问题越来越突出,也越来越重要:它是一个程序可用性、健壮性的基础。这个话题可大可小,大到系统的设计,小到一行代码的写法,都可能影响系统的安全。

毫不例外,安全与前端开发的结合也持续走热。不管是经验丰富的程序员,还是尚在打基础的学生,也许都对 HTTPS、XSS、CSRF 等前端相关的安全问题不陌生。然而,这其中每一个主题都可以非常深入,都能系统地做一节课。但是,我认为面面俱到、走马观花地梳理这些内容,讲解这些概念价值不大。毕竟,这方面知识都已经比较成熟,社区上资料很多。

本讲我想从一个大部分产品都要涉及的登录鉴权入手,结合单页面应用,从这个角度,管中窥豹,尽可能多地涉及一些常见的安全知识,帮助大家了解前端安全。

接下来,让我们从应用场景入手,从前后端交互切入,以单页面应用为基础,呈现“鉴权”这个安全领域重要话题的全貌,并尽力覆盖到 XSS 和 CSRF 等攻击手段以及最佳实践。

关于这个主题的知识点如下:

单页应用鉴权简介 ​

首先,我们要分清单页应用鉴权与传统鉴权方式有所不同:

单页应用采用前后端分离的设计方式,路由由前端管理,前后端遵循一定规范(如 REST、GraphQL),通过 AJAX 进行通信。在这种情况下,用户对页面请求时,后端经常无法获取用户身份信息,更无法确定返回的数据。

同时一次鉴权完毕后,如何在单页应用的体验当中,保持这个鉴权状态也值得思考。一般来说,单页应用鉴权采用下面的步骤实现。

  • Step 1:前端根据用户交互,发送数据请求之前,需要准备用户信息,同数据请求一起发给后端处理。
  • Step 2-1:后端按照约定好的规则,根据请求中带有的用户身份信息,进行验证。如果验证不通过,返回 403 或者 401 相关状态码或其他状态,以表示鉴权失败。
  • Step 2-2:如果鉴权成功,后端返回相关数据。
  • Step 3:前端根据数据渲染视图。

基本结构非常简单清晰:

在这个结构背后,隐藏的技术方案和安全细节非常值得我们思考,请继续阅读,我们将剖析几个重要概念和安全实践。

HTTPS ​

鉴权过程中,如果使用 HTTP 协议来传输敏感数据(用户昵称、用户密码、token……),那么很容易被中间人拦截获取。现代通信中,我们都使用 HTTPS 协议来对传输内容进行加密。关于 HTTPS 的应用及其原理,又是一个超级话题。这里由于内容的限制,不过多展开,给大家分享一下我收藏的关于 HTTPS 好的文章:

不要使用 URL query 传递敏感数据 ​

URL query 会通过服务端日志、浏览器日志、浏览器历史记录查到。不要使用 URL query 传递敏感数据,这当然是最基本的准则之一。如果敏感数据在 URL query 中,这就给了恶意用户轻松获取数据的机会。同时,URL query 的长度也有限制,这也是其传递数据的弊端之一。

防止暴力攻击的手段 ​

攻击者可以通过暴力手段,尝试攻破用户的密码等信息。因此后端服务要时刻注意加入频率限制,限制一个用户短时间尝试密码的次数;也可以限制可疑用户(比如触发了过多服务端错误用户)的访问。另外,需要注意的是不要给任何人暴露服务端的技术细节信息,比如要记得关闭 X-Powered-By(服务器响应头隐藏);Node 端在使用 express.js 的情况下,强烈建议使用 Helmetjs。

Helmet 帮助 Node.js 开发者通过设置合理的 HTTP header,预防一些常见的 Web 漏洞,比如上面提到的关闭 X-Powered- By。实际上它就是一组灵活的中间件函数,增强以下 HTTP header 的安全性:

  • Content-Security-Policy 响应头,它可以设置应用是否可以引用某些来源内容,进而防止 XSS
  • 关闭 X-Powered-By 响应头,以避免暴露服务端信息
  • 增加 Public Key Pinning 响应头,预防中间人伪造证书
  • 设置 Strict-Transport-Security 响应头,这样浏览器只能通过 HTTPS 访问当前资源
  • 为 IE8+ 设置 X-Download-Options 响应头,目前只有 IE8+ 支持这个 header,用来预防下载内容的安全隐患
  • 设置 Cache-Control 和 Pragma header 以关闭浏览器端缓存
  • 设置 X-Content-Type-Options 响应头,以禁用浏览器内容嗅探
  • 设置 X-Frame-Options 响应头,以预防 clickjacking,这个响应头给浏览器指示是否允许在 <frame> 或者 <iframe> 标签中渲染某个页面
  • 设置 X-XSS-Protection 响应头,当检测到跨站脚本攻击(XSS)时,浏览器停止加载页面

它的使用非常简单:

js
const express = require('express')
const helmet = require('helmet')

const app = express()

app.use(helmet())
const express = require('express')
const helmet = require('helmet')

const app = express()

app.use(helmet())

其源码是典型的 express 中间件写法,它依次加载相关中间件集。比如它将引用 X-Powered-By 中间件,这个中间件的源码非常简单:

js
module.exports = function hidePoweredBy (options) {
  var setTo = (options || {}).setTo

  if (setTo) {
    return function hidePoweredBy (req, res, next) {
      res.setHeader('X-Powered-By', setTo)
      next()
    }
  } else {
    return function hidePoweredBy (req, res, next) {
      res.removeHeader('X-Powered-By')
      next()
    }
  }
}
module.exports = function hidePoweredBy (options) {
  var setTo = (options || {}).setTo

  if (setTo) {
    return function hidePoweredBy (req, res, next) {
      res.setHeader('X-Powered-By', setTo)
      next()
    }
  } else {
    return function hidePoweredBy (req, res, next) {
      res.removeHeader('X-Powered-By')
      next()
    }
  }
}

通过 setHeader 和 removeHeader 方法,完成对 X-Powered-By 响应头的添加和删除。

升级依赖保证安全 ​

现如今我们的应用,大部分脚本都来自第三方依赖,第三方库出现安全隐患的新闻已经屡见不鲜。除了从源头把控依赖的引入外,适时合理地更新 npm 包,是值得倡导的做法,npm 便在 6.0 后有相关命令如下

shell
# npm 6.0 新增,扫描所有依赖,列出依赖中有安全隐患的包
npm audit
# npm 6.0 新增,扫描所有依赖,并把不安全的依赖包升级到可兼容的版本
npm audit fix
# npm 6.0 新增,扫描所有依赖,列出依赖中有安全隐患的包
npm audit
# npm 6.0 新增,扫描所有依赖,并把不安全的依赖包升级到可兼容的版本
npm audit fix

单页应用鉴权实战 ​

言归正传,我们来看一下实现单页应用鉴权的两种主要手段:

  • JWT
  • Authentication cookie

这两种方式不尽相同,我们将逐一分析,并尝试合并这两种方案的优点,将它们结合为第三种方式。

采用 JWT 实现鉴权 ​

在鉴权过程中,为了验证用户的身份,需要浏览器向服务器端提供一个验证信息,我们称为 token。这个 token 通常由 JSON 数据格式组成,通过 hash 散列算法生成一个字符串,称为 JSON Web Token(JSON 表示令牌的原始类型为 JSON 格式,Web 表示在互联网中进行传播,Token 表示令牌,简称 JWT)。任何 token 持有者都可以无差别地用它来访问相关的资源。

我们可以在 HTTP Authorization header 中找到 token,其实就是一个字符串值。这个字符串用来表示用户的身份信息,进行身份认证或者从服务器获取合法资源。当然这个 token 往往是被加密的。那么这个 token 具体是如何生成的呢?

我们先从 JWT 说起,一个 JWT 包含以下 3 个部分:

  • header(消息头)
  • payload(消息体,储存用户 id、用户角色等) + 过期时间(可选)
  • signature(签名)

我们说过,JWT 就是 JSON 格式的数据,JWT 的前两个部分就是 JSON 数据,第三部分 signature 是基于前两部分 header 和 payload 生成的签名。前两部分分别通过 Base64URL 算法生成两组字符串,再和 signature 结合,三部分通过 . 号分割,就是最终的 token。

更多这方面的信息,大家可以参考:

正常来讲,当客户端在提交用户名/密码(或者其他方式)通过认证后,会获得 JWT 的 token,接着通过 JavaScript 脚本,对于所有数据请求都在其 HTTP header 中加上这个 JWT 的 token。服务端接到请求之后,验证 token 的 signature 是否等同于 payload,进而得知 payload 字段是否被中间人更改。

细心的读者可能会发现,我们提到“通过 JavaScript 脚本,对于所有数据请求,都在 HTTP header 中加上这个 token”。这就涉及 客户端如何存储和维护 JWT 的问题了。

存储 JWT,我们可以考虑:

  • 内存存储
  • local/session cookie
  • local/session storage……

这几种方式。我并不建议开发者将 token 存储在 local storage 当中,因为:

  • 当用户关掉浏览器后,JWT 仍然会被存储在 local storage 中,即便 JWT 过期,可能一直被存储(除非手动更新或清理)
  • 任何 JavaScript 都能轻而易举地获得 local storage 的内容
  • 无法被 web worker 使用

但在实际项目中,笔者也在 localstorage 中存储过 JWT,这需要我们分清利弊,结合实际场景选择方案。如果吃透概念,就能减少 bug 的出现,具体存储方案可以灵活一些。

更好的选择之一是将 JWT 存储在 session cookie 中,auth0 有一篇很好的文章,感兴趣的读者可以参考:Where to Store Tokens。

JWT 隐患 ​

JWT 实现鉴权也存在的隐患,上面我们也简要提到了,隐患主要来自 XSS。攻击者可以主动注入恶意脚本或者使用用户输入,通过 JavaScript 代码来偷取 token,接下来便能通过 token 冒充受害用户。

比如,一个博客留言系统,用户可以在其留言内容中加入以下脚本:

js
<img src=x onerror="&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041">
<img src=x onerror="&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041">

一般的防御手段是采用 HTML 转义来控制过滤用户输入(为了防止 XSS 攻击,常常需要将用户输入的特殊字符进行转义)。

cookie 是含有有效期和相关 domain,存储在浏览器中的键值对组合,可以由 JavaScript 创建:

js
document.cookie = ‘my_cookie_name=my_cookie_value’
document.cookie = ‘my_cookie_name=my_cookie_value’

也可以由服务端通过 response header 创建:

js
Set-Cookie: my_cookie_name=my_cookie_value
Set-Cookie: my_cookie_name=my_cookie_value

浏览器会自动在每个请求当中加入相关 domain 下的 cookie:

js
GET https://www.example.com/api/users
Cookie: my_cookie_name=my_cookie_value
GET https://www.example.com/api/users
Cookie: my_cookie_name=my_cookie_value

cookie 一般分为两种(出处):

  • Session cookie,这种 cookie 会随着用户关闭浏览器而被清除,不会被标记任何过期时间 Expires 或者最大时限 Max- Age。
  • Permanent cookie,与 session cookie 相反,会在用户关闭浏览器之后被浏览器持久化存储。

同时,服务端可以对 cookie 进行一些关键配置,以保障 cookie 的使用安全,诸如:

  • HttpOnly cookie:浏览器端 JavaScript 没有读 cookie 权限。
  • Secure cookie:传输链路只有在特定安全通道(通常指 HTTPS),请求才会自动加入相关 cookie。
  • SameSite cookie:在跨域情况下,相关 cookie 无法被请求携带,这里主要是为了防止 CSRF 攻击。

一个经典场景就是使用 cookie 存储一个 session ID(session ID 由服务端管理,进行创建和计时,以便在必要的时候清除)。通过验证 cookie 和 session ID,服务端便能标记一个用户的访问信息。这种情况就是我们说的 stateful,而本节课的主角 JWT 是 stateless 的,因为它不需要服务端维护 session ID,是无状态的,更加利于横向扩展。

采用 Authentication cookie 实现单页应用鉴权的安全隐患主要有两种:

  • XSS 如果没有使用 httpOnly 选项,那么攻击者可能会通过注入恶意脚本,任意读取用户 cookie。而 cookie 直接存储了用户的身份认证信息,这当然是非常可怕的。
  • CSRF 是常见的针对 cookie 展开进攻的手段。我们知道跨域访问技术(CORS,跨域资源共享)的同源策略能保证不同源的客户端脚本在没有明确授权的情况下,无法读写对方资源。同源策略只是针对浏览器侧的编程脚本语言,如果我们对另一个恶意服务器发送 AJAX 请求,同源策略会有所限制,但是如果请求直接通过 HTML form 发送,那么同源策略毫无办法。

另一个利用 CSRF 实施攻击的场景为:假如受害者在网页中登录了 Facebook,同时又打开了 bad.com,bad.com 属于攻击者的网站,这个网站中有这样的代码:

js
<img src="https://facebook.com/postComment?userId=dupont_123&comment=I_VE_BEEN_HACKED">
<img src="https://facebook.com/postComment?userId=dupont_123&comment=I_VE_BEEN_HACKED">

如此一来,攻击者网站的代码请求了 Facebook 发送个人状态的接口(https://facebook.com/postComment?),该受害者会莫名其妙地发送一个状态,内容为 "I_VE_BEEN_HACKED"。

总结 为了防御 XSS 攻击,需要开发者设置 httpOnly 选项;为了防御 XSRF,需要开发者设置 SameSite 选项。需要注意,并不是所有浏览器都支持 SameSite。

此外,一些其他防御手段有:

  • Short session timeout:设置 session 过期时间,比如银行网站往往需要每 10 分钟或者更短时间就重新登录。 关键操作需要用户重新进行鉴权认证。
  • Double submitted cookie:当用户浏览一个站点时,服务端生成一个伪随机数 pseudorandom value,并将其设置为 cookie,且不设置 httpOnly 标识。这样 JavaScript 就能够访问这个 pseudorandom value,并要求在提交每个表单时,一并将这个 pseudorandom value 作为 form value 提交上来,同时在 cookie 中也要提交 value。服务端便可以对比 form value 中的 pseudorandom value 和 cookie value 是否一致,以此来认证用户的安全身份。

Double submitted cookie 之所以能有效防范攻击,是因为同源策略致使攻击者无法读取来自攻击目标服务端的 cookie 值,更无法修改攻击网站的 cookie value。即便攻击者可以从 form 中提交任何 form value,但是无法通过服务端对 form value 中的 pseudorandom value 和 cookie value 的一致性进行验证。

设想我们要实现这样一个鉴权系统:

  • 尽可能抵御 XSS 和 CSRF
  • 做到 stateless

考虑到安全性能,JWT 方案的主要问题在于攻击者存在直接读取 JWT 信息的可能。 如果我们将 JWT 和 cookie 方案结合呢 ?即将 JWT 部分敏感信息放入 cookie 当中,这样一来,便可以结合前文两种方式的优点。

如图,我们再总结一下存在的三种交互可能。第一种是经典 JWT 方式:

这种情况下,前后端使用 JWT 进行鉴权交互,前端通过 JavaScript 操作 JWT 信息完成请求准备。

第二种方式,将 JWT 信息在 session cookie 中维护:

在这种情况下,JWT 信息全部存储在 cookie 中, 并设置 cookie 的 httpOnly、SameSite、Secure 属性,前端无法读取 JWT 信息,但每次请求都会由浏览器带上必要的 JWT 数据(作为 cookie)。同时,由于采用 session cookie,也不存在 JWT 信息过期的情况,用户关闭页面之后不会将 JWT 信息持久化存储,下次再打开页面时,会重新进行鉴权流程。

第一种方式有一定的安全隐患;第二种方式我们将 JWT 所有信息存储在 session cookie 当中,优点明显,但是无法做到持久化存储,在某种程度上也会带来不便。那么我们权衡之后进行了变通,结合前面两种方式产生了第三种方式:

这样,JWT 的 signature 部分维护在设置了 httpOnly 的 cookie 中,这意味着 JavaScript 无法读取完整的 JWT 信息。同时,cookie 会在每次请求中被携带, 并由服务端返回后在浏览器中进行存储,这样 JWT 信息在每次请求时都可以被更新,JWT 过期时间也会被自动加入。

这篇文章:Getting Token Authentication Right in a Stateless Single Page Application 就很好地对上述方式进行了总结。

为了实现最大限度的安全保障,我们也可以考虑结合前文介绍的 Double submitted cookie 以及“关键操作需要用户重新进行鉴权认证”的处理。 例如,我们认为用户更改邮箱地址,是一个关键操作。那么,在发生这个操作时,即便用户已经登录,系统还是要求用户重新填写用户密码,以确认修改。后端在收到修改请求后,产生一个随机 number(经过加密运算),作为 permanent cookie 返回给前端,JavaScript 需要读取这个值,并将这个随机 number 作为表单 form value 的一项,它需要随新的邮箱地址一起提交,服务端对这个随机 form value 进行验证,验证方式是对比表单中的 form value 和 cookie 当中的随机 number 是否一致。

这样便更大限度地防御了 CSRF 攻击,流程如下:

我们总结一下流程。

  • Step 1:单页应用检查 cookie 中是否存在 JWT payload,如果存在,表示用户已经成功进行鉴权;反之,重定向到类似 /login 的登录页面。
  • Step2:用户在未授权的情况下,在登录页面 /login 将用户名和密码提交给服务端,服务端返回信息中设置 authentication cookie,cookie 中含有 JWT 信息。

第二步的具体操作方法可以采用上述第二种和第三种方式,或者增强 CSRF 防御的其他手段。

总结 ​

我们再来总结一下单页应用进行鉴权的关键问题:token 最初由服务端下发,前端在请求时需要携带。这样一来:

  • 如果前端将 JWT 存储在 localStorage 或者 sessionStorage 当中,由于 localStorage 或者 sessionStorage 都可以被 JavaScript 访问,如果攻击者能够读取 localStorage 或者 sessionStorage,那么就能轻易获取 token,很容易进行 XSS 攻击。
  • 如果将 JWT 存储在 cookie 当中,我们就可以指定 cookie httpOnly 属性,来防止被 JavaScript 读取,也可以指定 secure 属性,来保证 JWT 信息只在 HTTPS 下被携带。但是这样容易遭到 CSRF 攻击,因此就出现了我们的增强方式。

本节我们通过分析和设计单页应用鉴权方案,熟悉了 JWT 和传统 cookie- session。我们在介绍一些安全方面最佳实践的同时,覆盖了一些常见的攻击手段:XSS 和 CSRF 等。前端安全是一个庞大且复杂的课题,本节只是通过一个比较重要的话题带大家切入,要想全面熟悉前端安全,完全可以开一门新课了。虽然我的课程志不在此,不过下面我会根据相关安全话题,将我收藏的文章分享给大家。

分享 ​

HTTPS 相关 ​

攻防 ​

同源策略和跨域理论相关 ​

鉴权 ​

CDN 劫持和其他安全问题 ​

本站总访问量次,本站总访客数人次