标签:
认证是任何 web 应用中不可或缺的一部分。在这个教程中,我们会讨论基于 token 的认证系统以及它和传统的登录系统的不同。这篇教程的末尾,你会看到一个使用 AngularJS 和 NodeJS 构建的完整的应用。
一、传统的认证系统
在开始说基于 token 的认证系统之前,我们先看一下传统的认证系统。
用户在登录域输入 用户名 和 密码 ,然后点击 登录 ;
在这之前一切都很美好。web 应用正常工作,并且它能够认证用户信息然后可以访问受限的后端服务器;然而当你在开发其他终端时发生了什么呢,比如在 Android 应用中?你还能使用当前的应用去认证移动端并且分发受限制的内容么?真相是,不可以。有两个主要的原因:
在移动应用上 session 和 cookie 行不通。你无法与移动终端共享服务器创建的 session 和 cookie。
在这个例子中,需要一个独立客户端服务。
在基于 token 的认证里,不再使用 cookie 和session。token 可被用于在每次向服务器请求时认证用户。我们使用基于 token 的认证来重新设计刚才的设想。
将会用到下面的控制流程:
用户在登录表单中输入 用户名 和 密码 ,然后点击 登录 ;
在这个例子中,我们没有返回的 session 或者 cookie,并且我们没有返回任何 HTML 内容。那意味着我们可以把这个架构应用于特定应用的所有客户端中。你可以看一下面的架构体系:
那么,这里的 JWT 是什么?
二、JWT
JWT 代表 JSON Web Token ,它是一种用于认证头部的 token 格式。这个 token 帮你实现了在两个系统之间以一种安全的方式传递信息。出于教学目的,我们暂且把 JWT 作为“不记名 token”。一个不记名 token 包含了三部分:header,payload,signature。
header 是 token 的一部分,用来存放 token 的类型和编码方式,通常是使用 base-64 编码。
你可以在下面看到 JWT 刚要和一个实例 token:
你不必关心如何实现不记名 token 生成器函数,因为它对于很多常用的语言已经有多个版本的实现。下面给出了一些:
三、一个实例
在讨论了关于基于 token 认证的一些基础知识后,我们接下来看一个实例。看一下下面的几点,然后我们会仔细的分析它:
多个终端,比如一个 web 应用,一个移动端等向 API 发送特定的请求。
基于 token 的认证在解决棘手的问题时有几个优势:
这些就是基于 token 的认证和通信中最明显的优势。基于 token 认证的理论和架构就说到这里。下面上实例。
四、应用实例
你会看到两个用于展示基于 token 认证的应用:
在后端项目中,包括服务接口,服务返回的 JSON 格式。服务层不会返回视图。在前端项目中,会使用 AngularJS 向后端服务发送请求。
在后端项目中,有三个主要文件:
package.json 用于管理依赖;
就是这样!这个项目非常简单,你不必深入研究就可以了解主要的概念。
{ "name": "angular-restful-auth", "version": "0.0.1", "dependencies": { "express": "4.x", "body-parser": "~1.0.0", "morgan": "latest", "mongoose": "3.8.8", "jsonwebtoken": "0.4.0" }, "engines": { "node": ">=0.10.0" } }
package.json包含了这个项目的依赖:express 用于 MVC,body-parser 用于在 NodeJS 中模拟 post 请求操作,morgan 用于请求登录,mongoose 用于为我们的 ORM 框架连接 MongoDB,最后 jsonwebtoken 用于使用我们的 User 模型创建 JWT 。如果这个项目使用版本号 >= 0.10.0 的 NodeJS 创建,那么还有一个叫做 engines 的属性。这对那些像 HeroKu 的 PaaS 服务很有用。我们也会在另外一节中包含那个话题。
var mongoose = require(‘mongoose‘); var Schema = mongoose.Scema; var UserSchema = new Schema({ email: String, password: String, token: String }); module.exports = mongoose.model(‘User‘, UserSchema);
上面提到我们可以通过使用用户的 payload 模型生成一个 token。这个模型帮助我们处理用户在 MongoDB 上的请求。在User.js,user-schema 被定义并且 User 模型通过使用 mogoose 模型被创建。这个模型提供了数据库操作。
我们的依赖和 user 模型被定义好,现在我们把那些构想成一个服务用于处理特定的请求。
// Required Modules var express = require("express"); var morgan = require("morgan"); var bodyParser = require("body-parser"); var jwt = require("jsonwebtoken"); var mongoose = require("mongoose"); var app = express();
在 NodeJS 中,你可以使用 require 包含一个模块到你的项目中。第一步,我们需要把必要的模块引入到项目中:
var port = process.env.PORT || 3001;
var User = require(‘./models/User‘);
// Connect to DB
mongoose.connect(process.env.MONGO_URL);
服务层通过一个指定的端口提供服务。如果没有在环境变量中指定端口,你可以使用那个,或者我们定义的 3001 端口。然后,User 模型被包含,并且数据库连接被建立用来处理一些用户操作。不要忘记定义一个 MONGO_URL 环境变量,用于数据库连接 URL。
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(morgan("dev"));
app.use(function(req, res, next) {
res.setHeader(‘Access-Control-Allow-Origin‘, ‘*‘);
res.setHeader(‘Access-Control-Allow-Methods‘, ‘GET, POST‘);
res.setHeader(‘Access-Control-Allow-Headers‘, ‘X-Requested-With,content-type, Authorization‘);
next();
});
上一节中,我们已经做了一些配置用于在 NodeJS 中使用 Express 模拟一个 HTTP 请求。我们允许来自不同域名的请求,目的是建立一个独立的客户端系统。如果你没这么做,可能会触发浏览器的 CORS(跨域请求共享)错误。
Access-Control-Allow-Origin 允许所有的域名。
app.post(‘/authenticate‘, function(req, res) {
User.findOne({email: req.body.email, password: req.body.password}, function(err, user) {
if (err) {
res.json({
type: false,
data: "Error occured: " + err
});
} else {
if (user) {
res.json({
type: true,
data: user,
token: user.token
});
} else {
res.json({
type: false,
data: "Incorrect email/password"
});
}
}
});
});
我们已经引入了所需的全部模块并且定义了配置文件,所以是时候来定义请求处理函数了。在上面的代码中,当你提供了用户名和密码向 /authenticate 发送一个 POST 请求时,你将会得到一个 JWT。首先,通过用户名和密码查询数据库。如果用户存在,用户数据将会和它的 token 一起返回。但是,如果没有用户名或者密码不正确,要怎么处理呢?
app.post(‘/signin‘, function(req, res) {
User.findOne({email: req.body.email, password: req.body.password}, function(err, user) {
if (err) {
res.json({
type: false,
data: "Error occured: " + err
});
} else {
if (user) {
res.json({
type: false,
data: "User already exists!"
});
} else {
var userModel = new User();
userModel.email = req.body.email;
userModel.password = req.body.password;
userModel.save(function(err, user) {
user.token = jwt.sign(user, process.env.JWT_SECRET);
user.save(function(err, user1) {
res.json({
type: true,
data: user1,
token: user1.token
});
});
})
}
}
});
});
当你使用用户名和密码向 /signin 发送 POST 请求时,一个新的用户会通过所请求的用户信息被创建。在 第 19 行,你可以看到一个新的 JSON 通过 jsonwebtoken 模块生成,然后赋值给 jwt 变量。认证部分已经完成。我们访问一个受限的后端服务器会怎么样呢?我们又要如何访问那个后端服务器呢?
app.get(‘/me‘, ensureAuthorized, function(req, res) {
User.findOne({token: req.token}, function(err, user) {
if (err) {
res.json({
type: false,
data: "Error occured: " + err
});
} else {
res.json({
type: true,
data: user
});
}
});
});
当你向 /me 发送 GET 请求时,你将会得到当前用户的信息,但是为了继续请求后端服务器, ensureAuthorized 函数将会执行。
function ensureAuthorized(req, res, next) {
var bearerToken;
var bearerHeader = req.headers["authorization"];
if (typeof bearerHeader !== ‘undefined‘) {
var bearer = bearerHeader.split(" ");
bearerToken = bearer[1];
req.token = bearerToken;
next();
} else {
res.send(403);
}
}
在这个函数中,请求头部被拦截并且 authorization 头部被提取。如果头部中存在一个不记名 token,通过调用 next()函数,请求继续。如果 token 不存在,你会得到一个 403(Forbidden)返回。我们回到 /me 事件处理函数,并且使用req.token 获取这个 token 对应的用户数据。当你创建一个新的用户,会生成一个 token 并且存储到数据库的用户模型中。那些 token 都是唯一的。
这个简单的例子中已经有三个事件处理函数。然后,你将看到;
process.on(‘uncaughtException‘, function(err) {
console.log(err);
});
当程序出错时 NodeJS 应用可能会崩溃。添加上面的代码可以拯救它并且一个错误日志会打到控制台上。最终,我们可以使用下面的代码片段启动服务。
// Start Server
app.listen(port, function () {
console.log( "Express server listening on port " + port);
});
总结一下:
引入模块
我们已经完成了后端服务。到现在,应用已经可以被多个终端使用,你可以部署这个简单的应用到你的服务器上,或者部署在 Heroku。有一个叫做 Procfile 的文件在项目的根目录下。现在把服务部署到 Heroku。
你可以在这个 GitHub 库下载项目的后端代码。
我不会教你如何在 Heroku 如何创建一个应用;如果你还没有做过这个,你可以查阅这篇文章。创建完 Heroku 应用,你可以使用下面的命令为你的项目添加一个地址:
git remote add heroku <your_heroku_git_url>
现在,你已经克隆了这个项目并且添加了地址。在 git add 和 git commit 后,你可以使用 git push heroku master 命令将你的代码推到 Heroku。当你成功将项目推送到仓库,Heroku 会自动执行 npm install 命令将依赖文件下载到 Heroku 的 temp 文件夹。然后,它会启动你的应用,因此你就可以使用 HTTP 协议访问这个服务。
在前端项目中,将会使用 AngularJS。在这里,我只会提到前端项目中的主要内容,因为 AngularJS 的相关知识不会包括在这个教程里。
你可以在这个 GitHub 库下载源码。在这个项目中,你会看下下面的文件结构:
ngStorage.js 是一个用于操作本地存储的 AngularJS 类库。此外,有一个全局的 layout 文件 index.html 并且在 partials 文件夹里还有一些用于扩展全局 layout 的部分。 controllers.js 用于在前端定义我们 controller 的 action。 services.js 用于向我们在上一个项目中提到的服务发送请求。还有一个 app.js 文件,它里面有配置文件和模块引入。最后,client.js 用于服务静态 HTML 文件(或者仅仅 index.html,在这里例子中);当你没有使用 Apache 或者任何其他的 web 服务器时,它可以为静态的 HTML 文件提供服务。
...
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.20/angular.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.20/angular-route.min.js"></script>
<script src="/lib/ngStorage.js"></script>
<script src="/lib/loading-bar.js"></script>
<script src="/scripts/app.js"></script>
<script src="/scripts/controllers.js"></script>
<script src="/scripts/services.js"></script>
</body>
在全局的 layout 文件中,AngularJS 所需的全部 JavaScript 文件都被包含,包括自定义的控制器,服务和应用文件。
‘use strict‘;
/* Controllers */
angular.module(‘angularRestfulAuth‘)
.controller(‘HomeCtrl‘, [‘$rootScope‘, ‘$scope‘, ‘$location‘, ‘$localStorage‘, ‘Main‘, function($rootScope, $scope, $location, $localStorage, Main) {
$scope.signin = function() {
var formData = {
email: $scope.email,
password: $scope.password
}
Main.signin(formData, function(res) {
if (res.type == false) {
alert(res.data)
} else {
$localStorage.token = res.data.token;
window.location = "/";
}
}, function() {
$rootScope.error = ‘Failed to signin‘;
})
};
$scope.signup = function() {
var formData = {
email: $scope.email,
password: $scope.password
}
Main.save(formData, function(res) {
if (res.type == false) {
alert(res.data)
} else {
$localStorage.token = res.data.token;
window.location = "/"
}
}, function() {
$rootScope.error = ‘Failed to signup‘;
})
};
$scope.me = function() {
Main.me(function(res) {
$scope.myDetails = res;
}, function() {
$rootScope.error = ‘Failed to fetch details‘;
})
};
$scope.logout = function() {
Main.logout(function()