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) | 作用 |
---|---|
domain | Cookie生效的域名 |
expires | 过期时间,整形 |
httpOnly | 是否仅能用于服务器端修改 |
maxAge | 过期时间,区别于expire:expire = new Date() + 6000 等同maxAge = 6000 |
path | Cookie的作用路径 |
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容器,提供了一整套优雅、安全的身份验证方案。
身份验证可以大致上被拆分为两个部分,身份授权和身份校验;这两个部分又可以分为三个步骤:
- 先根据关键信息(一般是登录名和密码)验证用户。
- 然后授予用户相对应权限。
- 后续的工作中则要验证签发出去的权限。
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过程基本类似,不明白的同学可以参考上面,不再赘述。