个人网站记录三 - Nest登录校验 Session-Cookie, Jwt

1,480 阅读13分钟

http是无状态的,每个http请求之间是无关联的。但大多情况都是需要记录会话信息的,比如记录用户登录信息以确认当前是哪个用户的请求。通常都会采用Session CookieJwt(Json Web Token)来保存会话信息,以弥补http无状态的特性。
一般简单的会话流程大致如下:

  1. 当访问需要用户登录接口时。如未登录(没有会话标识)执行第2步;如已存在标识则直接第6步
  2. 跳转或提示用户登录
  3. 用户执行登录操作,后端校验登录信息。
  4. 后端校验登录成功后,会生成一个标识(用户信息标识,比如用户id,一般都会加密处理)并返回。
  5. 前端接收到返回结果,并记录会话标识
  6. 携带上标识请求
  7. 后端校验该标识并作出相应的处理

大致的流程如上,不同方式处理的细节不同。
比如Sesssion Cookie方式通常会在后端通过session存储会话信息,并在请求时cookie携带会话信息(比如session id)。
Jwt方式一般不会在后端存储会话信息,而是直接将信息通过某种方式生成一个token,然后通过某header字段携带(比如token、Authorization等,也可以是cookies)

Session-Cookies 方式

需要在Nest使用sesssion,安装express-session即可,还可安装@types/express-session用于在编码时提供代码提示。

安装

yarn add express-session
yarn add @types/express-session -D

使用

在入口通过调用中间件的方式

// main.ts
// ...
import * as session from 'express-session';

  app.use(
    session({
      // 用于生成签名的密钥
      secret: 'lw-ech',
      // 是否强制将“未初始化”的会话保存到存储中, 默认true
      saveUninitialized: false,
      // 是否前置保存会话
      resave: false,
      // 生成cookie的名称
      name: 'suuid',
      // cookie选项配置
      cookie: {
        // 过期时间, 单位毫秒。这里设置1分钟
        maxAge: 60000,
        // cookie是否签名
        signed: false,
      },
    }),
  );

express-session有众多参数,如下。

// session options
{
  cookie: {
      // Cookie Options
      // 默认为{ path: '/', httpOnly: true, secure: false, maxAge: null }
       /** maxAge: 设置给定过期时间的毫秒数(date)
      * expires: 设定一个utc过期时间,默认不设置,http>=1.1的时代请使用maxAge代替之(string)
      * path: cookie的路径(默认为/)(string)
      * domain: 设置域名,默认为当前域(String)
      * sameSite: 是否为同一站点的cookie(默认为false)(可以设置为['lax', 'none', 'none']或 true)
      * secure: 是否以https的形式发送cookie(false以http的形式。true以https的形式)true 是默认选项。 但是,它需要启用 https 的网站。 如果通过 HTTP 访问您的站点,则不会设置 cookie。 如果使用的是 secure: true,则需要在 express 中设置“trust proxy”。
      * httpOnly: 是否只以http(s)的形式发送cookie,对客户端js不可用(默认为true,也就是客户端不能以document.cookie查看cookie)
      * signed: 是否对cookie包含签名(默认为true)
      * overwrite: 是否可以覆盖先前的同名cookie(默认为true)*/
  },
    
  // 默认使用uid-safe这个库自动生成id
  genid: req => genuuid(),  
    
  // 设置会话的名字,默认为connect.sid
  name: 'value',  
  
  // 设置安全 cookies 时信任反向代理(通过在请求头中设置“X-Forwarded-Proto”)。默认未定义(boolean)
  proxy: undefined,
    
  // 是否强制保存会话,即使未被修改也要保存。默认为true
  resave: true, 
    
  // 强制在每个响应上设置会话标识符 cookie。 到期重置为原来的maxAge,重置到期倒计时。默认值为false。
  rolling: false,
    
  // 强制将“未初始化”的会话保存到存储中。 
  // 当会话是新的但未被修改时,它是未初始化的。 
  // 选择 false 对于实现登录会话、减少服务器存储使用或遵守在设置 cookie 之前需要许可的法律很有用。
  // 选择 false 还有助于解决客户端在没有会话的情况下发出多个并行请求的竞争条件。默认值为 true。
  saveUninitialized: true,
    
  // 用于生成会话签名的密钥,必须项  
  secret: 'secret',
  
  // 会话存储实例,默认为new MemoryStore 实例。
  store: new MemoryStore(),
  
  // 设置是否保存会话,默认为keep。如果选择不保存可以设置'destory'
  unset: 'keep'
}

添加登录和获取用户接口,做以下调整

  1. 在user控制器中添加了两个接口(为方便测试直接定义的get请求)
    • /user/login:登录接口。登录成功后在session中存储登录的用户id和用户名。会生成sessionid通过响应头set-cookie返给前端。
    • /user/myinfo:获取当前信息接口。express-session会对请求cookie中的携带的sessionid校验,成功了才能访问存储的session信息,sessionid过期或被修改都将校验失败。
// user.controllerr.ts
// ...
export class UserController {
  constructor(private readonly userService: UserService) {}
  // 为方便直接在浏览器测试,使用get请求
  @Get('login')
  async login(@Query() user, @Session() session) {
    const dataUser = await this.userService.findUser(user);
    session.uerid = dataUser.id;
    session.username = dataUser.username;
    return '登录成功';
  }

  @ApiOperation({ summary: '获取当前用户信息' })
  @Get('myinfo')
  async getMyInfo(@Session() session) {
    if (!session.uerid) {
      throw new HttpException(`用户未登录`, 301);
    }
    const dataUser = await this.userService.findUserById(session.uerid);
    return dataUser;
  }
  // ...
  1. 在user服务添加两个对应方法,根据用户名和密码查询用户,以及根据用户id查询用户。
// user.service.ts
// ...
export class UserService{
  // ...
  async findUser(uesr: LoginDto) {
    const dataUser = await this.usersRepository.findOne({ where: uesr });
    if (!dataUser) {
      throw new HttpException(`用户名或密码不正确`, 200);
    }
    return dataUser;
  }

  async findUserById(id: number) {
    const dataUser = await this.usersRepository.findOne({ where: { id } });
    if (!dataUser) {
      throw new HttpException(`用户不存在`, 200);
    }
    return dataUser;
  }
}

  1. 添加对应的LoginDto
// login-user.dto.ts
import { ApiProperty } from '@nestjs/swagger/dist/decorators';
import { IsNotEmpty } from 'class-validator';

export class LoginDto {
  @ApiProperty({ description: '用户名' })
  @IsNotEmpty({ message: '用户名不能为空' })
  readonly username: string;

  @ApiProperty({ description: '密码' })
  @IsNotEmpty({ message: '密码不能为空' })
  readonly password: string;
}

测试效果

当未登录时调用获取用户接口,提示未登录 image.png

执行登录,登录成功后sessonid被添加到cookie,提示登录成功
image.png

再调用获取用户接口,会携带上cookie。成功返回用户信息
image.png

前面设置的cookie存活一分钟,1分钟后cookie过期自动清除后,再调用获取用户接口则又会提示未登录。
另外当我们修改了cookie,请求也会失败! 例如执行登录后,把Cookie改了(这里把最后一个字母给删除了,可以对比下下面两截图)。请求携带被修改后的Cookie,请求会被校验失败。
image.png image.png

说明

前面通过session-cookie简单示例了保存会话信息的一种方式。案例中只在个别接口使用了session,而实际开发中需要使用会话信息的接口往往是比较多的,显然不能再按这种方式在每个接口加判断。此时可以通过中间件或者守卫的方式。

sessioncookie的某些缺点,大多数公司都是采用token的方式来校验会话。

  • app不能像浏览器那样携带上cookie,还需要通过手动设置。
  • session会存储在后端,占用后端资源。用户很多时,占用的开销是比较大的。
  • cookie有被攻击的风险。
  • ...

平时因项目情况,主要使用的是token方式,下面将介绍。上面session cookie案例只是很简单的介绍下了,还很多细节没有完善(比如退出登录,使用中间件或守卫方式抽离校验逻辑...)。如开发打算使用session cookie方式的,可查阅官方仓库了解更多用法。

Jwt(Json Web Token)方式

JWT组成

JWT由 头部(header)、有效载荷(payload)、签名(signature) 三部分组成,通过.连接:[header].[payload].[signature]

  1. header(头部): 包含两个属性 algtyp的Json,例如。
    {
      // 指定了用于生成签名的算法
      "alg": "HS256"
      // 令牌类型
      "typ": "JWT"
    }
    
    然后将该Json进行base64编码
  2. playload(载荷):存放的数据,比如用户信息,通常也是个Json个对象。标准中建议使用以下注册的声明 :
    • iss (issuer): jwt签发者
    • sub (subject): jwt所面向的用户
    • aud (audience): 接收jwt的一方
    • exp (expiration time): jwt的过期时间,这个过期时间必须要大于签发时间
    • nbf (Not Before): 定义在什么时间之前,该jwt都是不可用的.
    • iat (Issued At): jwt的签发时间
    • jti (JWT ID): jwt编号,主要用来作为一次性token,从而回避重放攻击。
    playload也是经过base64编码,所以不建议放敏感数据
  3. Signature(签名):主要由部分组成,base64后的header + base64后的palyload + secret密钥。然后用header中定义的签名,进行签名。

由于headerplayload都是通过Base64编码,可以被解密。而secret密钥只在服务端存储,可有效防止被篡改,只要密钥别被泄露了就安全。
而当服务端接收到token时,知道加密方式而又知道密钥。就可以用相同的方式得到token,然后与接收到的token做对比判断是否一致,就能校验token有效性。

安装

在node中最流行的身份验证库莫属于Passport了,而使用@nestjs/passport模块,可以很容易地将这个库与 Nest 应用程序集成。使用@nestjs/passport模块定义的守卫AuthGuard,在路由函数执行前对请求进行鉴权。
Passport采用策略模式,不同情况使用不同的策略。比如未登录前可通过账号密码校验(local策略,需安装passport-local库),登录后通过jwt校验(jwt策略passport-jwt)。
另外还得安装方便在Nest集成的用于生成JWT的@nestjs/jwt库,以及一些@types/*提示库。如下

yarn add @nestjs/passport passport passport-local @nestjs/jwt passport-jwt
yarn add @types/passport @types/passport-local @types/passport-jwt -D

配置与使用

注意:为方便快速了解Jwt的使用,会先演示在不使用Passport的情况使用Jwt的流程,再演示使用Passport的情况

新增auth模块,先创建基本3件套

npx nest g module auth
npx nest g controller auth
npx nest g service auth

auth.controller.ts

内容如下,主要两个路由函数

  • /auth/login: 执行用户名密码登录
  • /auth/userInfo:演示校验token,即如何鉴权
import {
  Body,
  Controller,
  Post,
  Headers,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly jwtService: JwtService,
  ) {}

  @Post('login')
  async login(@Body() user) {
    return this.authService.login(user);
  }

  @Post('userInfo')
  async userInfo(@Headers('token') token) {
    // 这开始 主要就是校验token是否正确
    console.log('请求头携带的token', token);
    try {
      const dtoken = this.jwtService.verify(token);
      console.log('检验token成功,如校验失败不会执行这行', dtoken);
    } catch (error) {
      console.log('校验失败,需catch一下');
      throw new UnauthorizedException();
    }
    // 到这校验token结束

    // 以下才开始下业务逻辑
    // ...
    return '最后返回业务数据';
  }
}

auth.service.ts

内容如下,就一个login方法。该方法主要以下逻辑

  1. 调用 findUser 方法。findUser 方法(原先写的)接收一个{ username , password }的对象来查询数据库,正确情况下返回用户信息。
  2. 然后将用户信息通过jwt签名后返回。
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UserService } from 'src/user/user.service';

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

  async login(user) {
    const userinfo = await this.userService.findUser(user);
    const payload = {
      sub: userinfo.id,
      username: userinfo.username,
    };
    return { token: this.jwtService.sign(payload) };
  }
}

auth.module.ts

内容如下。除基本的AuthController AuthService外, 还引入了JwtModuleUserModule模块。

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserModule } from 'src/user/user.module';

@Module({
  controllers: [AuthController],
  providers: [AuthService],
  // JwtModule.register 传入参数sercet密钥,还能传递其他参数,比如过期时间。
  // 执行后会根据传承动态返回模块
  imports: [JwtModule.register({ secret: 'lw-ech1' }), UserModule],
})
export class AuthModule {}

测试效果(不使用守卫)

请求/auth/login校验用户名和密码,成功后返回token。(前端接收到返回的token后保存在本地,后续请求携带上该token)。 请求/auth/userinfo会校验token。

/auth/login 账号错误,提示失败。
image.png

/auth/login 账号正确,返回token
image.png

/auth/userinfo 携带有效token image.png image.png

/auth/userinfo 不携带token或携带了无效的token(比如token被修改)
image.png
image.png

前面演示了在路由函数中直接使用Jwt,相信对Jwt的流程以及如何在Nest使用都有一定的了解了吧。
前演示是直接在路由函数中添加的校验token的逻辑,如仅在一个路由函数添加校验逻辑还好,但当需要校验的路由函数多了总不能每个都拷贝一下吧?
也许有人就会想到在中间件中添加校验逻辑,但需要准备一份路由名单区分需要鉴权和不需要鉴权的接口。
如需一种更加优雅的方式,那就得使用守卫了。

Passport配合守卫,Jwt登录鉴权

  1. 新增两策略文件 local.strategy.tsjwt.strategy.ts,以及constants.ts存放一些常量
// constants.ts
export const jwtConstants = {
  secret: 'lw-ech1',
};
// local.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({
      // 指定从body中取 usernameField 值字段,默认 username
      usernameField: 'username',
      // 指定从body中取 passwordField 值字段,默认 password
      passwordField: 'password',
    });
  }

  async validate(username: string, password: string) {
    const token = await this.authService.login({ username, password });
    console.log(token);
    return token;
  }
}

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

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      // 从请求的什么地方获取token,可以有多种方式
      // 1,ExtractJwt.fromAuthHeaderAsBearerToken: 从请求头的Authorization字段中获取,
      //  该字段有个固定写法 Authorization: Bearer <Token>
      // 2, ExtractJwt.fromHeader(header字段名),
      //  比如ExtractJwt.fromHeader('token')表示从请求头的token中获取
      // 3,ExtractJwt.fromBodyField(body字段名) 从请求体中的字段中获取
      // 还有其他方式,就不多介绍了。一般都惯用前两种
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      // 忽略过期
      ignoreExpiration: false,
      // 密钥
      secretOrKey: jwtConstants.secret,
    });
  }

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

  1. 修改 auth.controller.ts,主要将校验逻辑放到了守卫的策略上。
import { Request, Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller('auth')
export class AuthController {
  @UseGuards(AuthGuard('local'))
  @Post('login')
  async login(@Request() req) {
    return req.user;
  }

  @UseGuards(AuthGuard('jwt'))
  @Post('userInfo')
  async userInfo(@Request() req) {
    console.log('jwt鉴权成功返回 user:', req.user);
    return '用户信息';
  }
}

/auth/login使用local策略的守卫,/auth/userInfo使用jwt策略的守卫。
这两接口函数执行前,会先执行相应的守卫。localjwt策略流程上很相似的
local策略

  • local策略的守卫会执行LocalStrategy类(服务)的validate方法。
  • LocalStrategy继承了passport-local策略。
  • (在执行validate方法前)passport-local策略默认从body中取usernamepassword(可通过usernameFieldpasswordField选项修改取值字段,即super里传递的选项),然后作为参数传入validate方法。
  • validate方法成功执行会将返回值会填充到请求的user字段上。

jwt策略

  • jwt策略的守卫会执行JwtStrategy类(服务)的validate方法。
  • JwtStrategy继承了passport-jwt策略。
  • (在执行validate方法前)passport-jwt策略会根据super传递的选项,在请求中获取对应token,并根据选项传递的密钥进行校验。当获取到token并校验成功后,会将从token解析的playload部分作为参数传入validate方法。失败则请求会以Unauthorized失败返回。
  • validate方法成功执行会将返回值会填充到请求的user字段上。
  1. 别忘记在auth.module.ts中引入LocalStrategyJwtStrategyPassportModule
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserModule } from 'src/user/user.module';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';

@Module({
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({ secret: jwtConstants.secret }),
    UserModule,
  ],
})
export class AuthModule {}

测试过,和前面测试效果一样。这里就不再贴测试效图了。
使用守卫配合Passport,鉴权变得容易而又优雅。针对不同情况可以使用不同策略,只需在需要使用鉴权的接口上加上相应策略的守卫即可。

总结

文本介绍了如何在Nest中使用session-cookieJwt。因token不存储在服务端,只对请求时携带的token做校验。需要确认token失效,就得在playload上加过期时间,或者搞个黑白名单。后面有空再补充具体用法,以及刷新token问题。