如何使用Next.js和NestJS实现双Token认证与刷新机制
Next.js与NestJS实现双Token认证与自动刷新机制的完整教程
初始化项目
我们这里使用pnpm-worksapce的形式去做代码演示
cd /Users/guojian/Desktop/project/cms/test/doubleToken pnpm init
创建工作目录apps和 pnpm-workspace.yaml配置文件
packages: - "apps/*"
前端服务Nextjs
这样我们先初始化前端工程,进入apps的目录,执行pnpx 创建nextjs项目
进入到web项目执行 npm run dev,然后访问http://localhost:3000
这样前端的初始框架就搭建完了。我们现在急需去创建NestJs项目。
后端服务Nestjs
首先我们先安装nestjs的官方cli工具
pnpm install -g @nestjs/cli
可能你会遇到这样的一个报错
如果出现这样的提示,我们只要跟着提示执行操作即可
安装完成之后我们立即执行命令
nest new service
然后进入到service启动服务项目,cd service && npm run start:dev
这里会有端口占用报错,node的开源项目一般都会使用3000端口来启动服务的。
我们去到service里面的main.ts修改一下port,修改成3001,我们再来启动一下
这个时候后端服务的基础框架搭建也完成了
实现步骤分析
前端NextJs部分
- 前端请求接口时,如果返回http的状态码为401的时候,进入拿refreshToken再次获取accessToken的环节。
- 如果refreshToken也不存在,则直接跳转到登录页面
- 封装fetch函数,对状态码等异常进行处理
- 只要返回头返回了access-token和refresh-token这两个值,则往localstorage里面保存这两个变量
后端NestJs部分
- 添加authLogin函数处理登录逻辑,校验通过后通过头部返回accessToken和refreshToken。
- 添加公共interceptor函数,对返回的数据进行格式化处理,和特殊key的判断设置为头信息返回。
- 封装全局的errorFilter,对异常错误捕获并处理。
- 封装自定义的errorException,对已知业务异常进行抛出处理
- 封装Auth守卫,对需要登录的路由进行守卫。(返回401的状态码)
下面我们一步一步来实现
首先实现NestJs部分,在这里我们着重了解的是双token刷新和后端处理的逻辑,暂未对数据库进行涉猎,所以用户这里我是写死了几个测试数据,有需要的朋友留言一下,让我看看下一个文章要分享什么内容。
Ok,废话不多说,我们直接看代码。
首先我们添加share目录,在里面可以做一些公共处理的函数。
先定义接口返回的基础结构
/** * share/lib/ApiResponse.ts */ interface IApiResponse<T> { status: number; code: number; data: T; msg?: string; } export class ApiResponse { static success: <T>(data: T) => IApiResponse<T> = (data) => { return { status: 1, code: 0, data: data, }; }; static fail: (code?: number, msg?: string, data?: any) => IApiResponse<any> = (code = 1, msg = '', data = {}) => { return { status: 0, code, msg, data: data, }; }; }
接着完成完成自定义error
/** * share/error/index.ts */ // 这里我们集成了Error类,然后增加一些自定义返回。 export class CustomBaseException extends Error { constructor( private readonly response: string | Record<string, any>, private readonly status: number, ) { super(); this.initMessage(); } public initMessage() { if (isString(this.response)) { if (typeof this.response === 'string') { this.message = this.response; } } else if ( isObject(this.response) && isString((this.response as Record<string, any>).message) ) { this.message = (this.response as Record<string, any>).message; } } public getResponse(): string | object { return this.response; } public getStatus(): number { return this.status; } public getBusinessCode() { if (isString(this.response)) { return this.status; } return this.response?.code || this.status; } public static createBody( objectOrError: object | string, message?: string, statusCode?: number, ) { if (!objectOrError) { return { statusCode, message }; } return isObject(objectOrError) && !Array.isArray(objectOrError) ? objectOrError : { statusCode, message: objectOrError, error: message }; } } export class CommonException extends CustomBaseException { constructor(message: any, code?: number, meta?: any) { super( CustomBaseException.createBody( { message, ...meta }, message, code || 400, ), code || 400, ); } } // 所有的报错都统一在这个类的静态函数处理。 export class CustomException { static authException = () => { return new CommonException('请登录', 401, { code: 40100 }); }; static forbitionException = () => { return new CommonException('暂无权限', 403, { code: 40300 }); }; static badRequestException(message: string, meta?: Record<string, any>) { return new CommonException(message, 400, { code: 40001, ...meta }); } }
添加公共的返回逻辑
/** * share/lib/TransformDataInterceptor.ts */ import { CallHandler, ExecutionContext, Injectable, NestInterceptor, } from '@nestjs/common'; import { map, Observable } from 'rxjs'; import { ApiResponse } from './ApiResponse'; import { Response } from 'express'; @Injectable() export class TransformDataInterceptor implements NestInterceptor { intercept( context: ExecutionContext, next: CallHandler<any>, ): Observable<any> | Promise<Observable<any>> { const res = context.switchToHttp().getResponse<Response>(); return next.handle().pipe( map((data) => { // 对responseHeaders这个key特殊处理,变成设置头信息返回 if (data && data.responseHeaders) { Object.keys(data.responseHeaders).forEach((key) => { res.setHeader(key, data.responseHeaders[key]); }); delete data.responseHeaders; } return ApiResponse.success(data); }), ); } }
我们来添加一个公共的异常处理
/** * share/lib/AllExceptionsFilter.ts */ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, } from '@nestjs/common'; import { Response } from 'express'; import { ApiResponse } from './ApiResponse'; import { CommonException } from '../error'; @Catch() export class AllExceptionsFilter implements ExceptionFilter { catch(exception: Error, host: ArgumentsHost): any { const ctx = host.switchToHttp(); const res: Response = ctx.getResponse(); if (exception instanceof CommonException) { // 如果是自定义的异常 return res .status(exception.getStatus()) .send( ApiResponse.fail( exception.getBusinessCode(), exception.message, exception.getResponse(), ) as unknown as string, ); } if (exception instanceof HttpException) { // http异常 return res .status(exception.getStatus()) .send( ApiResponse.fail( exception.getStatus(), exception.message, exception.getResponse(), ) as unknown as string, ); } // 无法预知的异常 res .status(500) .send( ApiResponse.fail(50000, exception.message, { error: exception.stack }), ); } }
添加share.module.ts,在里面处理公共的切面逻辑。
/** * share/share.module.ts */ import { Module } from '@nestjs/common'; import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; import { TransformDataInterceptor } from './lib/TransformDataInterceptor'; import { AllExceptionsFilter } from './lib/AllExceptionsFilter'; @Module({ providers: [ { provide: APP_FILTER, useClass: AllExceptionsFilter, }, { provide: APP_INTERCEPTOR, useClass: TransformDataInterceptor, }, ], }) export class ShareModule {}
然后添加到app.module.ts里面
/** * app.module.ts */ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ShareModule } from './share/share.module'; @Module({ imports: [ ShareModule, ], controllers: [AppController], providers: [AppService], }) export class AppModule {}
我们这就来处理业务了,添加一个auth控制器,里面会有两个路由(/auth/login和/auth/refreshToken)
import { Body, Controller, Get, Post, Req } from '@nestjs/common'; import { AuthService } from './auth.service'; import { UserEntity } from './entity/user.entity'; import { Request } from 'express'; @Controller('auth') export class AuthController { constructor(private authService: AuthService) {} @Post('login') login(@Body() body: UserEntity) { return this.authService.login(body); } @Get('refreshToken') refreshToken(@Req() req: Request) { const refreshToken = req.headers['refresh-token'] || ''; return this.authService.refreshToken(refreshToken as string); } }
然后我们就要去实现authService类了,里面处理真正的业务逻辑,代码如下所示,我们要使用JWT(JSON Web Token)所以我们要pnpm install --save @nestjs/jwt 然后我们在app.module.ts里面引入这个模块
/** * auth/auth.service.ts */ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { UserEntity } from './entity/user.entity'; import { CustomException } from '../share/error'; // mock user const users: UserEntity[] = [ { id: 1, username: 'admin@qq.com', password: '123456', deleteAt: null, }, { id: 2, username: '424139777@qq.com', password: '123456', deleteAt: new Date(), }, { id: 3, username: '', password: '123456', deleteAt: null, }, ]; @Injectable() export class AuthService { constructor(private readonly jwtService: JwtService) {} login(user: UserEntity) { // 判断用户是否合法 const findIndex = users.findIndex( (item) => item.username === user.username && item.deleteAt === null, ); if (findIndex < 0) { throw CustomException.badRequestException('账号不存在', { username: '账号不存在', }); } if (users[findIndex].password !== user.password) { throw CustomException.badRequestException('密码错误', { username: '密码错误', }); } const tokens = this._generateToken(users[findIndex]); return { responseHeaders: { ...tokens, }, }; } _generateToken(user: UserEntity) { const payload = { userId: user.id, }; const accessToken = this.jwtService.sign(payload); const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d', }); return { 'Access-Token': accessToken, 'Refresh-Token': refreshToken, }; } async refreshToken(refreshToken: string) { const { userId } = this.jwtService.verify(refreshToken); const user = users.find( (item) => item.id == userId && item.deleteAt === null, ); if (user) { const tokens = this._generateToken(user); return { responseHeaders: { ...tokens, }, success: true, }; } return { success: false, }; } }
/** * app.module.ts */ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthController } from './auth/auth.controller'; import { AuthService } from './auth/auth.service'; import { ShareModule } from './share/share.module'; import { JwtModule } from '@nestjs/jwt'; @Module({ imports: [ ShareModule, JwtModule.registerAsync({ global: true, useFactory: () => { return { secret: 'testasdasdads', signOptions: { expiresIn: 60 * 60 * 0.5, //半小时 }, }; }, }), ], controllers: [AppController, AuthController], providers: [AppService, AuthService], }) export class AppModule {}
然后我们需要添加一下我们的路由守卫,随便给两个路由添加上,然后就可以去写前端的代码了
/** * auth/guard/authGuard.ts */ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Request } from 'express'; import { CustomException } from '../../share/error'; import { JwtService } from '@nestjs/jwt'; @Injectable() export class AuthGuard implements CanActivate { constructor(private jwtService: JwtService) {} async canActivate(context: ExecutionContext): Promise<boolean> { const req = context.switchToHttp().getRequest<Request>(); const headerAuthorization = req.headers.authorization || ''; const bearer = headerAuthorization.split(' ')[1]; if (!bearer) throw CustomException.authException(); try { this.jwtService.verify(bearer); // 这里可以做黑白名单做限制,回源库查找用户是否禁用等 } catch (e) { throw CustomException.authException(); } return true; } }
我们往app.controller.ts里面的路由添加守卫
/** * app.controller.ts */ import { Controller, Get, UseGuards } from '@nestjs/common'; import { AppService } from './app.service'; import { AuthGuard } from './auth/guard/authGuard'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() @UseGuards(AuthGuard) getHello(): string { return this.appService.getHello(); } @Get('test') @UseGuards(AuthGuard) getHelloTest(): { list: string[] } { return { list: ['a', 'b', 'c'], }; } }
我们开始做前端部分
首先我们先封装service部分,自己封装一下fetch函数添加一些接口定义和servic
/** * src/services/ownFetch.ts */ import {isBrowser} from "@/lib/utils"; import {refreshTokenAction} from "@/services/index"; export const baseUrl = process.env.BASE_URL || 'http://127.0.0.1:3001' export const onwFetch = (url: string, config: ApiManager.TResponse = {}) => { // @ts-ignore return new Promise>(async (resolve, reject) => { try { let _url = new URL(url, baseUrl); if (config.queryCache === undefined || config.queryCache) { _url.searchParams.append('__T', Date.now() + ''); delete config.queryCache; } if (isBrowser()) { const token = localStorage.getItem('accessToken') || ''; config.headers = config.headers || {}; if (token) { // @ts-ignore config.headers['Authorization'] = `Bearer ${token}`; } } const _config = {...config} // config.headers. 为了sse 做的预留 const isStream = config.fetchEventStream ?? false; if (isStream) { delete config.fetchEventStream; } const res = await fetch(_url, { ...config }); // 200的情况 if (res.ok) { const accessToken = res.headers.get('access-token') || '' const refreshToken = res.headers.get('refresh-token') || '' if(accessToken){ window.localStorage.setItem('accessToken',accessToken) } if(refreshToken){ window.localStorage.setItem('refreshToken',refreshToken) } resolve(res); return; } if(res.status===401){ // 尝试登录 if(await refreshTokenAction.refreshTokenService()){ _config.headers = _config.headers || {}; const token = localStorage.getItem('access-token') ||''; if(token){ // @ts-ignore _config.headers['Authorization'] = `Bearer ${token}`; } resolve(await onwFetch(_url as unknown as string,{..._config}) as any) } } //非200的情况 const data = (await res.json()) as any; if (!isStream) { resolve({ response: res, error: true, json: async () => { return data; } } as any); return; } reject(res); } catch (e) { reject(e); } }); };
/** * src/services/index.ts */ import {onwFetch} from "@/services/ownFetch"; export const loginService = (username:string,password:string)=>{ return onwFetch('/auth/login',{ method:'post', body:JSON.stringify({ username, password }), headers:{ 'content-type':"application/json", }, }) } export const getListService = ()=>{ return onwFetch<{data:string}>('/') } export const getTestListService = ()=>{ return onwFetch<{list:string[]}>('/test') } export const refreshTokenAction:{ refreshTokenServiceInstance:Promise<boolean> | undefined refreshTokenService:()=>Promise<boolean> retryIndex:number maxRetry:number } = { refreshTokenServiceInstance:undefined, retryIndex:0, maxRetry:10, refreshTokenService:function(){ if(this.refreshTokenServiceInstance){ return this.refreshTokenServiceInstance } this.refreshTokenServiceInstance = new Promise((resolve)=>{ const refreshToken = localStorage.getItem('refreshToken'); if (!refreshToken && window.location.pathname !== '/login') { window.location.href = '/login'; return; } onwFetch<{success:boolean}>('/auth/refreshToken',{ headers:{ 'refresh-token': refreshToken || '' } }).finally(()=>{ this.refreshTokenServiceInstance = undefined }).then(async res=>{ const json = await res.json() if(json.data.success){ this.retryIndex=0 resolve(true) }else{ if(this.retryIndex<this.maxRetry){ this.retryIndex++; setTimeout(()=>{ resolve(true) },this.retryIndex*60) }else{ this.retryIndex=0 resolve(false) } } }).catch(e=>{ this.retryIndex++; resolve(false) }) }) return this.refreshTokenServiceInstance } }
然后我们先做一个登录页,如下图所示
我们来看一下代码
/** * src/login/page.tsx */ import React from "react"; import {LoginFormPage} from "@/app/login/_component/loginPage"; const LoginForm = () => { return <div className={'h-screen w-full flex flex-col justify-center items-center'}> <LoginFormPage/> </div> } export default LoginForm
## 添加shadcn/ui cd web pnpm dlx shadcn-ui@latest add form pnpm dlx shadcn-ui@latest add button pnpm dlx shadcn-ui@latest add card pnpm dlx shadcn-ui@latest add input pnpm dlx shadcn-ui@latest add label
这个时候我们可以去测试accessToken失效和refreshToken有用的情况下是否自动刷新accessToken并正确返回
/** * src/app/page.tsx */ import {HomePageView} from "@/app/_home/homePageView"; export default function Home() { return ( <main className="flex min-h-screen flex-col items-center justify-between p-24"> <HomePageView /> </main> ); }
代码如下所示
'use client' /** * stc/_home/homePageView.tsx */ import {useEffect, useState} from "react"; import {getListService, getTestListService} from "@/services"; export const HomePageView = ()=>{ const [data, setData] = useState<{data:string}>() const [testData, setTestData] = useState<{list:string[]}>() const [fetch, setFetch] = useState<string | ''>('') useEffect(() => { if(fetch === '') return async function getList (){ const res = await getListService(); const json = await res.json(); setData(json.data) } async function getTestList (){ const res = await getTestListService(); const json = await res.json(); setTestData(json.data) } void getList(); void getTestList(); }, [fetch]); return <div> <div onClick={()=>{ setFetch(new Date()+"") }}>测试接口</div> <div>{JSON.stringify(data)}</div> <div>{JSON.stringify(testData)}</div> </div> }
发送请求的时候发现跨域了,我们现在来解决一下接口跨域的问题,NestJs自带cors模块,我们启用一下即可,然后允许一下响应头
/** * src/main.ts */ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableCors({ exposedHeaders: ['access-token', 'refresh-token'], }); await app.listen(3001); } void bootstrap();
最后补充一下,如果是用nginx做反向代理的话,可能由于jwt生成access-token过长,会拒绝请求,这个时候你需要去修改nginx的配置
# For more information on configuration, see: # * Official English Documentation: http://nginx.org/en/docs/ # * Official Russian Documentation: http://nginx.org/ru/docs/ user nginx; worker_processes auto; error_log /var/log/nginx/error.log; pid /run/nginx.pid; # Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. include /usr/share/nginx/modules/*.conf; events { worker_connections 1024; } http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; types_hash_max_size 4096; proxy_buffers 8 8k; # 把body相关的缓冲大小扩大一倍 proxy_busy_buffers_size 32k; # busy的buffer保持最小. 与 proxy_buffer_size 值保持一致 proxy_buffer_size 32k; # 最大可以存储32k的header include /etc/nginx/mime.types; default_type application/octet-stream; # Load modular configuration files from the /etc/nginx/conf.d directory. # See http://nginx.org/en/docs/ngx_core_module.html#include # for more information. include /etc/nginx/conf.d/*.conf; server { listen 80; listen [::]:80; server_name _; root /usr/share/nginx/html; # Load configuration files for the default server block. include /etc/nginx/default.d/*.conf; error_page 404 /404.html; location = /40x.html { } error_page 500 502 503 504 /50x.html; location = /50x.html { } } # Settings for a TLS enabled server. # # server { # listen 443 ssl http2; # listen [::]:443 ssl http2; # server_name _; # root /usr/share/nginx/html; # # ssl_certificate "/etc/pki/nginx/server.crt"; # ssl_certificate_key "/etc/pki/nginx/private/server.key"; # ssl_session_cache shared:SSL:1m; # ssl_session_timeout 10m; # ssl_ciphers PROFILE=SYSTEM; # ssl_prefer_server_ciphers on; # # # Load configuration files for the default server block. # include /etc/nginx/default.d/*.conf; # # error_page 404 /404.html; # location = /40x.html { # } # # error_page 500 502 503 504 /50x.html; # location = /50x.html { # } # } } # 主要是这三个 # proxy_buffers 8 8k; # 把body相关的缓冲大小扩大一倍 # proxy_busy_buffers_size 32k; # busy的buffer保持最小. 与 proxy_buffer_size 值保持一致 # proxy_buffer_size 32k; # 最大可以存储32k的header
项目github地址:refreshToken