MidwayJS 全栈开发(六)JWT 注册登录认证
MidwayJS 全栈开发(六)JWT 注册登录认证
前言
上一篇内容回顾: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 的Header
或Payload
中放置敏感信息,因为它们可以被解码公开的。
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
目录,用来聚合登录认证相关逻辑和路由代码。
- 新增
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;
}
- 新增
auth.controller.ts
如上章节所述 Controller
和 Service
职责分离,我们还要在 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
,说明功能已经打通了。
我们再使用 Prisma Studio 查看下用户表中的数据,可以看到一条刚刚注册的用户 Flcwl
的记录,并且其密码已经被加密存储在数据库了。完美~
image.png
用户登录
有了用户,那么接下来我们进入用户登录实战开发。同样,我们梳理下我们要做的几件事:
- 校验用户名是否存在
- 比对密码是否匹配
- 基于 JWT 颁布用户凭证并返回
生成 Token
首先攻克难点,如何生成带用户身份凭证的 Token
。
这里我们使用 Midway 官方提供的 JWT 组件,其中内置封装了 JWT 一些可用的 API,我们可以基于它快速实现 JWT 签发和认证。
- 配置 JWT 的密钥和过期时间
// src/config/config.default.ts
export default (appInfo: MidwayAppInfo) => {
return {
// ...
+ jwt: {
+ secret: 'my-secret', // 密钥
+ expiresIn: '3d', // 过期时间
+ },
}
};
- 注入 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 {
// ...
}
- 编写签发用户凭证逻辑
调用 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
模块下开发。
- 登录逻辑实现
首先,我们要知道登录逻辑的核心在于如何进行密码比对。
由于我们的密码是加密后存储的在数据库中的,所以对于登录时接收到的密码我们需要做两件事: - 将其使用同样的盐进行加密生成密文 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);
+ }
}
- 声明登录接口路由
遵循职责分离原则,我们在 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 被拿到,就可以冒充该用户进行任何操作及请求。