前言

用户鉴权,是一个系统项目中的重中之重。几乎所有的需求,都是围绕用户体系去展开设计的。放眼市面上诸多项目,哪一个不是建立在用户体系基础上的,如博客、电商、工具、管理系统、音乐、游戏等等领域。所以我们将用户鉴权这块内容放在了第一个要实现的接口。

知识点

  • egg-jwt 插件的使用
  • egg 中间件编写
  • token 鉴权

    用户鉴权是什么

    官方定义:用户鉴权,一种用于在通信网络中对试图访问来自服务提供商的服务的用户进行鉴权的方法。用于用户登陆到DSMP或使用数据业务时,业务网关或Portal发送此消息到DSMP,对该用户使用数据业务的合法性和有效性(状态是否为激活)进行检查。

鉴权的机制,分为四种:

  • HTTP Basic Authentication
  • session-cookie
  • Token 令牌
  • OAuth(开放授权)

本教程采用的鉴权模式是 token 令牌模式,出于多端考虑,token 可以运用在如网页、客户端、小程序、浏览器插件等等领域。如果选用 cookie 的形式鉴权,在客户端和小程序就无法使用这套接口,因为它们没有域的概念,而 cookie 是需要存在某个域下。

注册接口实现

整个注册的流程大致如下:
image.png
注意将 config.default.js 的数据库配置项中的数据库名称修改一下,因为我们前面新建了一个数据库:

  1. config.mysql = {
  2. // 单数据库信息配置
  3. client: {
  4. // host
  5. host: 'localhost',
  6. // 端口号
  7. port: '3306',
  8. // 用户名
  9. user: 'root',
  10. // 密码
  11. password: '你的密码',
  12. // 数据库名
  13. database: 'account-book',
  14. },
  15. // 是否加载到 app 上,默认开启
  16. app: true,
  17. // 是否加载到 agent 上,默认关闭
  18. agent: false,
  19. };

用户在网页端注册的时候会上报两个参数,「用户名」和「密码」,此时我们便需要在服务端代码中拿到这俩参数。
在 controller 目录下新建 user.js 用于编写用户相关的代码,代码如下:

  1. 'use strict';
  2. const Controller = require('egg').Controller;
  3. class UserController extends Controller {
  4. async register() {
  5. const { ctx } = this;
  6. const { username, password } = ctx.request.body;
  7. // 1.判空
  8. if (!username || !password) {
  9. ctx.body = {
  10. code: 500,
  11. msg: '账号密码不能为空',
  12. data: null,
  13. };
  14. return;
  15. }
  16. const userInfo = await ctx.service.user.getUserByName(username);
  17. // 2.验证数据库内是否已经有该账户名
  18. if (userInfo && userInfo.id) {
  19. ctx.body = {
  20. code: 500,
  21. msg: '账户名已被注册,请重新输入',
  22. data: null,
  23. };
  24. return;
  25. }
  26. // 默认头像,放在 user.js 的最外,部避免重复声明。
  27. const defaultAvatar = 'http://s.yezgea02.com/1615973940679/WeChat77d6d2ac093e247c361f0b8a7aeb6c2a.png';
  28. // 3.调用 service 方法,将数据存入数据库
  29. const result = await ctx.service.user.register({
  30. username,
  31. password,
  32. signature: '世界和平。',
  33. avatar: defaultAvatar,
  34. });
  35. if (result) {
  36. ctx.body = {
  37. code: 200,
  38. msg: '注册成功',
  39. data: null,
  40. };
  41. } else {
  42. ctx.body = {
  43. code: 500,
  44. msg: '注册失败',
  45. data: null,
  46. };
  47. }
  48. }
  49. }
  50. module.exports = UserController;

把数据写入数据库,我们需要在 service 目录下新建 user.js,

  1. 'use strict';
  2. const Service = require('egg').Service;
  3. class UserService extends Service {
  4. async getUserByName(username) {
  5. const { app } = this;
  6. try {
  7. const result = await app.mysql.get('user', { username });
  8. return result;
  9. } catch (e) {
  10. console.log(e);
  11. return null;
  12. }
  13. }
  14. async register(params) {
  15. const { app } = this;
  16. try {
  17. const result = await app.mysql.insert('user', params);
  18. return result;
  19. } catch (e) {
  20. console.log(e);
  21. return null;
  22. }
  23. }
  24. }
  25. module.exports = UserService;

登录接口实现

我们通过注册的「用户名」和「密码」,调用登录接口,接口会返回给我们一个 token 令牌。这个令牌的生成和使用我们通过一张流程图来分析:
image.png
网页端获取到 token 之后,需要将其存在浏览器本地,它是有过期时间的,通常我们会设置 24 小时的过期时间,如果不是一些信息敏感的网站或app,如银行、政务之类,我们可以将过期时间设置的更长一些。

之后每次发起请求,无论是获取数据,还是提交数据,我们都需要将 token 带上,以此来标识,此次获取(GET)或提交(POST)是哪一个用户的行为。

你可能会有疑问,服务端是怎么通过 token 来判断是哪一个用户在发起请求。既然 egg-jwt 有加密的功能,那也会有解密的功能。通过解密 token 拿到当初加密 token 时的信息,信息的内容大致就是当初注册时候的用户信息。

首先我们需要在项目下安装 egg-jwt 插件,执行如下指令:

  1. npm i egg-jwt -S

在 config/plugin.js 下添加插件:

  1. ...
  2. jwt: {
  3. enable: true,
  4. package: 'egg-jwt'
  5. }
  6. ...

紧接着前往 config/config.default.js 下添加自定义加密字符串:

  1. config.jwt = {
  2. secret: 'xiumubai',
  3. };

secret 加密字符串,将在后续用于结合用户信息生成一串 token。secret 是放在服务端代码中,普通用户是无法通过浏览器发现的,所以千万不能将其泄漏,否则有可能会被不怀好意的人加以利用。
在 /controller/user.js 下新建 login 方法,逐行添加分析,代码如下:

  1. async login() {
  2. // app 为全局属性,相当于所有的插件方法都植入到了 app 对象。
  3. const { ctx, app } = this;
  4. const { username, password } = ctx.request.body
  5. // 根据用户名,在数据库查找相对应的id操作
  6. const userInfo = await ctx.service.user.getUserByName(username)
  7. // 没找到说明没有该用户
  8. if (!userInfo || !userInfo.id) {
  9. ctx.body = {
  10. code: 500,
  11. msg: '账号不存在',
  12. data: null
  13. }
  14. return
  15. }
  16. // 找到用户,并且判断输入密码与数据库中用户密码。
  17. if (userInfo && password != userInfo.password) {
  18. ctx.body = {
  19. code: 500,
  20. msg: '账号密码错误',
  21. data: null
  22. }
  23. return
  24. }
  25. }

app 是全局上下文中的一个属性,config/plugin.js 中挂载的插件,可以通过 app.xxx 获取到,如 app.mysql、app.jwt 等。config/config.default.js 中抛出的属性,可以通过 app.config.xxx 获取到,如 app.config.jwt.secret。
所以我们继续编写后续的登录逻辑,上述的判断都通过之后,后续的代码逻辑如下:

  1. async login () {
  2. ...
  3. // 生成 token 加盐
  4. // app.jwt.sign 方法接受两个参数,第一个为对象,对象内是需要加密的内容;第二个是加密字符串,上文已经提到过。
  5. const token = app.jwt.sign({
  6. id: userInfo.id,
  7. username: userInfo.username,
  8. exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60) // token 有效期为 24 小时
  9. }, app.config.jwt.secret);
  10. ctx.body = {
  11. code: 200,
  12. message: '登录成功',
  13. data: {
  14. token
  15. },
  16. };
  17. }

我们把获取到的 userInfo 中的 id 和 username 两个属性,通过 app.jwt.sign 方法,结合 app.config.jwt.secret 加密字符串(之前声明的 Nick),生成一个 token。这个 token 会是一串很长的加密字符串,类似这样 dkadaklsfnasalkd9a9883kndlas9dfa9238jand 的一串密文。
完成上述操作之后,我们在路由 router.js 脚本中,将登录接口抛出:

  1. 'use strict';
  2. /**
  3. * @param {Egg.Application} app - egg application
  4. */
  5. module.exports = app => {
  6. const { router, controller } = app;
  7. router.post('/api/user/register', controller.user.register);
  8. router.post('/api/user/login', controller.user.login);
  9. };

测试一下:
image.png
可以看到,登陆以后,token成功拿到了
我们写个方法来验证token是否能正常使用了

  1. // 验证方法
  2. async test() {
  3. const { ctx, app } = this;
  4. // 通过 token 解析,拿到 user_id
  5. const token = ctx.request.header.authorization; // 请求头获取 authorization 属性,值为 token
  6. // 通过 app.jwt.verify + 加密字符串 解析出 token 的值
  7. const decode = await app.jwt.verify(token, app.config.jwt.secret);
  8. // 响应接口
  9. ctx.body = {
  10. code: 200,
  11. message: '获取成功',
  12. data: {
  13. ...decode
  14. }
  15. }
  16. }

我们发起请求的时候,通过在请求头 header 上,携带认证信息,让服务端可以通过 ctx.request.header.authorization 获取到 token,并且解析出内容返回到客户端,别忘了去 router.js 抛出这个接口:

  1. router.get('/api/user/test', controller.user.test);

我们测试一下接口是否可行:
image.png
可以看到,鉴权已经通过了。

注意,我们在请求头 Headers 上添加 authorization 属性,并且值为之前登录接口获取到的 token 值。发起请求后,我们得到返回值,id = 1、username = admin。实际证明,我们的鉴权,基本上已经完成了。

登录验证中间件

中间件我们可以理解成一个过滤器,举个例子,我们有 A、B、C、D 四个接口是需要用户权限的,如果我们要判断是否有用户权限的话,就需要在这四个接口的控制层去判断用户是否登录,为代码如下:

  1. A() {
  2. if(token && isValid(token)) {
  3. // do something
  4. }
  5. }
  6. B() {
  7. if(token && isValid(token)) {
  8. // do something
  9. }
  10. }
  11. C() {
  12. if(token && isValid(token)) {
  13. // do something
  14. }
  15. }
  16. D() {
  17. if(token && isValid(token)) {
  18. // do something
  19. }
  20. }

上述操作会有两个弊端:
1、每次编写新的接口,都要在方法内部做判断,这很费事。
2、一旦鉴权有所调整,我们需要修改每个用到判断登录的代码。
现在我们引入中间件的概念,在请求接口的时候,过一层中间件,判断该请求是否是登录状态下发起的。此时我们打开项目,在 app 目录下新新建一个文件夹 middleware,并且在该目录下新增 jwtErr.js,
我们为其添加如下代码:

  1. 'use strict';
  2. module.exports = secret => {
  3. return async function jwtErr(ctx, next) {
  4. // 若是没有 token,返回的是 null 字符串
  5. const token = ctx.request.header.authorization;
  6. if (token !== 'null' && token) {
  7. try {
  8. // 验证token
  9. ctx.app.jwt.verify(token, secret);
  10. await next();
  11. } catch (e) {
  12. console.log(e);
  13. ctx.status = 200;
  14. ctx.body = {
  15. msg: 'token已过期,请重新登录',
  16. code: 401,
  17. };
  18. return;
  19. }
  20. } else {
  21. ctx.status = 200;
  22. ctx.body = {
  23. code: 401,
  24. msg: 'token不存在',
  25. };
  26. return;
  27. }
  28. };
  29. };

首先中间件默认抛出一个函数,该函数返回一个异步方法 jwtErr,jewErr 方法有两个参数 ctx 是上下文,可以在 ctx 中拿到全局对象 app。
首先,通过 ctx.request.header.authorization 获取到请求头中的 authorization 属性,它便是我们请求接口是携带的 token 值,如果没有携带 token,该值为字符串 null。我们通过 if 语句判断如果有 token 的情况下,使用 ctx.app.jwt.verify 方法验证该 token 是否存在并且有效,如果是存在且有效,则通过验证 await next() 继续执行后续的接口逻辑。否则判断是失效还是不存在该 token。
编写完上述的中间件之后,我们就要前往 router.js 去使用它,如下所示:

  1. 'use strict';
  2. /**
  3. * @param {Egg.Application} app - egg application
  4. */
  5. module.exports = app => {
  6. const { router, controller, middleware } = app;
  7. const _jwt = middleware.jwtErr(app.config.jwt.secret); // 传入加密字符串
  8. router.post('/api/user/register', controller.user.register);
  9. router.post('/api/user/login', controller.user.login);
  10. router.get('/api/user/test', _jwt, controller.user.test); // 放入第二个参数,作为中间件过滤项
  11. };

我们模拟不带 authorization 的请求,如下所示:
image.png
勾去选项,发起请求,如上图所示,进入中间件,判断 token 不存在。我们在随便写一个 token 值验证无效的情况。
image.png
可见,登录验证的中间件逻辑基本上已经实现了,后续我们如果想要新增一些接口是需要用户权限的,便可以在抛出方法的第二个参数,添加 _jwt 方法,这样便可在进入接口逻辑之前就进行用户权限的判断。