Nest的状态与身份

2,220 阅读10分钟

Cookie

Cookie是最为常用的客户端数据方案,其本身被设计出来就是用于保存客户的状态信息,每次访问网站,发送请求时都会被带上,自动发送给服务器端。

前文已经有所提及,Nest支持两套Web框架,不同的底层,所以,与之对应也有两套Cookie的支持组件。Express使用cookie parser,Fastify使用fastify-cookie,他们底层都是用的cookie

npm i -s cookie-parser # Express
npm i -s fastify-cookie # Fastify
npm i -D @types/cookie-parser

在main.ts中引入相关的组件,以Express为例(Fastify被注释):

import * as cookieParser from 'cookie-parser';
// import fastifyCookie from 'fastify-cookie';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(cookieParser());
  await app.listen(3000);
}
bootstrap();

基本使用

以Express为例,读写Cookie

  @Get('setCookie')
  setCookie(@Res({ passthrough: true }) res: Response): string {
    const options = {
      expires: new Date(Date.now() + 900000),
      httpOnly: true,
    };
    res.cookie('name', 'django', options);
    return `Setting cookie test`;
  }

  @Get('getCookie')
  getCookie(@Req() req: Request): string {
    this.logger.log(req.cookies['name']);
    return `Getting cookie test.`;
  }

访问/setCookie 后会得到

关于第三个参数,是一个选项对象,最重要的有以下这些:

键(key)作用
domainCookie生效的域名
expires过期时间,整形
httpOnly是否仅能用于服务器端修改
maxAge过期时间,区别于expire:expire = new Date() + 6000等同maxAge = 6000
pathCookie的作用路径
secure仅能在HTTPS下传输,建议在生产环境中设置为true
signed签名

中间件方式

Nest实质上就是一个IoC容器,利用其本身架构特性,利用中间件方式更优雅地实现对Cookie的操作。定义一个参数装饰器:

const GetCookie = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return data ? request.cookie?.['name'] : request.cookie;
  },
);

const SetCookie = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const response = ctx.switchToHttp().getResponse();
    const options = {
      expires: new Date(Date.now() + 900000),
      httpOnly: true,
    };
    return (val) => {
      response.cookie(data, val, options);
    };
  },
);

稍加改动先前的代码:

  @Get('getCookie')
  getCookie(@GetCookie('name') name): string {
    this.logger.log(name);
    return `Getting cookie test.`;
  }

  @Get('setCookie')
  setCookie(@SetCookie('name') setName): string {
    setName('django');
    this.logger.log('set the name of cookie django');
    return `Setting cookie test`;
  }

Session

相对于存储在客户端的Cookie,Session是存储在服务器内存中,以HTTP通信中的SessionId为索引的状态存储方案;两者另外一个明显的差别是,Cookie不会因为客户访问量增大而占用更多的占用服务器内存,但是Session是会占用更多的内存(没有安装其他扩展中间件的默认情况下)。需要注意,因为存储在服务器的内存中(默认),随着访问用户的积累,服务器内存消耗会越来越多,造成内存泄漏。包括Express官方建议,除非是用于开发和测试,不建议使用在生产环境中。除非是重新设计了session的中间件,改用诸如数据库、缓存做专门的持久化服务,具体可以参考express-session的存储插件

这里我们仅以Express框架对Session作了解。

npm i --save express-session

先安装express的session组件,在main.ts中直接使用

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser';
import * as session from 'express-session';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(cookieParser());
  app.use(
    session({
      secret: 'my-secret',
      resave: false,
      saveUninitialized: false,
    }),
  );
  await app.listen(3000);
}
bootstrap();

session的初始化参数对象支持以下字段:

  • cookie:Object 设置一个session ID的cookie对象,参考Cookie的设置
  • genid:Function 定义一个生成session ID的策略(一个方法)
  • name:string 设置在客户端Cookie的名称,默认是connect.sid
  • proxy?:boolean
    • true:X-Forwarded-Proto(XFP)标记在header中
    • false:忽略所有头部标签,并仅使用安全连接;
    • undefined:信任代理(默认)
  • resave:boolean 不管有无改变内容,都会在请求时重写入内存。根据官方的文档,目前默认值为true,不过未来会改为false
  • rolling:boolean 强制在每次响应设置session ID,并按照maxAge的参数重新设置过期。
  • saveUninitialized:boolean 在没有初始化的情况下,是否保存内容。
  • secret:string|Array 用于签发session ID,如果是数组,则第一个元素用于签发,其他用于验证。
  • store 存储实例,可以对接其他中间件,例如可以使用数据库存储session内容。
  • unset:string
    • destory 对话结束后销毁
    • keep 存储区中的绘画倍保留,但是请求期间的修改会被忽略。

设置session的初始化之后就可以很方便的使用了。

//  import {Session} from '@nestjs/common';
  @Get('getSession')
  getSession(@Session() session: Record<string, any>): string {
    session.visits = session.visits ? session.visits + 1 : 1;
    this.logger.log(session.visits);
    return 'done';
  }

身份验证

实际上用上面提到的Cookie和Session的技术,已经可以完成身份验证的所有工作了。但如果是这么简单,那么我们还要Nest干嘛,Nest的IoC容器,提供了一整套优雅、安全的身份验证方案。

身份验证可以大致上被拆分为两个部分,身份授权和身份校验;这两个部分又可以分为三个步骤:

  1. 先根据关键信息(一般是登录名和密码)验证用户。
  2. 然后授予用户相对应权限。
  3. 后续的工作中则要验证签发出去的权限。

Passport

NEST集成了号称最流行的Passport组件(18.3Kstar)。在高层及中完成了上述的一系列身份的授权操作。并在底层开放了授权的策略方式(又是一种IoC)。

npm install --save @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local

其中,passport-local 或者 passport-jwt 都属于一种策略包。以LocalStrategy为例,主要以用户名+密码的方式进行验证。

Passport local

依照Nest的架构,我们需要依次建立两个服务和模块,授权和用户,在项目中执行:

nest g mo auth
nest g s auth
nest g mo users
nest g s users

其依赖关系如下,在这里我先挖一个坑,如果有同学希望详细了解Nest的IoC容器架构下的模块、服务、控制器和数据传输类型的同学,我另外再写一篇。现在这里简单的说一下,在Nest中,各个功能以“模块”(module)划分。相互之间的依赖关系也是在模块中Module的装饰器中设置。应用程序必须有一个根模块,就是app.module。其参数主要有4类:

  • providers :Nest的injector的装饰器自动对其进行实例化操作,也可以被用于跨模块之间共享;
  • controllers:一组控制器;
  • imports:本模块中依赖的其他模块;
  • exports:其他模块需要引入本模块的部分(或者全部)模块;

这个例子中,模块相互之间的结构

以上这个结构图,自上向下逐个创建程序文件(顺着依赖关系)。提示:可以使用Nest命令行来创建程序文件,自动会采用最佳实践(在适当的目录中),如果不太了解可以参考先前一篇文章。先是users.service,源码如下:

import { Injectable } from '@nestjs/common';
export type User = { userId: number; userName: string; password: string };

@Injectable()
export class UsersService {
  // 定义一组用户信息(一般来说都会来自于数据库,所以findOne函数用了异步方式)
  private readonly users = [
    { userId: 1, userName: 'a', password: 'abc' },
    { userId: 2, userName: 'b', password: 'abc' },
  ];
  
  // 以用户名查找用户对象
  async findOne(userName: string): Promise<User | undefined> {
    return this.users.find((user) => user.userName === userName);
  }
}

users.module 则是用于集成users方面的所有程序文件,目前只有上面一个,源码如下:

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

接着编写授权服务,auth.service

import { Injectable } from '@nestjs/common';
import { UsersService } from 'src/users/users.service';

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UsersService) {}

  async validate(userName: string, password: string): Promise<any> {
    const user = await this.usersService.findOne(userName);
    // 密码校验通过后,返回对象本身
    if (user && user.password === password) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
}

auth.service需要注入到LocalStrategy类,文件名local.strategy.ts,实现PassportStrategy:

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }
  // 具体的验证方法
  // 注意,这里个坑:即便是有大小写的参数名,但是JSON提交的参数都是小写
  async validate(username: string, password: string): Promise<any> {
    return await this.authService.validate(userName, password);
  }
}

auth.module 模块,需要将UserModule导入,AuthService和LocalStrategy作为功能提供者

import { Module } from '@nestjs/common';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';

@Module({
  imports: [UsersModule],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}

验证守卫local-auth.guard,以local策略实现验证守卫,默认情况下没有返回用户对象都会产生一个UnauthorizedException异常,这样控制器中逻辑就无法继续了,稍加改动后:

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  handleRequest(err, user) {
    if (err) {
      throw err;
    }
    if (!user) {
      // 登录失败
      console.log('登录失败');
      return null;
    }
    return { ...user, tag: 'local' };
  }
}

app.controller控制器中使用

import { Controller, Logger, Req, Post, UseGuards } from '@nestjs/common';
import { AppService } from './app.service';
import { LocalAuthGuard } from './auth/local-auth.guard';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  logger = new Logger('root');

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Req() req) {
    req.user && this.logger.log(`${req.user.tag}验证通过`);
    return req.user ? `登录成功,欢迎${req.user.userName}~` : '登录失败';
  }
}

测试脚本:(留意参数的大小写)

curl --location --request POST 'localhost:3000/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "a",
    "password": "abc"
}'
# 登录成功,欢迎a~

到这里,我们重新梳理一下验证过程,其中蓝色过程是需要开发者编写具体业务的过程:

Passport JWT

签发JWT

Json Web Token 是一种应用于大量调用API(也可以是其他web资源)的身份验证的技术。具体可以参考阮一峰老师的文章。Nest和Passport集成了这方面的功能组件,安装:

npm install --save @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt

@nestjs/jwt是一个用于操作JWT的工具类(详细用法参考这里),passport-jwt则是Passport的策略之一(同local)。

基于上面已经完成的代码,我们稍微改动一下,在auth.service重加入登录方法:

import { JwtService } from '@nestjs/jwt';
// ...
  async sign(user: any) {
    return {
      access_token: this.jwtService.sign(user),
    };
  }
// ...

JWT是需要配置,对签发令牌的有效期以及密文(对称加密)执行签发算法的,所以创建一个constant.ts配置文件

export const jwtConfig = {
  secret: 'some_complex_cryptogram',
  signOptions: { expiresIn: '60m' },
};

稍微修改一下auth.module,主要是两个地方,第一个是引入了jwtModule,第二是我需要在根控制器下使用,所以还需要把AuthService分享给其他模块使用,代码如下:

import { Module } from '@nestjs/common';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from './constants';
@Module({
  imports: [UsersModule, JwtModule.register(jwtConfig)],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService],
})
export class AuthModule {}

最后,稍微修改一下登录控制器

// ...
  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Req() req) {
    req.user && this.logger.log(`${req.user.tag}验证通过`);
    // return req.user ? `登录成功,欢迎${req.user.userName}~` : '登录失败';
    if (req.user) return this.authService.sign(req.user);
    else throw new UnauthorizedException();
  }
// ...

执行登录命令

curl --location --request POST 'localhost:3000/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "a",
    "password": "abc"
}'
# {"access_token":"eyJhbGciOiJIUzI1NiI..."}

至此,签发JWT的工作已经完成了。

验证JWT

local策略一样,先创建一个jwt.strategy策略程序

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConfig } from './constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      // 指明jwt是来自于请求的哪部分(Header的Bearer)
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      // 是否忽略过期时间,true:令牌永久有效;
      ignoreExpiration: false,
      // 密文
      secretOrKey: jwtConfig.secret,
    });
  }

  async validate(payload: any) {
    return { userId: payload.userId, userName: payload.userName};
  }
}

在构造函数中,调用父类@nestjs/passport中PassportStrategy的构造函数的参数有很多,具体参考这里。并将这个策略交给auth.module模块托管,成为功能组之一。

@Module({
  imports: [UsersModule, JwtModule.register(jwtConfig)],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

创建一个jwt守卫 jwt-auth.guard.ts:(与local-auth.guard极度相似)

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  handleRequest(err, user) {
    if (err) {
      throw err;
    }
    if (!user) {
      // 登录失败
      console.log('jwt验证失败');
      return null;
    }
    return { ...user, tag: 'jwt' };
  }
}

运行脚本调试(先运行签发得到令牌,再运行验证)

curl --location --request POST 'localhost:3000/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "a",
    "password": "abc"
}'
# {"access_token":"eyJhbGciOiJIUzI1NiI..."}  <-- 复制这个部分,贴到下面的 Bearer 后面
curl --location --request GET 'localhost:3000/profile' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiI...' # <-- 上面的贴过来,注意:Bearer不能动,后面有一个空格。

使用Postman等工具调试等同学,需要在请求头中加入自定义项目,Key为Authorization,Value为Bearer ${token}。注意,其中有一个空格。

JWT的验证过程与Local过程基本类似,不明白的同学可以参考上面,不再赘述。