MidwayJS 全栈开发(六)JWT 注册登录认证

691 阅读2分钟

image.png

前言

上一篇内容回顾:MidwayJS 全栈开发(五)Prisma 与 PostgreSQL 实战 RestAPI

登录可以说是 web 应用必不可少的一环,有了账户才能标识用户身份,实现云端存储个人数据。本节主要关注 JWT,以及基于它如何实现用户的注册和登录功能。

JWT

什么是 JWT

首先是 JSON Web Token (JWT),它是一个开放标准(RFC 7519) ,它定义了一种紧凑和自包含的方式,基于 JSON 对象在双方之间安全地传输信息。

此信息是一串使用指定加密算法进行数字签名的 Token,该签名可以证明只有持有私钥的一方才是对其进行签名的一方,因而可以进行验证和信任

同时也可以增加一些额外的且必须声明的信息,常被用于在客户端和服务端间传递被认证的用户身份信息

JWT 结构

JWT 主要包含由三部分内容组成,他们之间用原点(.)连接,最终产物其实就是一串字符串。

${Header}.${Payload}.${Signature}

接下来我们分别看下每个部分的定义和作用:

Header 报头

通常由两部分组成:令牌类型(JWT)和所使用的签名算法(HMAC SHA256)。最终会被 Base64URL 编码作为 JWT 的第一部分。

{ "alg": "HS256", "typ": "JWT" }

Payload 声明

用于声明数据,通常分为三大类: registered(预定义的), public(公开的) 和 private(私有的)。

对于预定义声明,属于非强制但是推荐使用的。比如:exp (Token过期时间),sub (主题,常设用户身份标识),iss(签发者),aud (接收方),iat(签发时间)等。

一般我们使用预定义声明夹带一些额外信息就够了,它们通常是标识用户身份的必要信息,最终会被 Base64URL 编码作为 JWT 的第二部分。

{ 
  "sub": "1234567890", // 身份标识 userId
  "username": "Flcwl", 
  "role": "ADMIN"
}

需要注意的是,请不要在 JWT 的 HeaderPayload 中放置敏感信息,因为它们可以被解码公开的。

Signature 签名

为了防止传递的内容被篡改,我们还需要对 header 和 payload 进行签名(也就是私钥加密)。

  • 加密算法:在 header 中明确指定的,即:HMACSHA256
  • 私钥 secret:在服务端中定义,不对外暴露,用于验证携带内容是否可信
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload), 
  secret
)

最终我们会把加密生成的这个字符串使用 Base64URL 进行编码,然后作为 JWT 的第三部分。

最终生成的产物如下图所示。

image.png

可以看到,最终生成的就是一串字符串,我们简单称之为 Token

JWT 认证流程

当用户输入用户名和密码成功登录以后,服务端认证成功就会颁发 Token 并返回给到客户端。

此后,Token 就可以作为携带认证用户身份的凭证了。客户端收到服务器返回的 Token 后,可以储存在 Cookie 里面,也可以储存在 LocalStorage 中。

在后续每次与服务器的通信时,只需要携带它就能实现进行基于用户的前后端数据传输了。

关于如何携带 Token,你可以把它放在 Cookie 里面(自动发送但是无法跨一级域); 也可以放在 HTTP 的请求头的 Authorization 字段里面,比如 Bearer Token (RFC 6750) 规范。

最后,就是服务端获取到 Token 后如何进行合法性验证的问题了,在这里我们只需要用私钥重新生成签名,然后比对一下签名即可。

image.jpg

JWT 的优劣势

这里我们在分析下 JWT 的特点以及优劣势,加深对其实际应用的理解。

优势

  • 无状态Token 自身包含了认证用户的信息,无需服务端存储用户 Session 会话信息;
  • 支持跨域:携带方式不限于 Cookie,免去跨域限制,避免 CSRF 攻击;
  • 方便传输:JWT 的构成简单占用字节小,传输简单;
  • 性能略高: 服务端验证获取用户信息无需 SQL 或 Session 查询,仅需一次 HMACSHA256 计算即可;

劣势

  • 安全性低:Token 中的payload 没有加密,因此不能存储敏感数据,反观 Session 信息是存在服务端的,相对来讲更加安全;
  • 无法销毁:认证信息均在 Token 中,一旦生成 Token 只能等到了过期时间自动销毁。因而本身没法注销登录,甚至即使修改密码,只要没到过期时间也能认证访问成功;
  • 一次性签发:如有变更,想要修改 Token 里面的内容,就必须重新签发新的 Token。因此其本身也就无具备 Token 续签免登的能力。

JWT 的适用场景

  • 分布式系统认证场景:认证的用户信息均存储在 Token 中(客户端),相对于 Session 无需要多台机器之间进行数据共享和同步,简单方便。
  • 一次性验证场景:比如用户注册后需要发一封邮件让其激活账户,通常邮件中会有一个认证链接,这个链接需要具备以下的特性:1. 能够标识用户;2. 该链接具有时效性(通常只允许几小时之内激活);3. 同时不能被篡改以激活其他可能的账户。
  • 跨端应用场景:无需受限于 Cookie 携带传输方案,并且支持跨语言,只要客户端支持存储 Token 就能够使用。

本节小结

本小结主要介绍了 JWT 的工作原理以及使用场景分析,对于 JWT 而言最大的优点是无状态,最大的缺点可能就是一次性签发。在实际业务场景中,如果要想用更好地去使用它,可能还需要结合服务端存储以补足其短处。


用户认证实战

讲完理论知识,终于我们进入实战环节。这里我们使用用户名 + 密码来模拟用户注册登录以及认证访问的场景。

用户注册

首先得有用户,第一步我们需要实现用户注册的能力。

image.png

那么,从产品以及服务端的角度,我们可能需要按顺序处理一下逻辑:

  1. 二次密码检查,避免误输入,强化用户记忆
  2. 检查用户表是否已存在该用户,保证用户名唯一
  3. 注册成功的用户信息落库,密码加密安全存储

密码安全存储方案

首先攻克难点:如何实现密码这样的敏感数据安全存储。这里我们采用的方案是不可逆加密存储:

  1. 对密码加盐进行不可逆加密,生成密文
  2. 将密文以及盐都存储到用户表中,而不存储真实密码

这样,即使数据库表泄漏,也无法看到表中用户真实的密码,极大保证用户的隐私安全。因此,我们先要编写以下 2 个工具方法:

  1. 生成盐方法
  2. 不可逆加密方法
// src/utils/index.ts

import * as CryptoJS from 'crypto-js';

// ...

export function makeSalt() {
  return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Base64);
}

export function encryptWithSalt(secret: string, salt: string) {
  if (!secret || !salt) return '';

  return CryptoJS.PBKDF2(secret, salt, {
    keySize: 16,
    iterations: 1000,
  }).toString(CryptoJS.enc.Base64);
}

crypto-js 是一款基于 JS 用于加密、解密的库,内置很多实用的相关算法。

user.service 改造

接下来,我们来实现依赖数据库操作的 2 个方法:

  1. 根据用户名查找用户的接口,用来实现 “检查用户表是否已存在该用户” 逻辑。
  2. 插入一条用户数据的接口,用来实现 “注册成功的用户信息落库” 逻辑。

结合上一篇实战内容,我们知道 user.service 负责用户表的逻辑处理,所以我们在 user.service.ts 中的新增 findByUsername()insert() 的方法。

// src/modules/user/user.service.ts

import { Provide } from '@midwayjs/core';
import { prisma } from '../../prisma';

@Provide()
export class UserService {
   // ...

+  async findByUsername(username: string) {
+    return prisma.user.findFirst({ where: { username: username } });
+  }

+  async create(user: UserCreateModel) {
+    return await prisma.user.create({ data: user });
+  }
}

+ export interface UserCreateModel {
+   username: string;
+   password: string;
+   salt: string;
+ }

对于 Prisma 还不了解的同学可以返回阅读 上一篇:Prisma 与 PostgreSQL 实战 RestAPI

注册模块实现

最后,我们开始编写用户注册的功能接口,在 modules 下创建 auth 目录,用来聚合登录认证相关逻辑和路由代码。

  1. 新增 auth.service.ts

这里我们在 auth.service.ts 中实现用户注册的逻辑部分,如果注册成功,将返回注册成功的用户 id

// src/modules/auth/auth.service.ts

import { Inject, Provide } from '@midwayjs/decorator';
import { UserService } from '../user/user.service';
import { BaseResponse, encryptWithSalt, makeSalt } from '../../utils';

@Provide()
export class AuthService {
  @Inject()
  userService: UserService;

  async register(requestBody: UserRegisterBodyRequest) {
    const { username, password, twicePassword } = requestBody;

    // 1
    const user = await this.userService.findByUsername(username);

    if (user) {
      return BaseResponse.error('用户已存在');
    }

    // 2
    if (password !== twicePassword) {
      return BaseResponse.error('两次密码输入不一致');
    }

    // 3
    const salt = makeSalt();
    const encryptedPassword = encryptWithSalt(password, salt);

    try {
      const result = await this.userService.create({
        username,
        password: encryptedPassword,
        salt,
      });
      return BaseResponse.ok(result.id);
    } catch (error) {
      return BaseResponse.error(error.message);
    }
  }
}

export interface UserRegisterBodyRequest {
  username: string;
  password: string;
  twicePassword: string;
}
  1. 新增 auth.controller.ts

如上章节所述 ControllerService 职责分离,我们还要在 auth.controller.ts 中声明用户注册的接口路由。

// src/modules/auth/auth.controller.ts

import { Controller, Post, Body, Inject } from '@midwayjs/decorator';
import { AuthService } from './auth.service';
import type { UserRegisterBodyRequest } from './auth.service';

@Controller('/auth')
export class AuthController {
  @Inject()
  authService: AuthService;

  @Post('/register')
  async register(@Body() body: UserRegisterBodyRequest) {
    return await this.authService.register(body);
  }
}

注册模拟测试

我们可以简单模拟下用户注册请求,可以看到最终返回成功注册的用户 id,说明功能已经打通了。 image.png

我们再使用 Prisma Studio 查看下用户表中的数据,可以看到一条刚刚注册的用户 Flcwl 的记录,并且其密码已经被加密存储在数据库了。完美~

image.png


用户登录

有了用户,那么接下来我们进入用户登录实战开发。同样,我们梳理下我们要做的几件事:

  1. 校验用户名是否存在
  2. 比对密码是否匹配
  3. 基于 JWT 颁布用户凭证并返回

生成 Token

首先攻克难点,如何生成带用户身份凭证的 Token

这里我们使用 Midway 官方提供的 JWT 组件,其中内置封装了 JWT 一些可用的 API,我们可以基于它快速实现 JWT 签发和认证。

  1. 配置 JWT 的密钥和过期时间
// src/config/config.default.ts  

export default (appInfo: MidwayAppInfo) => {
   return {
     // ...  
+    jwt: {  
+      secret: 'my-secret', // 密钥
+      expiresIn: '3d', // 过期时间
+    },  
   }
};
  1. 注入 JWT 组件能力
import { Configuration } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
+ import * as jwt from '@midwayjs/jwt';  
  
@Configuration({  
  imports: [
    koa,
+    jwt,
    // ...
  ],  
})
export class ContainerLifeCycle {
  // ...  
}
  1. 编写签发用户凭证逻辑

调用 JwtService.sign 方法完成对用户 id 身份的凭证签发。

// src/modules/auth/auth.service.ts

import { Inject, Provide } from '@midwayjs/decorator';
import { UserService } from '../user/user.service';
import { encryptWithSalt } from '../../utils/index';
import { BaseResponse } from '../../responses';
+ import { JwtService } from '@midwayjs/jwt';

@Provide()
export class AuthService {
  @Inject()
  userService: UserService;

+  @Inject()
+  jwtService: JwtService;

+  async certificate(entity: { id: string }) {
+    const payload = {
+      sub: entity.id,
+    };
+
+    try {
+      // 签发生成 token
+      const accessToken = await this.jwtService.sign(payload);
+      return BaseResponse.ok({ accessToken });
+    } catch (err) {
+      console.error(err);
+      return BaseResponse.error('JWT 组件异常');
+    }
+  }
}

以上就先完成了 JWT 的 Token 签发功能实现,最终会以 accessToken 放回给到客户端。

登录模块实现

关于登录功能,我们和注册功能一样,都聚合在 modules/auth 模块下开发。

  1. 登录逻辑实现

首先,我们要知道登录逻辑的核心在于如何进行密码比对。

由于我们的密码是加密后存储的在数据库中的,所以对于登录时接收到的密码我们需要做两件事:

  • 将其使用同样的盐进行加密生成密文 A
  • 然后和数据库中的密文密码 B 进行比对
// src/modules/auth/auth.service.ts

import { Inject, Provide } from '@midwayjs/decorator';
import { UserService } from '../user/user.service';
import { encryptWithSalt } from '../../utils/index';
import { BaseResponse } from '../../responses';
import { JwtService } from '@midwayjs/jwt';

@Provide()
export class AuthService {
  @Inject()
  userService: UserService;

  @Inject()
  jwtService: JwtService;

  async certificate(entity: { id: string }) {
    const payload = {
      sub: entity.id,
    };

    try {
      // 签发生成 token
      const accessToken = await this.jwtService.sign(payload);
      return BaseResponse.ok({ accessToken });
    } catch (err) {
      console.error(err);
      return BaseResponse.error('JWT 组件异常');
    }
  }

+  async login(username: string, password: string) {
+    const user = await this.userService.findByUsername(username);
+    if (!user) {
+      return BaseResponse.error('用户不存在');
+    }
+
+    const encryptedPassword = encryptWithSalt(password, user.salt);
+    if (encryptedPassword !== user.password) {
+      return BaseResponse.error('账号或密码不正确');
+    }
+
+    // 签发 token 并返回
+    return this.certificate(user);
+  }
}
  1. 声明登录接口路由

遵循职责分离原则,我们在 auth.controller.ts 中声明用户登录的接口路由。

import { Controller, Post, Body, Inject } from '@midwayjs/decorator';
import { AuthService } from './auth.service';
import type { UserRegisterBodyRequest } from './auth.service';

@Controller('/auth')
export class AuthController {
  @Inject()
  authService: AuthService;

  @Post('/register')
  async register(@Body() body: UserRegisterBodyRequest) {
    return await this.authService.register(body);
  }
}

+  @Post('/login')
+  async login(
+    @Body('username') username: string,
+    @Body('password') password: string
+  ) {
+    return this.authService.login(username, password);
+  }
}

登录模拟测试

紧接着我们使用刚刚注册的账号模拟请求下登录:最终可以看到接口返回了 accessToken 字段,即为 JWT 的 Token。成功~

image.png


认证访问

最后,我们还需要确保签发的 Token 有效且能做到登录后的认证访问。

我们假设这样一个场景:除了登录和注册接口,其他接口都需要走 JWT 认证访问。

认证访问中间件

这里,我们可以配合全局中间件 Middleware 来实现这样的诉求,每次从客户端过来的请求都会经过中间件认证检查,最终通过才会进入到真正的路由层。

image.png

对于 JWT 的认证传输,我们遵循 Bearer 规范,约定将 Token 放在请求头中 Authorization 字段中。

Authorization: Bearer <token>

最终中间件认证访问逻辑实现如下,主要做了两件事:

  1. 忽略 /api/auth/register/api/auth/login 接口走认证
  2. 实现对 Token 读取和校验的逻辑,主要使用 jwtService.verify(token) 方法
// src/middlewares/jwt.middleware.ts

import { Inject, Middleware, httpError } from '@midwayjs/core';
import { Context, NextFunction } from '@midwayjs/koa';
import { JwtService } from '@midwayjs/jwt';

@Middleware()
export class JwtMiddleware {
  @Inject()
  jwtService: JwtService;

  public static getName(): string {
    return 'jwt';
  }

  resolve() {
    return async (ctx: Context, next: NextFunction) => {
      // 认证身份信息判断和获取
      if (!ctx.headers['authorization']) {
        throw new httpError.UnauthorizedError();
      }
  
      const parts = ctx.get('authorization').trim().split(' ');

      if (parts.length !== 2) {
        throw new httpError.UnauthorizedError();
      }

      const [scheme, token] = parts;

      if (/^Bearer$/i.test(scheme)) {
        // jwt.verify 方法验证 token 是否合法
        await jwtService.verify(token, {
          complete: true,
        });
        await next();
      }
    };
  }

  // 配置忽略认证校验的路由地址
  public match(ctx: Context): boolean {
    const ignore = ['/api/auth/register', '/api/auth/login'].includes(ctx.path)
    return !ignore;
  }
}

然后,我们在入口配置文件中注入,启用该中间件。

// src/configuration.ts

import { Configuration, App } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import * as jwt from '@midwayjs/jwt';
+ import { JwtMiddleware } from './middlewares'

@Configuration({
  imports: [
    koa,
    jwt,
    // ...
  ],
})
export class ContainerLifeCycle {
  @App()
  app: koa.Application;

  async onReady() {
    // 添加中间件
    this.app.useMiddleware([
      // ...
+      JwtMiddleware,
    ]);
  }
}

用户认证访问模拟测试

代码写完了,最后我们走一下用户认证访问的流程。就用上一篇中定义的根据 id 查询用户数据的接口来测试:

此时,客户端只有将有效的 Token 存放在 Authorization 中去访问该请求,才会被认证通过访问该接口,如下图所示。

image.png

否则的话,系统将抛出未认证异常。

image.png

由于我们目前没有对服务进行任何异常处理封装,所以针对异常没有返回合适的数据结构。后面再进行优化,感兴趣的同学可以试试找找解决办法。


全文总结

本文主要介绍了如何使用 JWT 进行用户登录和认证访问。JWT 作为一种无状态的令牌认证方式,使用场景十分广泛,非常值得学习。当然,实现登录验证的方法还有很多,感兴趣的读者可以继续深入研究。

值得一提的是,使用 JWT 一定要确保宿主环境是安全可信的,比如 Web 场景下,那么 100% 推荐使用 HTTPS 协议进行传输,因为一旦 Token 被拿到,就可以冒充该用户进行任何操作及请求。