如何使用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