后端的“破接口”,我用NestJS中间层处理(1)——登录鉴权

903 阅读16分钟

前言

来到现在这家公司前,我也算是快4年经验的前端开发了,Vue/React双持的熟练工,PC/移动端/小程序多个项目的0-1开发经验,外加现在AI工具加持,总觉得应付日常开发不成问题。

可谁曾想,入职的头一天,clone项目看了一眼,也可能是我有点“代码洁癖”,我一度怀疑我是不是没睡醒

小柴柴揉眼睛动画.gif

洗把脸回来再看了几遍,我把项目里奇怪的地方总结了一下

所有请求都传到同一个ip地址,不区分path

所有传参用JSON.stringify拼接在一块儿

用户所有信息明文携带在各个请求的参数里面

长列表数据不做分页,一次性返回

请求成功/失败时候,返回的数据格式不统一

给我整不会了.jpeg

长相一致的请求

项目运行后,相信有些小伙伴会打开控制台看看接口,但我第一次神奇地发现,怎么这个项目的接口名全是ip地址啊?这都谁跟谁啊?

全部传递到ip的接口.png

不是,这不用path区分的吗?对于我这个前端来说,这debug多费劲啊……

最开始时候,我能在有的页面看到十几个同一ip发送的接口,现在找不到这种页面了,但是超过5个都长“一张脸”的接口,每次debug都要点开看参数才知道谁是谁,是不是也太累了点……

诡异的传参拼接

行吧,长一样的接口,我勉强也能接受,就debug时候点开看看参数区分一下吧

但是当我点开请求想查看传参的时候,我发现事情没我想得这么简单……

很长而且用string拼接的参数.png

不是,这所有参数都JSON.stringify,debug的时候眼睛不累的吗?咱就是说不必要连传参都搞这么“防御性”吧

明文传递的用户信息

再看一遍接口的stringify参数,我傻眼了,居然有一个完整user字段,把用户的名字部门工号卡号入职日期等等全都明文展示出来了

明文展示的用户信息.png

这,真的,没问题吗?安全吗?

而且退一步说,有必要每次都带这么多信息去判断用户状态吗?

吃惊表情包1.jpeg

长列表数据一次性返回

OK,上面的传参和明文用户信息我也忍了,没准人设计有一定道理呢

但是我又发现,只要是表格/列表渲染,页面就莫名其妙很卡很卡,要么加载好久,要么直接卡死

打开控制台的一瞬间,我傻了

让系统卡死的接口.png

1分钟多的一个返回数据256MB的接口?这怎么写的?

当我换了其他的列表页面,打开控制台,又看到了这个

600多项的列表.png

有证据了,跟后端battle,结果他说他一开始就没!设!计!分!页!,甚至还嘴角上扬起来“这数据不需要做分页啊”

无语表情包.gif

冗余数据嵌套

刚才这个分页问题里面,如果细心,应该也发现,真实的数据都包裹在内部内部

层层嵌套的返回数据.png

那就是说我列表渲染我还得给你从list.properties.XXX取值咯?

我一寻思,不能传参也是这样的吧,一看代码,得,又给我说中了,这参数真的套路深啊

传参的包裹.png

区分不了的提交

虽然我知道小公司会没办法注意一些规范,但没想到,git上面的提交记录,后端居然每次都写个1

全是1的提交记录.png

大大的疑惑表情包.webp

最后的挣扎

最后,抱着一丝侥幸心理(希望这些都不是真的),我在项目中找了一下接口相关的代码,发现,还真是JSON.stringify + 明文用户信息,而且确实不区分path直接传递到ip

接口拼接用户数据和传参的证明.png

完犊子,实锤了……

其他问题

当然还不止以上这些问题,还有一些细碎的是我开发过程中发现的,诸如:

  • 返回数据格式不统一(成功时候数据返回在result,失败时候却返回在trace,有的数据包裹在XXXObject多层嵌套,有的没有)
  • 使用非自增字段作为数据库表的主键(主键的设定有时候会听业务人员要求,这个我大为震惊)
  • 返回的数据都是string没有number
  • 不支持变量驼峰命名
  • 不可以传递null给后端

当然,有些问题可能只是个人习惯,有些吧,估计也是后端懒,有的我就不是很懂了(一只半道出家的前端崽)

但是这种感觉……真的超影响开发体验的好吗?

解决方案

OK,吐槽归吐槽,活儿还是要干的嘛。就在我头疼这后端给的“破接口”以后怎么用的时候,灵机一动,先想了一个方案:

在前端项目中封装一个函数,结合axios拦截器实现数据清洗

这个方案一开始觉得可行,但是写了一会儿,就觉得这个方法会有几个问题:

  1. 页面端查看接口的时候,依然还是ip+长字符串的参数,还是不方便debug接口问题
  2. 项目耦合,如果后续有多个项目,我得在各个前端项目中重复写好几遍
  3. 数据的处理比较繁琐,毕竟还有不同的接口状态返回格式(成功/失败),以及不同接口返回数据的格式

node中间层

axios拦截器这条路走不通了,我想了想,毕竟后端问题大头是数据嘛,那我在前后端中间做个桥梁不就好了,这个桥梁只要满足

  • 转发我请求的参数给后端(前端按照RESTful),转换数据格式
  • 把后端返回的数据做清洗
  • 后端需要的用户数据,加密传递,到中间层后再解密给后端

中间层的作用.png

以前在企业项目中,在一次后端还没给接口的时候,写个Koa2 + Sequelize + MySQL替代一下mockjs,至少玩个真实的数据库和接口的交互嘛。

之前的某次面试中,我也主动提到了这个经历。当时面试官眉头一皱:你是写了个中间层还是接口

当时我还没有接触过中间层的概念,直到这个时候,我突然意识到,这个中间层不就是我要的吗

作为前端崽,中间层也只会用nodejs写,于是node中间层的方案就这么定了下来

中间层技术选型

node的框架主流的包括

  • Express
  • Koa2
  • NestJS

最后我选择了NestJS搭建中间层,主要考虑了它丰富的工具(管道守卫拦截器等,当然这也是双刃剑),层次分明,条理清晰

当然,也有自己的一点小私心,就是多多使用一些新工具~

提交记录规范

提交记录规范这个我一直使用type:(scope)<subject>这一套,类似于这个commit规范

学习Sunday老师(栋哥)的课程后,知道了cz-config这个自动化工具,直接在项目里配置了一套,可以参考中后台解决方案学习心得这篇文章

项目搭建

项目直接使用NestJS脚手架@nestjs/cli创建

nest new 项目名称

脚手架自动生成文件

NestJS脚手架不只是生成项目,还非常方便地提供了一些方法,可以使用nest g XXX自动生成文件

我在项目中常用的包括

生成指定名称的controller/service/module/interceptor文件(最基础)

nest g controller 路径/模块名
nest g service 路径/模块名
nest g module 路径/模块名
nest g interceptor 路径/模块名

不生成test测试文件

nest g controller/service/module 路径/模块名 --no-spec

干生成dry

nest g controller/service/module 路径/模块名 -d

这个超好用,可以模拟语句实际生成的效果,告知用户执行命令后创建/更新哪些文件,并不实际创建文件

dry生成.png

扁平化生成文件flat

nest g itc 路径/模块名 --flat

这个我一般用在指定目录下直接生成interceptor拦截器,而非再次创建目录

flat生成区别.png

可以看出文件名都是创建路径path最后一部分(haha),唯一区别是文件的创建路径

  • 有flat:剔除路径path最后一部分,在这个目录下创建文件
  • 没有flat:完整路径下,创建文件

文件名简称

可以用nest --help查看所有nest脚手架支持创建的文件,其中alias这一列就是文件名简称

例如:nest g controller可以简写成nest g co

其他脚手架命令.png

环境配置

项目中肯定会涉及一些通用的环境变量,而在开发环境生产环境可能还有区别,因此需要做一个环境配置

这里使用了NestJS官方的@nestjs/config,本质上是对dotenv的封装

netjs/config的npm说明.png

可以在项目根目录下添加若干.env文件,区分环境

env文件.png

为了让全局都能读取到配置项,要添加forRoot并开启isGlobal属性,结合cross-env区分不同环境

// app.module.ts

// 默认读取.env.development
const envFilePath = [`.env.${process.env.NODE_ENV || 'development'}`, '.env'];

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath,
    }),
    ......
  ],
  controllers: [],
  providers: [],
})
// package.json
"scripts": {
    ......
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "cross-env NODE_ENV=production nest start --watch",
}

这样一来就可以在各个文件中使用configService.get读取配置了

但是考虑到扩展性维护性,把配置项的key用常量做封装

// config.enum.ts
export const BASE_URL = 'BASE_URL';
// attendance.service.ts
@Injectable()
export class AttendanceService {
  private readonly service;
  constructor(private readonly configService: ConfigService) {
    this.service = createService(this.configService.get<string>(BASE_URL));
  }
}

项目架构

面向切面编程AOP

我们知道前端常用的MVVM框架Vue/React都有生命周期,这一生命周期其实可以和页面的渲染过程对应上

例如

beforeCreate:浏览器加载DOM,初步解析

created/mounted:浏览器渲染页面,挂载DOM

updated:DOM更新

unmounted:页面卸载,DOM销毁

Vue3生命周期.png

而这些渲染过程的节点,我们都可以叫做切面,可以理解成某个时间节点

我们可以选择在这些时间节点上做一些事情,也可以日后移除

而这些添加/删除操作的过程,不会影响整个页面渲染流程

切面的核心就在于,保留了流程的完整性,同时还提供了扩展性

和前端项目类似,NestJS写接口的时候,也要考虑代码可维护性复用性,主要使用了面向切面编程思想AOP(Aspect-Oriented Programming)

从大体上看,接口发送和接收数据的过程涉及到

  • 两端
    • 客户端
    • 服务端
  • 三层(服务端)
    • Controller:接收请求/返回数据
    • Service:数据库数据的CURD操作
    • Data Access:详细的数据库数据

nestjs核心概念.jpg

图片选自慕课网Brain老师的NestJS 入门到实战 前端必学服务端新趋势

在此基础上,nestjs基于切面做了更细的处理

  • 请求发出
    • 守卫:经典的例如登录守卫
    • 前置拦截器
    • 管道:可以校验请求参数是否符合要求
  • 服务器返回数据
    • 后置拦截器:可对接口返回的数据进行清洗调整
    • 过滤器:异常/错误数据处理

这样一来,用户对接口就可以有更精细的控制,同时这种控制也不影响主流程,可以随时添加/删除/扩展

nestjs生命周期.jpg

图片选自慕课网Brain老师的NestJS 入门到实战 前端必学服务端新趋势

项目功能

针对于我自己遇到的后端接口的问题,整个NestJS中间层主要做了以下几个事情

  1. 登录鉴权:解决之前后端接口对用户数据的明文传输
  2. 返回数据格式转换:解决返回数据嵌套过深、数据格式没有转换、缺少驼峰命名等问题
  3. 假分页:性能上优化不了,在返回给前端的数据上尽量精简

登录鉴权

我们知道HTTP作为一个无状态的协议,每一次发送到服务端的请求,服务端是没办法直接根据请求判断用户登录状态的,所以主流的方法是在请求里携带一些信息一并传递给服务端

常用的包括

Cookie + Session:其中Cookie存储在客户端Session存储在服务端

JWTJson Web Token,请求头Header中携带认证信息

其中JWT是目前比较主流的跨域认证方案

  • 包括了Header头部Payload载荷Signature签名三个部分
  • 可以设置一个过期时间,在有效期内可以无限次使用
  • 可以在Payload中携带一些信息,通过解密才能获取

JWT组成部分和官网截图.png

可以在Json Web Token官网上看到对于其详细的介绍

JWT流程

JWT使用过程比较流程化,大致为

  • 做登录接口(用户名 + 密码)
  • 在登录成功的时候,根据用户信息+密钥+过期时间,生成Token,添加在登录接口的返回信息中
  • 其他接口请求的时候,需要在请求头上带上Token
  • 服务端对接收到的请求做Token的验证
    • 通过,返回对应数据
    • 未通过,返回401UnAuthorized

JWT大致思路.png

在官网的身份验证章节可以看到整个JWT的实现流程

登录基础流程实现

在实现JWT之前,我们先需要把登录流程给做了,创建一个User Module,包含ControllerService

// user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}
// user.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UserService } from './user.service';
import { LoginResponseDto } from './dto';

@ApiTags('user 用户')
@Controller('user')
export class UserController {
  constructor(private userService: UserService) {}

  @Post('login')
  @ApiOperation({
    summary: '用户登录',
    description: '用户登录,获取登录用的token令牌',
  })
  @ApiBody({ description: '登录信息(用户名、密码)', type: UserDto })
  @ApiResponse({
    status: 200,
    description: '登录成功',
    type: LoginResponseDto,
  })
  async login(@Body() user: UserDto) {
    const result = await this.userService.login(user);

    return result;
  }
}

因为是转发给后端接口,所以Service层中只是遵循了后端接口的要求拼接参数,生成axios的post请求

// user.service.ts
import { Injectable } from '@nestjs/common';
import { UserDto } from './dto';
import { ConfigService } from '@nestjs/config';
import { createService } from 'src/utils/service';
import { HR_BASE_URL } from 'src/enum/config.enum';

@Injectable()
export class UserService {
  private readonly service;
  constructor(private readonly configService: ConfigService) {
    this.service = createService(this.configService.get<string>(HR_BASE_URL));
  }

  async login(user: UserDto) {
    return this.service({
      url: '/login',
      method: 'post',
      data: {
        fun: 'HmrLogin',
        param: {
          userName: user.username,
          password: user.password,
        },
      },
    });
  }
}

到这里已经可以正常完成登录请求了,发送用户名密码到/login接口,可以正常返回用户信息

用户登录接口基本返回.png

后置拦截登录信息

接下来就是要在用户登录成功的情况下,把JWT生成并添加在返回数据中,这个过程应该发生在中间层拿到后端数据准备返回给客户端之前(下图红框标记)

JWT添加的时机.png

从前面NestJS提供的切面控制工具来看,这一操作应该使用后置拦截器Interceptor最合适

User Module中创建一个Token Interceptor

// token.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Observable, map } from 'rxjs';
import { HR_AUTH_EXPIRE_TIME } from 'src/enum';
import { AuthService } from 'src/modules/auth/auth.service';
import { addTimeToTimestamp } from 'src/utils/moment';

@Injectable()
export class TokenInterceptor implements NestInterceptor {
  constructor(
    private readonly authService: AuthService,
    private readonly configService: ConfigService,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((res) => {
        const {
          data: { resFlag, result },
        } = res;
        if (resFlag === 0) {
          const { id, type } = result;
          if (result && id && type) {
            // 1. 使用id和type作为payload生成token
            const authExpireTime =
              this.configService.get<string>(HR_AUTH_EXPIRE_TIME);
            const token = this.authService.certificate(
              { id, type },
              authExpireTime,
            );
            // 2. 生成token过期时间
            const expireTime = addTimeToTimestamp(authExpireTime);
            Object.assign(result, { token, expireTime });
          }
        }
        return res;
      }),
    );
  }
}

添加为User Controller全局后置拦截器(这里有点嫌设置单个路由拦截器写的太多了,偷懒了一点)

// user.controller.ts
import { UseInterceptors } from '@nestjs/common';
import { TokenInterceptor } from './interceptors/token.interceptor';

@UseInterceptors(TokenInterceptor)
export class UserController {
  ......
}

封装JWT生成逻辑

Token Interceptor里面,实现了两个事情

  • Token生成:根据id + type(后端要求必须携带的用户信息)作为payload,以及配置好的过期时间生成
  • expireTime生成:这个专门提供给前端的,在超时的时候不必通过接口的401结果再退出登录,可以在项目启动的时候直接返回登录页

考虑到JWT可能以后会扩展到多个模块(不同系统的登录用不同的密钥/逻辑等),单独封装一个Auth模块处理JWT鉴权

Service层中添加生成Token的方法certificate

// auth.service.ts
import { Injectable } from '@nestjs/common';
import { AuthInfoDto } from './dto';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(private readonly jwtService: JwtService) {}

  certificate(user: AuthInfoDto, expireTime?: string) {
    const token = this.jwtService.sign(user, { expiresIn: expireTime });
    return token;
  }
}

Auth Module中需要设置好JWT的密钥加密算法等配置项

此外,为了在其他模块中使用Auth Service,需要在Auth Module导出,后续其他模块直接导入整个Auth Module即可

// auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SECRET } from 'src/enum';

@Module({
  imports: [
    // 这里因为密钥放在配置项,所以需要导入ConfigService配置
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        return {
          secret: configService.get<string>(SECRET),
          signOptions: {
            algorithm: 'HS256',
          },
        };
      },
    }),
  ],
  providers: [AuthService],
  exports: [AuthService],
})
export class AuthModule {}

这样之后,我们再请求/login登录接口,就可以返回Token过期时间

接口请求返回token.png

Token信息的验证

Token生成好了,下一步就是我们带入验证了,思路上很简单

  • 使用密钥解密Token
    • 正常解密:把payload解析出来,放到request.user参数中
    • 过了有效期/不能解密:返回401Unauthorized

在官网的通行证(认证)章节可以看到完整的实现流程

这一部分NestJS官方其实基本封装好了,我们只需要按照要求搭建

首先安装@nestjs/passportpassport两个包,另外因为我们用的JWT,所以还要安装passport-jwt

npm i @nestjs/passport passport passport-jwt

然后创建一个Strategy文件,起名为JwtStrategy

// auth.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SECRET } from 'src/enum';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(protected configService: ConfigService) {
    super({
      // 从请求头的Authorization的Bearer取值
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      // 验证过期时间,过期的话校验失败
      ignoreExpiration: false,
      // 校验用的密钥
      secretOrKey: configService.get<string>(SECRET),
    });
  }
  
  // 如果JWT通过校验,validate方法会自动执行,其中参数就是payload
  async validate(payload: any) {
    return payload;
  }
}

最后把这个Strategy放到Auth Moduleprividers里即可

// auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtStrategy } from './auth.strategy';

@Module({
  ......
  providers: [AuthService, JwtStrategy],
  ......
})
export class AuthModule {}

完成了这一套操作之后,我们只需要在需要鉴权的路由上添加守卫Guard,使用UseGuards,就可以实现JWT登录鉴权了,payload解析出来的数据会出现在request.user

import {
  Controller,
  Post,
  Req,
  UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Post('test')
@UseGuards(AuthGuard('jwt'))
async test(@Req() request: any) {
  console.log(request.user);
}

为了方便后面使用,可以把这个守卫单独封装成一个JwtGuard

// jwt.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
  constructor() {
    super();
  }
}

此时对于添加了JwtGuard的请求,只要在请求头的Authorization中正确添加之前返回的Token,就可以在request.user拿到payload信息了

这些操作实际上都是JwtModule里的PassportModule实现的

payload数据返回.png

OK,这样的话登录鉴权部分算是做完了,拿到的Payload也可以直接按照后端原来的接口格式丢过去

// user.controller.ts
export class UserController {
  constructor(private userService: UserService) {}

  @Put('password/change')
  @UseGuards(JwtGuard)
  async changePassword(
    @Req() request: any,
    @Body() passwordInfo: PasswordInfoDto,
  ) {
    const result = await this.userService.changePassword(
      passwordInfo,
      request.user,
    );

    return result;
  }
}
// user.service.ts
import { Injectable } from '@nestjs/common';
import { PasswordInfoDto, UserDto } from './dto';
import { ConfigService } from '@nestjs/config';
import { createService } from 'src/utils/service';
import { HR_BASE_URL } from 'src/enum/config.enum';
import { AuthInfoDto } from 'src/modules/auth/dto';

@Injectable()
export class UserService {
  private readonly service;
  constructor(private readonly configService: ConfigService) {
    this.service = createService(this.configService.get<string>(HR_BASE_URL));
  }

  async changePassword(passwordInfo: PasswordInfoDto, userInfo: AuthInfoDto) {
    const { oldPassword, newPassword, confirmPassword } = passwordInfo;

    return this.service({
      url: '/password/update',
      method: 'post',
      data: {
        fun: 'HmrChangePassword',
        user: userInfo,
        param: {
          oldPassWord: oldPassword,
          newPassWord: newPassword,
          newCheckPassWord: confirmPassword,
        },
      },
    });
  }
}

结尾

到这里,原来后端接口里面的明文用户信息的问题,就基本上解决了

当然,接口的问题还不止于此,下一篇,就开始处理最头疼的问题:数据格式转换