专栏首页图雀社区Nest.js 从零到壹系列(三):使用 JWT 实现单点登录

Nest.js 从零到壹系列(三):使用 JWT 实现单点登录

本文由图雀社区认证作者 布拉德特皮 写作而成,点击阅读原文查看作者掘金链接,感谢作者的优质输出,让我们的技术世界变得更加美好?

前言

上一篇介绍了如何使用 Sequelize 连接 MySQL,接下来,在原来代码的基础上进行扩展,实现用户的注册和登录功能。

这里需要简单提一下两个概念 JWT 和 单点登录:

JWT

JWT(JSON Web Token)是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准(RFC 7519)。该 Token 被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该 Token 也可直接被用于认证,也可被加密。

具体原理可以参考《JSON Web Token 入门教程 \- 阮一峰》[1]

单点登录

单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

所以 JWT 实现【单点登录】的大致流程是:

  1. 客户端用户进行登录请求;
  2. 服务端拿到请求,根据参数查询用户表;
  3. 若匹配到用户,将用户信息进行签证,并颁发 Token;
  4. 客户端拿到 Token 后,存储至某一地方,在之后的请求中都带上 Token ;
  5. 服务端接收到带 Token 的请求后,直接根据签证进行校验,无需再查询用户信息;

下面,就开始我们的实战:

GitHub 项目地址[2],欢迎各位大佬 Star。

一、编写加密的工具函数

src 目录下,新建文件夹 utils,里面将存放各种工具函数,然后新建 cryptogram.ts 文件:

import * as crypto from 'crypto';

/**
 * Make salt
 */
export function makeSalt(): string {
  return crypto.randomBytes(3).toString('base64');
}

/**
 * Encrypt password
 * @param password 密码
 * @param salt 密码盐
 */
export function encryptPassword(password: string, salt: string): string {
  if (!password || !salt) {
    return '';
  }
  const tempSalt = Buffer.from(salt, 'base64');
  return (
    // 10000 代表迭代次数 16代表长度
    crypto.pbkdf2Sync(password, tempSalt, 10000, 16, 'sha1').toString('base64')
  );
}

上面写了两个方法,一个是制作一个随机盐(salt),另一个是根据盐来加密密码。

这两个函数将贯穿注册和登录的功能。

二、用户注册

在写注册逻辑之前,我们需要先修改一下上一篇写过的代码,即 user.service.ts 中的 findeOne() 方法:

// src/logical/user/user.service.ts
import { Injectable } from '@nestjs/common';
import * as Sequelize from 'sequelize'; // 引入 Sequelize 库
import sequelize from '../../database/sequelize'; // 引入 Sequelize 实例

@Injectable()
export class UserService {
  /**
   * 查询是否有该用户
   * @param username 用户名
   */
  async findOne(username: string): Promise<any | undefined> {
    const sql = `
      SELECT
        user_id userId, account_name username, real_name realName, passwd password,
        passwd_salt salt, mobile, role
      FROM
        admin_user
      WHERE
        account_name = '${username}'
    `; // 一段平淡无奇的 SQL 查询语句
    try {
      const user = (await sequelize.query(sql, {
        type: Sequelize.QueryTypes.SELECT, // 查询方式
        raw: true, // 是否使用数组组装的方式展示结果
        logging: true, // 是否将 SQL 语句打印到控制台
      }))[0];
      // 若查不到用户,则 user === undefined
      return user;
    } catch (error) {
      console.error(error);
      return void 0;
    }
  }
}

现在,findOne() 的功能更符合它的方法名了,查到了,就返回用户信息,查不到,就返回 undefined

接下来,我们开始编写注册功能:

// src/logical/user/user.service.ts
import { Injectable } from '@nestjs/common';
import * as Sequelize from 'sequelize'; // 引入 Sequelize 库
import sequelize from '../../database/sequelize'; // 引入 Sequelize 实例

import { makeSalt, encryptPassword } from '../../utils/cryptogram'; // 引入加密函数

@Injectable()
export class UserService {
  /**
   * 查询是否有该用户
   * @param username 用户名
   */
  async findOne(username: string): Promise<any | undefined> {
    ...
  }

  /**
   * 注册
   * @param requestBody 请求体
   */
  async register(requestBody: any): Promise<any> {
    const { accountName, realName, password, repassword, mobile } = requestBody;
    if (password !== repassword) {
      return {
        code: 400,
        msg: '两次密码输入不一致',
      };
    }
    const user = await this.findOne(accountName);
    if (user) {
      return {
        code: 400,
        msg: '用户已存在',
      };
    }
    const salt = makeSalt(); // 制作密码盐
    const hashPwd = encryptPassword(password, salt);  // 加密密码
    const registerSQL = `
      INSERT INTO admin_user
        (account_name, real_name, passwd, passwd_salt, mobile, user_status, role, create_by)
      VALUES
        ('${accountName}', '${realName}', '${hashPwd}', '${salt}', '${mobile}', 1, 3, 0)
    `;
    try {
      await sequelize.query(registerSQL, { logging: false });
      return {
        code: 200,
        msg: 'Success',
      };
    } catch (error) {
      return {
        code: 503,
        msg: `Service error: ${error}`,
      };
    }
  }
}

编写好后,在 user.controller.ts 中添加路由

// src/logical/user/user.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly usersService: UserService) {}

  // @Post('find-one')
  // findOne(@Body() body: any) {
  //   return this.usersService.findOne(body.username);
  // }

  @Post('register')
  async register(@Body() body: any) {
    return await this.usersService.register(body);
  }
}

现在,我们使用 Postman 来测试一下,先故意输入不一样的密码和已存在的用户名:

如图,密码不一致的校验触发了。

然后,我们把密码改成一致的:

如图,已有用户的校验触发了。

然后,我们再输入正确的参数:

我们再去数据库看一下:

发现已经将信息插入表中了,而且密码也是加密后的,至此,注册功能已基本完成。

三、JWT 的配置与验证

为了更直观的感受处理顺序,我在代码中加入了步骤打印

1. 安装依赖包

$ yarn add passport passport-jwt passport-local @nestjs/passport @nestjs/jwt -S

2. 创建 Auth 模块

$ nest g service auth logical
$ nest g module auth logical

3. 新建一个存储常量的文件

auth 文件夹下新增一个 constants.ts,用于存储各种用到的常量:

// src/logical/auth/constats.ts
export const jwtConstants = {
  secret: 'shinobi' // 秘钥
};

4. 编写 JWT 策略

auth 文件夹下新增一个 jwt.strategy.ts,用于编写 JWT 的验证策略:

// src/logical/auth/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }
  
  // JWT验证 - Step 4: 被守卫调用
  async validate(payload: any) {
    console.log(`JWT验证 - Step 4: 被守卫调用`);
    return { userId: payload.sub, username: payload.username, realName: payload.realName, role: payload.role };
  }
}

5. 编写 auth.service.ts 的验证逻辑

// src/logical/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { encryptPassword } from '../../utils/cryptogram';

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UserService, private readonly jwtService: JwtService) {}

  // JWT验证 - Step 2: 校验用户信息
  async validateUser(username: string, password: string): Promise<any> {
    console.log('JWT验证 - Step 2: 校验用户信息');
    const user = await this.usersService.findOne(username);
    if (user) {
      const hashedPassword = user.password;
      const salt = user.salt;
      // 通过密码盐,加密传参,再与数据库里的比较,判断是否相等
      const hashPassword = encryptPassword(password, salt);
      if (hashedPassword === hashPassword) {
        // 密码正确
        return {
          code: 1,
          user,
        };
      } else {
        // 密码错误
        return {
          code: 2,
          user: null,
        };
      }
    }
    // 查无此人
    return {
      code: 3,
      user: null,
    };
  }

  // JWT验证 - Step 3: 处理 jwt 签证
  async certificate(user: any) {
    const payload = { username: user.username, sub: user.userId, realName: user.realName, role: user.role };
    console.log('JWT验证 - Step 3: 处理 jwt 签证');
    try {
      const token = this.jwtService.sign(payload);
      return {
        code: 200,
        data: {
          token,
        },
        msg: `登录成功`,
      };
    } catch (error) {
      return {
        code: 600,
        msg: `账号或密码错误`,
      };
    }
  }
}

此时保存文件,控制台会报错:

可以先不管,这是因为还没有把 JwtService 和 UserService 关联到 auth.module.ts 中。

5. 编写本地策略

这一步非必须,根据项目的需求来决定是否需要本地策略

// src/logical/auth/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

6. 关联 Module

// src/logical/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '8h' }, // token 过期时效
    }),
    UserModule,
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

此时保存文件,若还有上文的报错,则需要去 app.module.ts,将 AuthServiceproviders 数组中移除,并在 imports 数组中添加 AuthModule 即可:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './logical/user/user.module';
// import { AuthService } from './logical/auth/auth.service';
import { AuthModule } from './logical/auth/auth.module';

@Module({
  imports: [UserModule, AuthModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

7. 编写 login 路由

此时,回归到 user.controller.ts,我们将组装好的 JWT 相关文件引入,并根据验证码来判断用户状态:

// src/logical/user/user.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly authService: AuthService, private readonly usersService: UserService) {}

  // JWT验证 - Step 1: 用户请求登录
  @Post('login')
  async login(@Body() loginParmas: any) {
    console.log('JWT验证 - Step 1: 用户请求登录');
    const authResult = await this.authService.validateUser(loginParmas.username, loginParmas.password);
    switch (authResult.code) {
      case 1:
        return this.authService.certificate(authResult.user);
      case 2:
        return {
          code: 600,
          msg: `账号或密码不正确`,
        };
      default:
        return {
          code: 600,
          msg: `查无此人`,
        };
    }
  }

  @Post('register')
  async register(@Body() body: any) {
    return await this.usersService.register(body);
  }
}

此时保存文件,同样的报错又出现了:

这次我们先去 user.module.tscontrollers 注释掉:

此时看控制台,没有 User 相关的路由,我们需要去 app.module.ts 将 Controller 添加回去:

这么做是因为如果在 user.module.ts 中引入 AuthService 的话,就还要将其他的策略又引入一次,个人觉得很麻烦,就干脆直接用 app 来统一管理了。

四、登录验证

前面列了一大堆代码,是时候检验效果了,我们就按照原来注册的信息,进行登录请求:

图中可以看到,已经返回了一长串 token 了,而且控制台也打印了登录的步骤和用户信息。前端拿到这个 token,就可以请求其他有守卫的接口了。

接下来我们试试输错账号或密码的情况:

五、守卫

既然发放了 Token,就要能验证 Token,因此就要用到 Guard(守卫)了。

我们拿之前的注册接口测试一下,修改 user.controller.ts 的代码,引入 UseGuardsAuthGuard,并在路由上添加 @UseGuards(AuthGuard('jwt'))

// src/logical/user/user.controller.ts
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from '../auth/auth.service';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly authService: AuthService, private readonly usersService: UserService) {}

  @Post('login')
  async login(@Body() loginParmas: any) {
    ...
  }

  @UseGuards(AuthGuard('jwt')) // 使用 'JWT' 进行验证
  @Post('register')
  async register(@Body() body: any) {
    return await this.usersService.register(body);
  }
}

然后,我们先来试试请求头没有带 token 的情况:

可以看到,返回 401 状态码,Unauthorized 表示未授权,也就是判断你没有登录。

现在,我们试试带 Token 的情况,把登录拿到的 Token 复制到 Postman 的 Authorzation 里(选择 Bearer Token):

然后再请求接口:

此时,已经可以正常访问了,再看看控制台打印的信息,步骤也正如代码中注释的那样:

至此,单点登录功能已基本完成。

总结

本篇介绍了如何使用 JWT 对用户登录进行 Token 签发,并在接受到含 Token 请求的时候,如何验证用户信息,从而实现了单点登录。

当然,实现单点登录并不局限于 JWT,还有很多方法,有兴趣的读者可以自己查阅。

这里也说一下 JWT 的缺点,主要是无法在使用同一账号登录的情况下,后登录的,挤掉先登录的,也就是让先前的 Token 失效,从而保证信息安全(至少我是没查到相关解决方法,如果有大神解决过该问题,还请指点),只能使用一些其他黑科技挤掉 Token。

现在,注册、登录功能都有了,接下来应该完善一个服务端应有的其他公共功能。

下一篇将介绍拦截器、异常处理以及日志的收集。

参考资料

[1]

《JSON Web Token 入门教程 - 阮一峰》: http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

[2]

GitHub 项目地址: https://github.com/SephirothKid/nest-zero-to-one

● Nest.js 从零到壹系列(一):项目创建&路由设置&模块● Nest.js 从零到壹系列(二):数据库的连接● 从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(最终篇)

·END·

本文分享自微信公众号 - 图雀社区(tuture-dev),作者:布拉德特皮

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-03-22

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Nest.js 从零到壹系列(一):项目创建&路由设置&模块

    本系列将以前端的视角进行书写,分享自己的踩坑经历。教程主要面向前端或者毫无后端经验,但是又想尝试 Node.js 的读者,当然,也欢迎后端大佬斧正。

    一只图雀
  • Taro 小程序开发大型实战(四):使用 Hooks 版的 Redux 实现应用状态管理(上篇)

    •熟悉的 React,熟悉的 Hooks[1]:我们用 React 和 Hooks 实现了一个非常简单的添加帖子的原型•多页面跳转和 Taro UI 组件库[2...

    一只图雀
  • Nest.js 从零到壹系列(七):讨厌写文档,Swagger UI 了解一下?

    上一篇介绍了如何使用寥寥几行代码就实现 RBAC 0,解决了权限管理的痛点,这篇将解决另一个痛点:写文档。

    一只图雀
  • Science: 位于人类听觉皮层的语调编码

    来自美国加州大学旧金山分校的研究人员C.Tang等人近期在《Science》杂志上发文,他们使用颅内电极记录癫痫病人听具有不同声学特征(如声调轮廓,声学内容,音...

    用户1279583
  • 一日一技:使用装饰器实现类属性的懒加载

    我们发现这样写有一个问题——类在初始化的时候,就会创建数据库的链接。但我们并不是在类刚刚初始化时就读写数据库。

    青南
  • Zookeeper 与分布式锁

    在上篇文章中讨论了基于 Redis 的单机分布式锁与集群分布式锁的方案,在数据一致性要求不是很高的情况下,Redis 实现的分布式锁可以满足我们的要求。最近在拜...

    haifeiWu
  • Linux下安装GLIBC_2.15

    試毅-思伟
  • rest_framework基础

    人生不如戏
  • Appium+java+Mac自动化测试(eclipse脚本编写)

    顾翔老师开发的bugreport2script开源了,希望大家多提建议。文件在https://github.com/xianggu625/bug2testscr...

    小老鼠
  • jquery获取元素到页面顶部的距离

    在前端开发过程中,经常会遇到要求滚动条滚动到某位置时某按钮固定在页面上,否则悬浮于页面上。这时就会用到获取需要固定在页面位置的元素距离页面顶部的距离,通过比较文...

    无邪Z

扫码关注云+社区

领取腾讯云代金券