前后端分离项目的 Token 存储问题由来已久,有的人存 Cookie 有的人存 LocalStorage 或 SessionStorage,最近刚把 RSS 订阅器项目的鉴权问题做好,感觉算是目前比较稳妥安全的方案了,分享一下经验。

前端用的是 Angular,后端用的是 Koa,登录注册界面由 Koa 负责,由 Koa 根据用户是否登录来决定首页跳转。我不会一开始就讲我的做法,而是循序渐进的从传统的存储方式逐渐过渡到我的做法当中来。

如何安全的传输用户 token

这是最传统也是最简单的方式了,前端登录,后端根据用户信息生成一个 token,并保存这个 token 和对应的用户 id,接着把 token 传给用户,存入浏览器 cookie,之后浏览器请求带上这个 cookie,后端根据这个 cookie 来标识用户。

flow-cookie-session

但这样做问题就很多,如果我们的页面出现了 XSS 漏洞,由于 cookie 可以被 JavaScript 读取,XSS 漏洞会导致用户 token 泄露,而作为后端识别用户的标识,cookie 的泄露意味着用户信息不再安全。

尽管我们通过转义输出内容,使用 CDN 等可以尽量避免 XSS 注入,但谁也不能保证在大型的项目中不会出现这个问题。另外,后端每次都需要根据 token 查出用户 id,这就增加了数据库的查询和存储开销。

在设置 cookie 的时候,其实你还可以设置 httpOnly 以及 secure 项。设置 httpOnly 后 cookie 将不能被 JS 读取,浏览器会自动的把它加在请求的 header 当中,设置 secure 的话,cookie 就只允许通过 HTTPS 传输。

secure 选项可以过滤掉一些使用 HTTP 协议的 XSS 注入,但并不能完全阻止。

httpOnly 选项使得 JS 不能读取到 cookie,那么 XSS 注入的问题也基本不用担心了。但设置 httpOnly 就带来了另一个问题,就是很容易的被 XSRF,即跨站请求伪造。当你浏览器开着这个页面的时候,另一个页面可以很容易的跨站请求这个页面的内容。因为 cookie 默认被发了出去。

CSRF

看起来我们不能兼顾。确实,光依靠这一个 token 我们没办法兼顾这两点。既然一个不够,那就两个。于是有了 XSRF-TOKEN,它和作为用户令牌的 token 类似,也是服务器生成的一个散列值。我们把 token 通过 httpOnly 发回去,把 XSRF-TOKEN 直接发回去。我们可以无视 httpOnly 的 cookie 因为我们没法操纵它,但对于这个 XSRF-TOKEN,我们就可以在我们网站的每个请求中都加入到 header 里面去。而服务端就需要检查这个 header 的 XSRF-TOKEN 是否真实有效。

由于 XSRF-TOKEN 以非 httpOnly 的形式存储在 cookie 中,正常情况下只有我们自己的网站可以获取到该 XSRF-TOKEN。这样 XSRF 攻击就变得不太可能了。另外由于用户 token 是通过 httpOnly 形式存储,JS 不可获取,这样也保证了用户 token 的安全。XSS 注入最多只能获取到 XSRF-TOKEN。

但还是有一种可能,XSS 注入取得 XSRF-TOKEN 后在当前页面发送请求出去。本文并不打算讨论 XSRF 和 XSS,明白这两个真正危害的地方就可以知道,这种 XSS 注入取得 XSRF-TOKEN 后发送请求其实并没有带来什么危害。不过呢,还是要看具体情况吧,如果我们的网站有一个投票 XXX 的接口,这个接口的链接被用在 XSS 注入中,那么当所有人打开这个页面的时候,都会自动的朝 XXX 投了一票。

不同于 XSRF, XSRF 可以从其他网站执行该段脚本,而这里只能注入到我们的网站中来执行。因为我们的 JS 也是这样子做的,取出 XSRF-TOKEN 放入请求头部然后发送请求出去,所以这就无法避免了。事实上,由于我们的前端代码都是公开的,无论 JS 层面绕多少个弯,XSS 注入还是可以照着做过来。但好在这种方式其实造成的影响相当有限,并不会比我们常说的 XSS 注入和 XSRF 攻击的危害大,要知道 XSS 注入危害的 cookie 的泄露,但其实这里并没有 cookie 的泄露。

我们再讨论另一个问题,前面也说了,服务器要经常去查询这个 token 对应的是哪一个用户。其实可不可以不要服务器去查询呢?如果我们生成 token 遵循一定的规律,比如我们使用对称加密算法来加密用户 id 形成 token,那么服务端以后其实只要解密该 token 就可以知道用户的 id 是什么了。不过呢,我只是举个例子而已,要是真这么做,只要你的对称加密算法泄露了,所有用户信息都不再安全了。恩,那用非对称加密算法来做呢,由公钥加密生成 token,私钥来解密 token,这样做就安全多了。其实现在有个规范就是这样做的,就是我们接下来要介绍的 JWT。

Json Web Token

接下来我们就简单介绍 JWT 这个东西,全称叫 Json Web Token。

JWT 简介

JWT 是一个开放标准(RFC 7519),它定义了一种用于简洁,自包含的用于通信双方之间以 JSON 对象的形式安全传递信息的方法。JWT 可以使用 HMAC 算法或者是 RSA 的公钥密钥对进行签名。它具备两个特点:

  • 简洁(Compact)

    可以通过URL, POST 参数或者在 HTTP header 发送,因为数据量小,传输速度快

  • 自包含(Self-contained)

    负载中包含了所有用户所需要的信息,避免了多次查询数据库

JWT 组成

JWT 由 Header, Payload, Signature 三部分组成,即头部,负载,签名,长这样:

jwt
  • Header 头部

    头部包含了两部分,token 类型和采用的加密算法

    1
    2
    3
    4
    {
    "alg": "HS256",
    "typ": "JWT"
    }

    它会使用 Base64Url 编码组成 JWT 结构的第一部分

  • Payload

    这部分就是我们存放信息的地方了,你可以把用户 ID 等信息放在这里,JWT 规范里面对这部分有进行了比较详细的介绍,常用的由 iss(签发者),exp(过期时间),sub(面向的用户),aud(接收方),iat(签发时间)。这些我们应该规范的使用,因为他们可能是在校验中使用到了(猜测,表示我一个也没用到 =.= )

    例如:

    1
    2
    3
    4
    5
    {
    "sub": "1234567890",
    "name": "John Doe",
    "admin": true
    }

    同样的,它会使用 Base64Url 编码组成 JWT 结构的第二部分

  • Signature

    前面两部分都是使用 Base64Url 进行编码的,即客户端可以解开知道里面的信息。Signature 需要使用编码后的 header 和 payload 以及我们提供的一个密钥,然后使用 header 中指定的签名算法进行签名。签名的作用是保证 JWT 没有被篡改过。

三个部分通过 . 连接在一起就是我们的 JWT 了,它可能长这个样子:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjU3ZmVmMTY0ZTU0YWY2NGZmYzUzZGJkNSIsInhzcmYiOiI0ZWE1YzUwOGE2NTY2ZTc2MjQwNTQzZjhmZWIwNmZkNDU3Nzc3YmUzOTU0OWM0MDE2NDM2YWZkYTY1ZDIzMzBlIiwiaWF0IjoxNDc2NDI3OTMzfQ.PA3QjeyZSUh7H0GfE0vJaKW4LjKJuC3dVLQiY4hii8s

长度貌似和你的加密算法和私钥有关系。其实到这一步可能就有人会想了,HTTP 请求总会带上 token,这样这个 token 传来传去占用不必要的带宽啊。如果你这么想了,那你可以去了解下 HTTP2,HTTP2 对头部进行了压缩,相信也解决了这个问题。

JWT 使用

JWT 生成了,怎么使用就看你了,不过还是有一点要求的。当访问需要 JWT 验证的 API 时,需要把该 JWT 放入头部的 Authorization 中

1
Authorization: Bearer <token>

注意 Bearer 是必须的,中间有一个空格,后面跟着 JWT,这样服务端就可以从 Authorization 取出来用了。当然了,你也可以完全爱怎么来就怎么来。但是按照规范你可以省很多事情。

简单的 JWT 流程是这样的,不带 XSRF 的,没有找到带 XSRF 的图 =.=

tokens-new

JWT 实践

刚才前面也说了,前端用的是 Angular,后端用的是 Koa,登录注册界面由 Koa 负责,由 Koa 根据用户是否登录来决定首页跳转。首先要强调下,下面说的不是唯一的方式,也不是最好的方式,而只是我自己这么用了并且我认为挺稳妥的。我在 Koa 中使用了两个模块,jsonwebtokenkoa-jwt。我以登录为例简单说下整个流程。

登录,生成 JWT

在说登录的处理之前,我想先强调这个登录页面还是后端(ejs)来渲染的,而不是前端来渲染。这个其实也会影响到我们存储 token 的考虑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
router.get(['/', '/login'], async (ctx, next) => {
if(ctx.cookies.get('jwt')) {
let token = jwt.decode(ctx.cookies.get('jwt'));
if(token.id) {
let result = await UserModel.findById(token.id);
if(result && result._id) await send(ctx, './public/index.html');
} else {
ctx.cookies.set('jwt', null, {overwrite: true, expires: new Date()});
ctx.render('login.ejs', {err: 'JWT 验证失败'});
}
} else {
await ctx.render('login.ejs');
}
});

当用户访问网站主页或者登录页面的时候,首先要先判断下是否已经有效登录了,如果是,那么跳转到 Angular 中去,否则跳转到登录页面。因为我想法是做一个主页,这个主页不需要加载太多类库,只是简单的展示页面和登录注册页面,用户登录或注册成功后在跳转到 Angular 的入口文件去。

接下来就是真正的登录接口了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
exports.login = async (ctx, next) => {
let result = await UserModel.findOne({
email: ctx.request.body.email,
password: SHA256(ctx.request.body.password).toString()});
let xsrf = SHA256(_.random(999999999)).toString();
if(result && result._id) {
let token = jwt.sign({id: result._id, xsrf: xsrf}, config.app.secretKey);
ctx.cookies.set("XSRF-TOKEN", xsrf, {httpOnly: false, overwrite: true, expires: new Date(new Date().getTime() + 5184000000)});
ctx.cookies.set("jwt", token, {httpOnly: true, overwrite: true, expires: new Date(new Date().getTime() + 5184000000)});
await ctx.redirect('/');
} else {
let exist = await UserModel.findOne({email: ctx.request.body.email});
if(exist && exist._id) ctx.throw(401, '密码错误');
else ctx.throw(401, '邮箱未注册');
}
}

恩,熟悉 Node 的应该看懂没啥问题。这里有个变量 xsrf ,作用前面我们已经说了,还有变量 token 就是 JWT 了。然后我们把他设置到 cookie 中,注意 xsrf 不能设置 httpOnlytoken 需要设置为 httpOnly,不要忘了把 xsrf 也放入 JWT 的 payload 部分中去,这里 payload 存储了用户 id 和当前的 xsrf

请求带上 XSRF

我们需要在以后的每个请求都带上 XSRF-TOKEN,具体操作就是把 cookie 中的 XSRF-TOKEN 取出来,放入请求的 X-XSRF-TOKEN 头部中,然后发送出去就好了。如果你用的是 Angular,其实你什么都不需要做了,因为这一步 Angular 已经帮你做好了,前提是你的 xsrf 必须放到 cookie 中的 XSRF-TOKEN 这个里面。如果你用的不是 Angular,那你就自己查下怎么做吧,这一步并不难做到。

设置 header 和校验 XSRF

前面说了,我们需要把 JWT 放到请求的 Authorization 头部中,但是由于我们对 JWT 设置了 httpOnly ,所以这个操作几乎就不太可能了。但别忘了我们可以在服务端做这一步,与此同时我们也可以把校验 XSRF 也做了,这里先不需要校验 JWT。

如果熟悉 Koa 的话就清楚 Koa 的中间件思路。我们在较顶层的位置写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// XSRF 检测,处理客户端未授权问题
app.use(async (ctx, next) => {
let token = ctx.cookies.get('jwt'), xsrf = ctx.request.headers['x-xsrf-token'];
ctx.request.header.authorization = 'Bearer ' + token;
// 当 JWT 存在且访问 API 时,检测 XSRF
if(token !== void 0 && /^\/api\//.test(ctx.url)) {;
let verify = Promise.promisify(jwt.verify);
await verify(token, config.app.secretKey).then(async (data) => {
if(xsrf !== data.xsrf) {
ctx.cookies.set("XSRF-TOKEN", null, {overwrite: true, expires: new Date()});
ctx.cookies.set("jwt", null, {overwrite: true, expires: new Date()});
ctx.status = 401;
ctx.body = { success: false, message: '用户验证失败'};
} else {
await next();
}
}, err => {
ctx.cookies.set("XSRF-TOKEN", null, {overwrite: true, expires: new Date()});
ctx.cookies.set("jwt", null, {overwrite: true, expires: new Date()})
ctx.status = 401;
ctx.body = { success: false, message: '用户验证失败'};
});
} else {
await next();
}

最前面几行就是设置 header,可以看到非常简单。if 语句进来就是检验 XSRF 了,我的逻辑是只有用户请求 API 的时候并且 JWT 存在的时候才做检测。我们后面有对 JWT 的检测所以这里不需要做,如果 JWT 不存在或者方位的不是 API 直接 next 就好了。

这里调用了 jwt.verify 方法取出了 payload 的内容,这个方法是 jsonwebtoken 这个模块提供的。

检验 JWT

有一点需要注意的是,有些资源我们允许用户无需登录就进行访问。例如我们前面的登录注册界面,还要像静态资源等等。使用 koa-jwt 可以很方便的做这件事情。

1
2
3
4
5
6
7
app.use(handel.routes())
.use(handel.allowedMethods());
app.use(jwt({ secret: config.app.secretKey, algorithm: 'RS256' }).unless({ path: [/^\/css|js|img|fonts/] }));
app.use(api.routes())
.use(api.allowedMethods());

handel 这个路由是我的登录注册页面和接口这些,而 api 就是 Angular 中需要用到的一系列接口。中间我们加入了一句话。这里的 jwtkoa-jwt 模块。

这些顺序不能乱,koa 中间件的加载是按自顶向下的顺序的,所以我们 handel 这里并不要 jwt 检测,而后面则需要。而我们前面说的 xsrf 检测和 header 处理自然是要放在更前的位置了。这里的 path 你可以根据需要修改。具体的用法参考文档就好了。

整个流程就完了,这个就实现了我们前面探讨的成果。既保护了 token 的安全,又防止了 XSRF 攻击。当然了我不敢说绝对安全,根本就没有绝对安全的东西。但目前这样的鉴权系统应该算马马虎虎了。

如果你的登录注册也是放在前端(比如由 Angular 来做),那你也可以像我上面说的这么做,或者可以把 jwt 作为登录请求的 response 返回,不过我不觉得这是一种安全的方式,关于其他的存储方式参考我后面给的链接吧,我就不多介绍了。

总结

其实关于 JWT 存放到哪里一直由很多讨论,有人说存放到本地存储,有人说存 cookie。但我觉得上面我说的这种方式是挺稳妥的,如果你有什么意见和看法欢迎提出。参考资料也附出了比较热门的关于 jwt 存储位置的讨论文章,可以看下。


参考资料: