如何使用Next.js和NestJS实现双Token认证与刷新机制

147
2024-05-31 19:14
1 个月前

Next.jsNestJS实现双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部分

  1. 前端请求接口时,如果返回http的状态码为401的时候,进入拿refreshToken再次获取accessToken的环节。
  2. 如果refreshToken也不存在,则直接跳转到登录页面
  3. 封装fetch函数,对状态码等异常进行处理
  4. 只要返回头返回了access-token和refresh-token这两个值,则往localstorage里面保存这两个变量

后端NestJs部分

  1. 添加authLogin函数处理登录逻辑,校验通过后通过头部返回accessToken和refreshToken。
  2. 添加公共interceptor函数,对返回的数据进行格式化处理,和特殊key的判断设置为头信息返回。
  3. 封装全局的errorFilter,对异常错误捕获并处理。
  4. 封装自定义的errorException,对已知业务异常进行抛出处理
  5. 封装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