JSON Web Token
HTTP 是一种无状态的通信协议,所谓无状态,就是服务器不能通过它判断两次发请求过来的是不是同一个用户。好在它可以扩展,通过扩展,可以分辨出是同一个用户在访问网站。
比如:
用户需要登录后才能在淘宝上购买商品,这样服务器才能知道购买商品的人是谁,地址是哪里。
当用户将某商品加入购物车时,也会将请求发送给服务器,而服务器则需要通过上次用户登录的记忆知道发请求的人是谁,这样才能将信息推到对应人的购物车上。
那么,这里的记忆在以前是指 cookie。
它的流程是这样的:
- 用户向服务器发送账号和密码。
- 服务器验证通过后,在当前对话(session)里面保存相关的数据,比如用户角色,登录时间等。
- 服务器向用户返回一个 session_id,写入用户的 Cookie。
- 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
- 服务器收到 session_id,得知用户的身份,就可以知道这是同一个用户。
这种模式的缺点在于扩展性不足。比如当用户登录同一家公司的不同网站都需要用户信息时,session 数据 就需要共享,让每台服务器都能读取 session。
共享的方法是让 session 数据持久化,写入数据库或者别的持久层。虽然这种方法架构清晰,但也工程量巨大,而且持久层的稳定性要求高。
由于上面的方法缺点明显,所以就需要另一种方法:
直接放弃 session 这种方式,而是利用新的方案替代原来的登录体系,JWT 就是流行的方案。
JWT 方案
JWT 全称 JSON Web Token,翻译过来就是 JSON 格式的 网络令牌。它会直接将用户的信息生成一个 JSON对象,发还给用户。
比如当用户登录 后,服务器经过认证,发现他的信息是这样的
json1{ 2 "username": "qiuyanxi", 3 "role": "admin", 4 "time": "2021-07-06" 5}
服务器会把这条数据返回给用户,当用户每次请求时,都会带上这条数据,服务器就可以通过这条数据上的内容判定用户身份。
这样的话服务器跟客户端的通信又会变成无状态的了。
不过由于直接这样传会不安全,所以 JWT 会将整个信息经过加密处理,转化成字符串的形式。
JWT 长什么样
整个 JWT 大概是这样的

它是一串很长的字符串,中间用.分割成三个部分:
- Header(头部)
- Payload(负载)
- Signature(签名)

Header
Header 部分是 JSON 对象,描述 JWT 的元数据,通常长这样
json1{ 2 "alg": "HS256", 3 "typ": "JWT" 4}
alg 属性表示签名的算法,typ 属性表示令牌的类型。
上面的 JSON 对象会通过 Base64URL 算法转化成字符串。
Payload
Payload 部分也是 JSON 对象,用来存放实际需要传递的数据,JWT 预设了以下官方字段:
- iss (issuer):签发人
- exp (expiration time):过期时间
- sub (subject):主题
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
除此之外,还可以定义私有字段,比如这样
json1{ 2 "sub": "1234567890", 3 "name": "John Doe", 4 "admin": true 5}
注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。
这个 JSON 对象也要使用 Base64URL 算法转成字符串。
Signature
Signature 部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥,这个密钥只有服务器知道。可以通过以下命令生成一段随机的密钥:
bash1openssl rand -base64 32
然后通过 Header 里面的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
javascript1HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret)
计算出签名后,将 Header、Payload、Signature 三个部分拼接成字符串,每个部分用.隔开,返回给用户。
Base64URL
Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。
JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法。
Nodejs 实现 JWT
下面我们使用 koa 实现 JWT,理解整个过程。首先我会在登录界面发送请求,并附上账号密码。
然后使用 koa 实现一个简易的 web 服务端,来接收这个请求,并返回 JWT。
javascript1//server.js 2import Koa from 'koa' 3const Router = require('koa-router') 4let bodyparser = require('koa-bodyparser') 5const jwt = require('jsonwebtoken') //通过这个库来生成 jwt 6const app = new Koa() 7const router = new Router() 8app.use(bodyparser()) 9 10//登录验证 11router.post('/login', async (ctx, next) => { 12 const { username, password } = ctx.request.body 13 if (username === 'admin' && password === 'admin') { 14 //生成 jwt签名,ssh 是密钥 15 const token = jwt.sign({ username, exp: Math.floor(Date.now() / 1000) + 60 * 60 }, 'ssh') 16 ctx.body = { 17 code: 200, 18 success: true, 19 data: true, 20 username, 21 token, 22 } 23 } else { 24 ctx.body = { code: 403 } 25 } 26}) 27// 验证是否有权限 28router.get('/validate', async (ctx) => { 29 let Authorization = ctx.get('authorization') 30 let [, token] = Authorization.split(' ') 31 if (token) { 32 try { 33 let r = jwt.verify(token, 'ssh') //核实 token 34 console.log(r) 35 ctx.body = { 36 code: 200, 37 username: r.username, 38 token, 39 } 40 } catch (e) { 41 ctx.status = 401 42 ctx.body = { 43 code: 401, 44 data: '没有登陆', 45 } 46 } 47 } else { 48 ctx.status = 403 49 ctx.body = { message: '你无此权限' } 50 } 51}) 52 53app.use(router.routes()).use(router.allowedMethods()) 54app.listen(3000) //监听3000端口
上面的代码中使用了 jsonwebtoken来实现令牌的生成
javascript1const token = jwt.sign({ username, exp: Math.floor(Date.now() / 1000) + 60 * 60 }, 'ssh')
生成后返回给客户端的数据是这样子的
javascript1code: 200 2data: true 3success: true 4token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNjI1NzUzODEzLCJpYXQiOjE2MjU3NTAyMTN9.RCCTU-KwuhmW7exexsx9WXtp2i0ec-ARSBCGIOdn_r0" 5username: "admin"
当用户登录时,还会进行一层权限校验,这时候客户端需要发送给服务器获取到的 token。
一般来说,token 会存到某个 store 中,由前端将其加入到请求头的Authorization中
typescript1/** 获取当前的用户 GET /api/currentUser */ 2export async function currentUser(options?: { [key: string]: any }) { 3 return request<API.CurrentUser>('/api/validate', { 4 method: 'GET', 5 headers: { 6 Authorization: `Bear ${localStorage.getItem('access-token')}`, 7 }, 8 ...(options || {}), 9 }) 10}
当服务器收到后,会经过密钥解码,取出其中的数据
javascript1 let r = jwt.verify(token, "ssh"); //核实 token 2 console.log(r);//{ username: 'admin', exp: 1625753813, iat: 1625750213 }
exp 是过期时间,而 iat 是签发时间,username 是在生成令牌时传入的用户信息。
整个过程大致就是如此。
Nest.js 实现 JWT
在 Nest 中使用 jwt 需要引入 @nestjs/jwt 这个包
bash1pnpm install @nestjs/jwt
然后在 AppModule 里引入 JwtModule:
typescript1import { Module } from '@nestjs/common' 2import { JwtModule } from '@nestjs/jwt' 3import { AppController } from './app.controller' 4import { AppService } from './app.service' 5 6@Module({ 7 imports: [ 8 JwtModule.register({ 9 secret: 'secret', 10 signOptions: { 11 expiresIn: '7d', 12 }, 13 }), 14 ], 15 controllers: [AppController], 16 providers: [AppService], 17}) 18export class AppModule {}
JwtModule 是一个动态模块,通过 register 传入 option。
然后在 controller 里注入 JwtModule 里的 JwtService:
typescript1import { Controller, Get, Inject } from '@nestjs/common' 2import { AppService } from './app.service' 3import { JwtService } from '@nestjs/jwt' 4 5@Controller() 6export class AppController { 7 constructor( 8 private readonly appService: AppService, 9 @Inject(JwtService) 10 private readonly jwtService: JwtService, 11 ) {} 12 13 @Get('/token') 14 async getToken() { 15 const newToken = await this.jwtService.sign({ name: 'John' }) 16 17 return { accessToken: newToken } 18 } 19 20 @Get('/verify') 21 async verifyToken(@Headers('authorization') authorization: string) { 22 const token = authorization.replace('Bearer ', '') 23 try { 24 const result = this.jwtService.verify(token) 25 console.log('——————🚀🚀🚀🚀🚀 —— verifyToken —— result:', result) 26 return result 27 } catch (error) { 28 throw new UnauthorizedException() 29 } 30 } 31}
现在访问http://localhost:3000/token,就可以拿到 token 了。

返回 token 时可以将它放到任意地方,body、header等都是可以的。
前端拿到 token 后,将其以Bearer <token>的形式放到Authorization字段中通过请求回传给服务端做验证。
我们用 postman 测试一下:
验证成功,拿到用户的信息了。
如果传一个错的 token 会校验失败,并且返回错误信息:

到现在为止,就基本实现了在 Nestjs 中使用 JWT 做身份验证 (Authentication )。
是不是比 session+redis 的方案更简单?
无感刷新登录状态
JWT 是有有效时间的,为了安全性考虑,往往会设置一个很短的时间,比如 30 分钟。
使用这种方式仅仅是让盗取信息者更不容易获取 token 而已。
这样做会导致一个问题:用户登录后才 30 分钟,就需要重新登录了,体验太差。
为了解决这个问题,服务端会返回两个 token:access_token 和 refresh_token。
access_token 是用来认证用户身份的,就是之前使用的 token。
refresh_token 是用来刷新 access_token 的。
流程是这样:
- 用户登录成功后,返回两个 token。
- 前端发送请求时,先检查 access_token 有效期,如果有效期过了,则使用 refresh_token 获取一个新的 access_token 。
- 用 access_token 发送请求。
- 如果 refresh_token 也过期了,则让用户需要重新登录。
下面基于 how-to-use-jwt 创建一个新的分支,并且创建一个 resource:
bash1nest generate resource user --no-spec
在 UserService 中实现一个 login 方法,当查询到用户存在时,就返回两个 token:
typescript1import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common' 2import { JwtService } from '@nestjs/jwt' 3 4@Injectable() 5export class UserService { 6 constructor( 7 @Inject(JwtService) 8 private readonly jwtService: JwtService, 9 ) {} 10 11 // mock 数据 12 db = [{ id: '123', name: 'qyx', password: '123456' }] 13 14 async login(name: string, password: string) { 15 const user = this.db.find((user) => user.name === name) 16 17 if (!user || user.password !== password) { 18 throw new HttpException('用户名或密码错误', HttpStatus.FORBIDDEN) 19 } 20 21 const accessToken = this.jwtService.sign({ name, id: user.id }, { expiresIn: '30m' }) 22 23 const refreshToken = this.jwtService.sign({ name, id: user.id }, { expiresIn: '7d' }) 24 return { accessToken, refreshToken } 25 } 26}
Controller 中的代码是传递 body 参数,并调用 Service 的代码:
typescript1import { Body, Controller, Post } from '@nestjs/common' 2import { UserService } from './user.service' 3import { LoginUserDto } from './LoginUserDto' 4 5@Controller('user') 6export class UserController { 7 constructor(private readonly userService: UserService) {} 8 @Post('login') 9 async login(@Body() body: LoginUserDto) { 10 return this.userService.login(body.name, body.password) 11 } 12}
用 postman 请求获得两个 token:

下面实现登录鉴权,这里使用路由守卫来对请求做用户鉴权:
bash1nest g guard login --flat --no-spec
typescript1import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common' 2import { JwtService } from '@nestjs/jwt' 3import { Observable } from 'rxjs' 4 5@Injectable() 6export class LoginGuard implements CanActivate { 7 @Inject(JwtService) 8 private jwtService: JwtService 9 10 canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> { 11 const request: Request = context.switchToHttp().getRequest() 12 13 const authorization = request.headers['authorization'] 14 15 if (!authorization) { 16 throw new UnauthorizedException('用户未登录') 17 } 18 19 try { 20 const token = authorization.split(' ')[1] 21 this.jwtService.verify(token) 22 23 return true 24 } catch (e) { 25 throw new UnauthorizedException('token 失效,请重新登录') 26 } 27 } 28}
上面的代码主要是取出 authorization header 中的 jwt token,这个就是 access_token,对它做校验。
jwt 有效就可以继续访问,否则返回 token 失效,请重新登录。
然后在 AppController 加上登录鉴权:
typescript1import { Controller, Get, Inject, UseGuards } from '@nestjs/common' 2import { AppService } from './app.service' 3import { JwtService } from '@nestjs/jwt' 4import { LoginGuard } from './login.guard' 5 6@Controller() 7@UseGuards(LoginGuard) 8export class AppController { 9 constructor( 10 private readonly appService: AppService, 11 @Inject(JwtService) 12 private readonly jwtService: JwtService, 13 ) {} 14 15 @Get() 16 getHello(): string { 17 return this.appService.getHello() 18 } 19}
现在访问 localhost:3000会提示用户未登录,因为并没有在 header 上加上 token:

用 postman 加上 token 就可以获取到接口内容了:

现在的 access_token 是 30 分钟后过期,30 分钟后就需要重新登录了。
接着实现用 refresh_token 刷新 access_token 的逻辑:
typescript1 async refreshToken(refreshToken: string) { 2 try { 3 const data = this.jwtService.verify(refreshToken); 4 const user = this.db.find((user) => user.id === data.id); 5 const accessToken = this.jwtService.sign( 6 { name: user.name, id: user.id }, 7 { expiresIn: '30m' }, 8 ); 9 return { accessToken }; 10 } catch (error) { 11 throw new UnauthorizedException('token 失效,请重新登录'); 12 } 13 }
这里的逻辑主要是取出 token 中的 user 信息,然后生成新的 access_token 返回。
在 Controller 中传递一下 refresh_token 参数:
typescript1 @Get('refreshToken') 2 async refreshToken(@Query('refresh_token') refreshToken: string) { 3 return this.userService.refreshToken(refreshToken); 4 }
接着测试一下:

带上有效的 refresh_token,能够拿到最新的 access_token 了。
这样在后端就已经实现了双 token 登录鉴权机制了。
只要 7 天内带上 refresh_token 就可以一直获得最新的 access_token 并保持登录状态。
现在还差最后一步,前端需要在 access_token 验证失败时调用 refresh_token 接口,获取并更新 access_token ,将其设置到 header 上以通过 authorization。
前端的请求库非常多,这里仅提供一下实现思路:
- 如果是基于 axios 请求,可以通过 axios 的 interceptors 实现请求和响应的拦截。在请求时设置 统一的 header,在响应时如果遇到 error 则查看是否为 token 过期,如果是则调用 refresh_token 接口更新最新的 access_token 并且重新请求。
- 如果是基于 Apollo-client 的 graphql 请求,可以通过apollo-link-token-refresh处理刷新 token 的逻辑。
总结
- JWT 方案可以让服务器重新回到无状态的情况。
- JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
- 最好不要将非常重要的信息写入 token 中。
- 为了安全性考虑,JWT 往往会设置一个较短的时效。考虑到用户体验,往往会基于 refresh_token 和 access_token 实现无感刷新登录状态的效果。
参考
enjoy!!