由来
做了这么长时间的web开发,从JAVA EE中的jsf,spring,hibernate框架,到spring web MVC,到用php框架thinkPHP,到现在的nodejs,我自己的看法是越来越喜欢干净整洁的web层,之前用jsf开发做view层的时候,用的primefaces做的界面显示,虽然primefaces的确提供了很大的便利,可以让开发人员专注于业务逻辑开发,这样其实就省去了前端开发的工作。而后来发现有些客户需要的展现形式很难实现,或者通过拼凑的方法实现的结果效率不高。使用不灵活,后来自己渐渐的转向了做前端工程师。spring WEB MVC可以做到干净整洁的web层,可以做到web层分离,通过ajax和服务端通信。现在在学习AngularJS框架,后台数据服务端打算用REST风格的接口来做,这个在前后台交互上就要考虑数据通信的安全问题,关于这个在关于SESSION的理解一文中其实有提到的。
转载请注明出处:http://www.haomou.net/2014/08/13/2014_web_token/
来龙去脉
诸如Ember,Angular,Backbone之类的前端框架类库正随着更加精细的Web应用而日益壮大。正因如此,服务器端的组建也正正在从传统的任务中解脱,转而变的更像API。API使得传统的前端和后端的概念解耦。开发者可以脱离前端,独立的开发后端,在测试上获得更大的便利。这种途径也使得一个移动应用和网页应用可以使用相同的后端。
当使用一个API时,其中一个挑战就是认证(authentication)。在传统的web应用中,服务端成功的返回一个响应(response)依赖于两件事。一是,他通过一种存储机制保存了会话信息(Session)。每一个会话都有它独特的信息(id),常常是一个长的,随机化的字符串,它被用来让未来的请求(Request)检索信息。其次,包含在响应头(Header)里面的信息使客户端保存了一个Cookie。服务器自动的在每个子请求里面加上了会话ID,这使得服务器可以通过检索Session中的信息来辨别用户。这就是传统的web应用逃避HTTP面向无连接的方法(This is how traditional web applications get around the fact that HTTP is stateless)。
API应该被设计成无状态的(Stateless)。这意味着没有登陆,注销的方法,也没有sessions,API的设计者同样也不能依赖Cookie,因为不能保证这些request是由浏览器所发出的。自然,我们需要一个新的机制。这篇文章关注于JSON Web Tokens,简写为JWTs,一个可能的解决这个问题的机制。这篇文章利用Node的Express框架作为后端,以及Backbone作为前端。
常用方法
第一个是使用在HTTP规范中所制定的Basic Auth, 它需要在在响应中设定一个验证身份的Header。客户端必须在每个子响应是附加它们的凭证(credenbtial),包括它的密码。如果这些凭证通过了,那么用户的信息就会被传递到服务端应用。
第二个方面有点类似,但是使用应用自己的验证机制。通常包括将发送的凭证与存储的凭证进行检查。和Basic Auth相比,这种需要在每次请求(call)中发送凭证。
第三种是OAuth(或者OAuth2)。为第三方的认证所设计,但是更难配置。至少在服务器端更难。
在使用中,并不会每次都让用户提交用户名和密码,通常的情况是客户端通过一些可靠信息和服务器交换取token,这个token作为客服端再次请求的权限钥匙。Token通常比密码更加长而且复杂。比如说,JWTs通常会长达150个字符。一旦获得了token,在每次调用API的时候都要附加上它。然后,这仍然比直接发送账户和密码更加安全,哪怕是HTTPS。
把token想象成一个安全的护照。你在一个安全的前台验证你的身份(通过你的用户名和密码),如果你成功验证了自己,你就可以取得这个。当你走进大楼的时候(试图从调用API获取资源),你会被要求验证你的护照,而不是在前台重新验证。
JWTs
JWTs是一份草案,尽管在本质上它是一个老生常谈的一种更加具体的认证授权的机制。一个JWT被周期(period)分成了三个部分。JWT是URL-safe的,意味着可以用来查询字符参数。(译者注:也就是可以脱离URL,不用考虑URL的信息)。关于Json Web Token,参考http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html
JWT的第一部分是对一个简单js对象的编码后的字符串,这个js对象是用来描述这个token类型以及使用的hash算法。下面的例子展示的是一个使用了HMAC SHA-256算法的JWT token。
1 | { |
在加密之后,这个对象变成了一个字符串:
eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9
JWT的第二部分是token的核心,这部分同样是对一个js对象的编码,包含了一些摘要信息。有一些是必须的,有一些是选择性的。实例如下:
1 | { |
这个结构被称为JWT Claims Set。这个iss是issuer的简写,表明请求的实体,可以是发出请求的用户的信息。exp是expires的简写,是用来指定token的生命周期。(相关参数参看:the document)加密编码之后如下:
1 | eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ |
JWT的第三个部分,是JWT根据第一部分和第二部分的签名(Signature)。像这个样子:
1 | dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk |
最后将上面的合并起来,JWT token如下:
1 | eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk |
处理Tokens
我们将用JWT simple模块去处理token,它将使我们从钻研如何加密解密中解脱出来。如果你有兴趣,可以阅读这篇说明,或者读这个仓库的源码。
首先我们将使用下面的命令安装这个库。记住你可以在命令中加入—save,让其自动的让其加入到你的package.json文件里面。
npm install jwt-simple
在你应用的初始环节,加入以下代码。这个代码引入了Express和JWT simple,而且创建了一个新的Express应用。最后一行设定了app的一个名为jwtTokenSecret的变量,其值为‘YOUR_SECRET_STRING’(记得把它换成别的)。
var express = require('express');
var jwt = require('jwt-simple'); var app = express(); app.set('jwtTokenSecret', 'YOUR_SECRET_STRING');
获取token
我们需要做的第一件事就是让客户端通过他们的账号密码交换token。这里有2种可能的方法在RESTful API里面。第一种是使用POST请求来通过验证,使服务端发送带有token的响应。除此之外,你可以使用GET请求,这需要他们使用参数提供凭证(指URL),或者更好的使用请求头。
这篇文章的目的是为了解释token验证的方法而不是基本的用户名/密码验证机制。所以我们假设我们已经通过请求得到了用户名和密码:
1 | User.findOne({ username: username }, function(err, user) { if (err) { // user not found return res.send(401); } if (!user) { // incorrect username return res.send(401); } if (!user.validPassword(password)) { // incorrect password return res.send(401); } // User has authenticated OK res.send(200); }); |
如果用户成功验证账号和密码,然后我们生成一个token,返回给用户。
1 | var expires = moment().add('days', 7).valueOf(); var token = jwt.encode({ iss: user.id, exp: expires }, app.get('jwtTokenSecret')); res.json({ token : token, expires: expires, user: user.toJSON() }); |
注意到jwt.encode()函数有2个参数。第一个就是一个需要加密的对象,第二个是一个加密的密钥。这个token是由我们之前提到的iss和exp组成的。注意到Moment.js被用来设置token将在7天之后失效。而res.json()方法用来传递这个JSON对象给客户端。
验证Token
客户端获取到token后,应该在每次向服务器请求数据时附带这个token,然后服务端验证token。
为了验证JWT,我们需要写出一些可以完成这些功能的中间件(Middleware):
- 检查附上的token
- 试图解密
- 验证token的可用性
- 如果token是合法的,检索里面用户的信息,以及附加到请求的对象上
我们来写一个中间件的框架1
// @file jwtauth.js var UserModel = require('../models/user'); var jwt = require('jwt-simple'); module.exports = function(req, res, next) { // code goes here };
为了获得最大的可扩展性,我们允许客户端使用一下3个方法附加我们的token:作为请求链接(query)的参数,作为主体的参数(body),和作为请求头(Header)的参数。对于最后一个,我们将使用Header x-access-token。
下面是我们的允许在中间件的代码,试图去检索token:
1 | var token = (req.body && req.body.access_token) || (req.query && req.query.access_token) || req.headers['x-access-token']; |
注意到他为了访问req.body,我们需要首先使用express.bodyParser()中间件(译者注,这个是Express 3.x的中间件)。
下一步,我们讲解析JWT:
1 | if (token) { try { var decoded = jwt.decode(token, app.get('jwtTokenSecret')); // handle token here } catch (err) { return next(); } } else { next(); } |
如果解析的过程失败,那么JWT Simple组件将会抛出一段异常。如果异常发生了,或者没有token,我们将会调用next()来继续处理请求。这代表喆我们无法确定用户。如果一个合格的token合法并且被解码,我们应该得到2个属性,iss包含着用户ID以及exp包含token过期的时间戳。我们将首先处理后者,如果它过期了,我们就拒绝它:
1 | if (decoded.exp <= Date.now()) { res.end('Access token has expired', 400); } |
如果token依旧合法,我们可以从中检索出用户信息,并且附加到请求对象里面去:
1 | User.findOne({ _id: decoded.iss }, function(err, user) { req.user = user; }); |
最后,将这个中间件附加到路由里面:
1 | var jwtauth = require('./jwtauth.js'); app.get('/something', [express.bodyParser(), jwtauth], function(req, res){ // do something }); |
或者匹配一些路由
1 | app.all('/api/*', [express.bodyParser(), jwtauth]); |
客户端请求
我们提供了一个简单的get端去获得一个远端的token。这非常直接了,所以我们不用纠结细节,就是发起一个请求,传递用户名和密码,如果请求成功了,我们就会得到一个包含着token的响应。
我们现在研究的是后续的请求。一个方法是通过JQuery的ajaxSetup()方法。这可以直接用来做Ajax请求,或者通过前端框架使用包装过的Ajax方法。比如,假设我们将我们的请求使用window.localStorage.setItem(‘token’, ‘the-long-access-token’);放在本地存储(Local Storage)里面,我们可以通过这种方法将token附加到请求头里面:
1 | var token = window.localStorage.getItem('token'); if (token) { $.ajaxSetup({ headers: { 'x-access-token': token } }); } |
很简单,但是这会劫持所有Ajax请求,如果这里有一个token在本地存储里面。它将会附加到一个名为x-access-token的Header里面。
bear token
关于bear token,参看 RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage, 目前国内各大网站都是用不同的token,也没说必须使用bear token,只有twitter明确说明的是使用bear token。
OAuth 2.0 (RFC 6749) 定义了 Client 如何取得 Access Token 的方法。Client 可以用 Access Token 以 Resource Owner 的名义来向 Resource Server 取得 Protected Resource ,例如我 (Resource Owner) 授权一個手机 App (Client) 以我 (Resource Owner) 的名义去 Facebook (Resource Server) 取得我的朋友名单 (Protected Resource)。OAuth 2.0 定义Access Token 是 Resource Server 用来认证的唯一方式,有了这个, Resource Server 就不需要再提供其他认证方式,例如账号密码。
然而在 RFC 6749 里面只定义抽象的概念,细节如 Access Token 格式、怎么传到 Resource Server ,以及 Access Token 无效时, Resource Server 怎么处理,都没有定义。所以在 RFC 6750 另外定义了 Bearer Token 的用法。Bearer Token 是一种 Access Token ,由 Authorization Server 在 Resource Owner 的允许下核发给 Client ,Resource Server 只要认在这个 Token 就可以认定 Client 已经获取 Resource Owner 的许可,不需要用密码学的方式来验证这个 Token 的真伪。关于Token 被偷走的安全性问题,另一篇再说。
Bearer Token 的格式
1 | Bearer XXXXXXXX |
其中 XXXXXXXX 的格式为 b64token ,ABNF 的定義:
1 | b64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"=" |
写成 Regular Expression 即是:
1 | /[A-Za-z0-9\-\._~\+\/]+=*/ |
关于Bear Token还是打算另起一篇,详细说明:Bearer Token
express-jwt实例
下面给一个具体的实例,这个例子的客户端是web app,使用AngularJS框架。服务端使用NodeJS做的RESTful API接口,客户端直接调用接口数据,其中使用了token认证机制。
当用户把他的授权信息发过来的时候, Node.js 服务检查是否正确,然后返回一个基于用户信息的唯一 token 。 AngularJS 应用把 token 保存在用户的 SessionStorage ,之后的在发送请求的时候,在请求头里面加上包含这个 token 的 Authorization。如果 endpoint 需要确认用户授权,服务端检查验证这个 token,然后如果成功了就返回数据,如果失败了返回 401 或者其它的异常。
用到的技术:
- AngularJS
- NodeJS ( express.js, express-jwt 和 moongoose)
- MongoDB
- Redis (备用,用于记录用户退出登录时候还没有超时的token)
客户端 : AngularJS 部分
首先,我们来创建我们的 AdminUserCtrl controller 和处理 login/logout 动作。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
26
27appControllers.controller('AdminUserCtrl', ['$scope', '$location', '$window', 'UserService', 'AuthenticationService',
function AdminUserCtrl($scope, $location, $window, UserService, AuthenticationService) {
//Admin User Controller (login, logout)
$scope.logIn = function logIn(username, password) {
if (username !== undefined && password !== undefined) {
UserService.logIn(username, password).success(function(data) {
AuthenticationService.isLogged = true;
$window.sessionStorage.token = data.token;
$location.path("/admin");
}).error(function(status, data) {
console.log(status);
console.log(data);
});
}
}
$scope.logout = function logout() {
if (AuthenticationService.isLogged) {
AuthenticationService.isLogged = false;
delete $window.sessionStorage.token;
$location.path("/");
}
}
}
]);
这个 controller 用了两个 service: UserService 和 AuthenticationService。第一个处理调用 REST api 用证书。后面一个处理用户的认证。它只有一个布尔值,用来表示用户是否被授权。
1 | appServices.factory('AuthenticationService', function() { |
好了,我们需要做张登陆页面:
1 | <form class="form-horizontal" role="form"> <div class="form-group"> <label for="inputUsername" class="col-sm-4 control-label">Username</label> <div class="col-sm-4"> <input type="text" class="form-control" id="inputUsername" placeholder="Username" ng-model="login.email"> </div> </div> <div class="form-group"> <label for="inputPassword" class="col-sm-4 control-label">Password</label> <div class="col-sm-4"> <input type="password" class="form-control" id="inputPassword" placeholder="Password" ng-model="login.password"> </div> </div> <div class="form-group"> <div class="col-sm-offset-4 col-sm-10"> <button type="submit" class="btn btn-default" ng-click="logIn(login.email, login.password)">Log In</button> </div> </div> </form> |
当用户发送他的信息过来,我们的 controller 把内容发送到 Node.js 服务器,如果信息可用,我们把 AuthenticationService里面的 isLogged 设为 true。我们把从服务端发过来的 token 存起来,以便下次请求的时候使用。等讲到 Node.js 的时候我们会看看怎么处理。
好了,我们要往每个请求里面追加一个特殊的头信息了:[Authorization: Bearer ] 。为了实现这个需求,我们建立一个服务,叫 TokenInterceptor。
1 | appServices.factory('TokenInterceptor', function ($q, $window, AuthenticationService) { |
然后我们把这个interceptor 追加到 $httpProvider :
1 | app.config(function ($httpProvider) { |
然后,我们要开始配置路由了,让 AngularJS 知道哪些需要授权,在这里,我们需要检查用户是否已经被授权,也就是查看 AuthenticationService 的 isLogged 值。
1 | app.config(['$locationProvider', '$routeProvider', |
服务端: Node.js + MongoDB 部分
为了在我们的 RESTful api 处理授权信息,我们要用到 express-jwt (JSON Web Token) 来生成一个唯一 Token,基于用户的信息。以及验证 Token。
首先,我们在 MongoDB 里面创建一个用户的 Schema。我们还要创建调用一个中间件,在创建和保存用户信息到数据库之前,用于加密密码。还有我们需要一个方法来解密密码,当收到用户请求的时候,检查是否在数据库里面有匹配的。
1 | var Schema = mongoose.Schema; |
然后我们开始写授权用户和创建 Token 的方法:
1 | exports.login = function(req, res) { |
最后,我们需要把 jwt 中间件加到所有的,访问时需要授权的路由上面:
1 | /* |
上面这个实例就采用了token的验证方式构建了api接口,但是有两个问题需要解决:
- 用户退出登录,但是token并没有失效,因为服务端没有删除这个token
- token失效了,怎么办,如果还是让用于登录重新获取token,会体验不好。应该有token刷新机制。
使用Redis解决问题1
解决方法是:当用户点了 logout 按钮的时候,Token 只会保存一段时间,就是你用jsonwebtoken 登陆之后,token 有效的这段时间,我们将这个token存放在Redis中,生存时间也是jwt获取这个token的时间。这个时间到期后,token 会被 redis 自动删掉。最后,我们创建一个 nodejs 的中间件,检查所有受限 endopoint 用的 token 是否存在 Redis 数据库中。NodeJS 配置 Reids
1
2
3
4
5
6
7
8
9
10
11
12
13var redis = require('redis');
var redisClient = redis.createClient(6379);
redisClient.on('error', function (err) {
console.log('Error ' + err);
});
redisClient.on('connect', function () {
console.log('Redis is ready');
});
exports.redis = redis;
exports.redisClient = redisClient;
然后,我们来创建一个方法,用来检查提供的 token 是不是被
Token 管理和中间件
为了在 Redis 中保存 Token,我们要创建一个方法来拿到请求中的 Header 的 Token 参数,然后把它作为 Redis 的 key 保存起来。值是什么我们不管它。
1 | var redisClient = require('./redis_database').redisClient; |
然后,再创建一个中间件来验证一下 token,当用户发起请求的时候:
1 | // Middleware for token verification |
verifyToken 这个方法,是一个中间件,用来拿到请求头中的 token,然后在 Redis 里面查找它。如果 token 被发现了,我们就发 HTTP 401.否则我们就继续工作流,让请求访问 API。
我们要在用户点 logout 的时候,执行 expireToken 方法:
1 | exports.logout = function(req, res) { |
最后我们更新路由,用上新的中间件:
1 | //Login |
好了,现在我们每次发送请求的时候,我们都去解析 token, 然后看看是不是有效的。
这里有整个项目的源代码
refresh token解决问题2
1 | appServices.factory('TokenInterceptor', function ($q, $window, $location, AuthenticationService) { |
上面代码中的最后一部分responseError其实就是授权失败的部分,这里面的处理方法是返回到登录授权页面。
这里面考虑的方法是,如果是token超时,使用refresh_token来换取新的token。这个refresh_token,是一开始核发的时候一块发布给客户端的,这里就不能使用上面这个bear token了,要自己处理一下token的问题。
思路1:在user中记录token超时时间,计算一下剩余时间,如果剩余时间比如说小于1分钟,开始核发新的token,客户端自动使用新的token,等退出时,就不核发新的token。
谢谢!
转载请注明出处:http://www.haomou.net/2014/08/13/2014_web_token/
有问题请留言。T_T I am teacher Chale. T_T