上一章我们通过引入mongodb实现了基本的用户管理,已经实现了异常处理的基本框架。今天我们会开始实现小红书后台的鉴权功能。
鉴权的主要目的就是为了:
让授权的用户访问相应的api资源,而禁止没有授权的用户去访问不属于它的资源。
现在比较流行的方案就是基于Token的鉴权方式, 请看知乎上的描述:
https://zhuanlan.zhihu.com/p/19920223?columnSlug=FrontendMagazine
基本的流程如下:
- 客户端用户通过提供用户名和密码进行登录
- 服务器端接收到用户名密码,生成一个唯一的Access Token令牌,代表这个用户的访问权限,并在本地存储起来(我们这里会用redis来存储到内存中,因为redis给我们提供了很好的令牌过期管理功能)
- 服务器将该令牌返回给客户端
- 客户端接收到令牌,并将其存储起来
- 客户端通过在http请求头的Authentication字段中提供令牌来访问服务器的资源
- 服务器收到请求后解析出http请求头的令牌,跟之前缓存起来的令牌进行对比,一致则允许访问相应的资源,否则返回授权失败
下面我们就开始进行实现。
1. REDIS客户端实现
首先,安装好Redis, 安装方法略…
其次,如果大家不是很熟悉Redis的话,请到官网进行学习:
https://github.com/NodeRedis/node_redis 上面有很多很好的实例,包含如何对redis进行异步调用。
跟上一章创建mongodb的客户端实例一样,我们这里需要创建Redis的实例来进行对Redis的访问。在libs目录下创建redisdb.js文件,实现代码如下:
const bluebird = require('bluebird');
const redis = bluebird.promisifyAll(require('redis'));
const log = require('./logger');
log.info('Initialize Redis Database ...');
const redisClient = redis.createClient('6379', '127.0.0.1');
module.exports = redisClient;
主旨就是:
- 通过bluebird来promise化redis的api调用,这样我们就能通过入setAsync的方式和结合es7的await/async来更简单的对redis进行操作
- 连上Redis服务器
- 将已经连接上Redis服务器的Redis客户端的实例export出去给其他模块使用
2. uuid和访问令牌
我们的访问令牌必须是唯一的,可以自己实现也可以使用第三方库。我们这里会用到uuid这个库:
https://github.com/kelektiv/node-uuid
2.1 登录时生成令牌并保存到redis
我们需要改造上一章实现的/auth/login路由:
- 首先,依然是检查用户名密码是否正确,不正确的话直接异常返回,否则往下走
- 然后,通过uuid库的接口创建一个唯一的uuid来代表我们的访问令牌
- 将该令牌保存到redis数据库中:其中令牌作为键,用户信息作为值。这样我们今后在需要的时候就能通过令牌来找到对应的用户信息。
- 设置令牌在redis中的过期时间。设置后,redis会自动帮我们做令牌的过期管理,一旦过期了,令牌就会被自动从redis中删除掉。比如我们这里设置令牌一小时后失效。
- 将访问令牌返回给客户端
代码实现如下:
router.post('/login', async (req, res, next) => {
try {
const {name, password} = req.body;
const user = await User.findOne({name});
if (!user.authenticate(password)) {
throw new ClientError.InvalidLoginError();
} else {
const accessToken = generateAccessToken();
await redis.setAsync(accessToken, JSON.stringify(user));
await redis.expireAsync(accessToken, 3600);
res.send({"access_token": accessToken});
}
} catch (e) {
log.error('Exception:',e);
next(e);
}
});
2.2. 登出时删除redis中用户对应的令牌
如前面所述,用户登录之后,访问需要权限的资源的话,需要在http请求头的Authentication中设置上刚刚登录返回来的访问令牌。
登出时就需要携带这个令牌,所以我们在服务器端可以通过解析请求头来获得。
在获得访问令牌之后,我们这里就简单的将其从redis服务器中删除掉就好了。
router.post('/logout', async (req, res, next) => {
try {
const accessToken = req.headers.authorization;
await redis.delAsync(accessToken);
res.status(204).end();
} catch (e) {
next(e);
}
});
3. 鉴权
如上所述,鉴权的目的就是让有权限的用户访问对应的资源,没有权限的用户不能访问超出其权限的资源。
我们服务器端各个api就是用户需要访问的资源,所以鉴权必须要在路由到各个api之前做。所以最好的方法就是在server.js文件中,在通过bodyparser进行请求数据解析之后,而在分派路由之前,来提供一个中间件来实现:
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
bodyParserXML(bodyParser);
app.use(bodyParser.xml());
// 在这里实现鉴权中间件
app.use(async (req, res, next) => {
...
});
fs.readdir(`${__dirname}/routes/`, (err,files) => {
for(const file of files) {
const path = '/v1/' + file.split(".")[0];
log.info('Attached router:',path);
app.use(path,require(`${__dirname}/routes/${file}`))
}
})
由于我们当前只有一个Admin用户,也就是说我们只有两种用户:
- 提供了访问令牌的用户
- 没有提供访问令牌的用户
首先,我们需要获得用户传过来的http请求头中的访问令牌:
const accessToken = req.headers.authorization;
对于提供了访问令牌的用户,我们需要:
- 检查其访问令牌的有效性
- 更新访问令牌的过期时间,这样的话就不至于让我们的客户端在一直访问的过程中,到了令牌过期的时间后就需要重新登录。
- 鉴权通过后通过调用next来进入到对应的路由中间件进行请求处理
- 鉴权没有通过的话,抛出异常,然后通过next(e)来让系统默认错误处理中间件来进行处理
实现如下:
// APIS need authentication
if (accessToken) {
const user = await redis.getAsync(accessToken);
if (!user) {
throw new ClientError.InvalidTokenError();
} else {
req.user = JSON.parse(user);
await redis.expireAsync(accessToken, 3600);
next();
}
} else {
throw new ClientError.InvalidTokenError();
}
对于没有提供访问令牌的请求:
- 检查访问的是哪些API
- 对于不需要授权的api,调用next,让对应的路由中间件进行请求处理
代码实现如下:
// APIS need no authentication
if (req.path === '/'
|| req.path === '/v1/auth/login') {
log.debug('no auth required');
next();
return;
}
完整的鉴权中间件代码如下:
const authMidware = async (req, res, next) => {
try {
const accessToken = req.headers.authorization;
log.debug('accessToken:', accessToken)
// APIS need no authentication
if (req.path === '/'
|| req.path === '/v1/auth/login') {
log.debug('no auth required');
next();
return;
}
// APIS need authentication
if (accessToken) {
const user = await redis.getAsync(accessToken);
if (!user) {
throw new ClientError.InvalidTokenError();
} else {
req.user = JSON.parse(user);
await redis.expireAsync(accessToken, 3600);
next();
}
} else {
throw new ClientError.InvalidTokenError();
}
} catch (e) {
log.debug('error while auth', e);
next(e);
}
};
4. 结语
重构后和完整的代码请从github中获取。
- git clone https://github.com/zhubaitian/XiaoHuangShuServer.git
- cd XiaoHuangShuServer/
- git checkout CH05
- npm install
- gulp dev
这一系列文章其实我写了有段时间了,后来忙起来忘了发布了😓。