前言

  • 本篇利用上一节jwt+local策略+passport的知识结合redis制作单点登录。

原理

  • 主要利用jwt每次生成token不一样,再次登录时覆盖redis的键使得验证不通过。
  • passport-jwt守卫可以对已发出的jwt token进行验证,如果验证成功,再去redis上对比下即可完成单点登录。

流程

  • 首先nest new一个项目出来。
  • 同时安装typeorm mysql
npm install --save @nestjs/typeorm typeorm mysql
 npm i --save @nestjs/config  class-transformer class-validator
  • 安装passport 一套策略
$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local
$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt
  • 首先创建个数据库,然后typeorm链接它:
@Module({
  imports: [
    ConfigModule.forRoot({
      load: [configuration],
    }),
    TypeOrmModule.forRoot(),
  ],
  controllers: [AppController],
  providers: [AppService],
})

config:

export default () => ({
  type: process.env.DB_TYPE,
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
  database: process.env.DB_DATABASE,
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  logging: true,
  jwtSecret: process.env.JWT_TOKEN,
});

ormconfig:

module.exports = [
  {
    name: 'default',
    type: process.env.DB_TYPE,
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    database: process.env.DB_DATABASE,
    username: process.env.DB_USERNAME,
    password: process.env.DB_PASSWORD,
    logging: false,
    synchronize: true,
    entities: ['dist/src/**/*.entity.{ts,js}'],
    migrations: ['src/migration/*.ts'],
    subscribers: ['src/subscriber/**/*.ts'],
    cli: {
      entitiesDir: 'src/',
      migrationsDir: 'src/migration',
      subscribersDir: 'src/subscriber',
    },
  },
];
  • 能连接Ok后,下面需要弄点数据,制作实体。
  • 首先制作user的实体,主要是用户密码,生成user的模块服务控制器一套:
import { Exclude, Expose } from 'class-transformer';
import {
  Column,
  CreateDateColumn,
  DeepPartial,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity({ name: 'user' })
export class UserEntity {
  @PrimaryGeneratedColumn({
    type: 'int',
    name: 'id',
    comment: '主键id',
  })
  id: number;

  @Column({
    type: 'varchar',
    nullable: false,
    length: 50,
    unique: true,
    name: 'username',
    comment: '用户名',
  })
  username: string;

  @Exclude() // 排除返回字段,不返回给前端
  @Column({
    type: 'varchar',
    nullable: false,
    length: 100,
    comment: '密码',
  })
  password: string;

  @Column('tinyint', {
    nullable: false,
    default: () => 0,
    name: 'is_del',
    comment: '是否删除,1表示删除,0表示正常',
  })
  isDel: number;

  @CreateDateColumn({
    type: 'timestamp',
    nullable: false,
    name: 'created_at',
    comment: '创建时间',
  })
  createdAt: Date;

  @UpdateDateColumn({
    type: 'timestamp',
    nullable: false,
    name: 'updated_at',
    comment: '更新时间',
  })
  updateAt: Date;

  @Expose()
  isDelStr(): string {
    return this.isDel ? '删除' : '正常';
  }
}

export type UserEntityDataType = DeepPartial<UserEntity>;
  • 导入它
imports: [TypeOrmModule.forFeature([UserEntity])],
  • 然后需要制作个register来生成用户,login来登录用户。
  • 先制作user服务,创建和查询
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity, UserEntityDataType } from './user.entity';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepository: Repository<UserEntity>,
  ) {}

   async createUser(data: UserEntityDataType): Promise<UserEntity> {
    const user = await this.userRepository.save(data); //这里密码以后需要加密
    const res = plainToClass(UserEntity, user); //只有变为实例才可以生效class-transformer的装饰器效果
    return res;
  }

  async findOne(username: string): Promise<UserEntity> {
    return await this.userRepository.findOne({ where: { username: username } });
  }

  //这个验证会交给passport-local
  async validateUser(
    username: string,
    pass: string,
  ): Promise<UserEntity | null> {
    const user = await this.findOne(username);
    if (user && user.password === pass) {
      //这里密码以后需要加密,user.password是加密后的密码,pass也进行加密,看是否相等
      const result = user;
      return result;
    }
    return null;
  }

  //通过守卫后进入login 到时候交给jwt服务返回token
  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return payload;
  }
}
  • 其实就是typeorm module里存了各种你定义的实体,声明下即可链接,然后拿到connection使用。
  • 然后需要控制器调用服务:
@Post('/register')
  async register(@Body() req) {
    const data: UserEntityDataType = {
      username: req.username,
      password: req.password,
    };
    console.log(data);
    return await this.userSrv.createUser(data);
  }
  • 然后需要制作dto校验(其实我个人认为这个dto整的有点繁琐)。
nest g pi pipes/validation/validation
import {
  ArgumentMetadata,
  Injectable,
  PipeTransform,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';

@Injectable()
export class MYValidationPipe implements PipeTransform {
  async transform(value: any, metadata: ArgumentMetadata) {
    const { metatype } = metadata;
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      const msg = Object.values(errors[0].constraints)[0];
      throw new HttpException({ message: msg }, HttpStatus.OK);
    }
    return value;
  }

  private toValidate(metatype: any): boolean {
    const types = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}
  • 全局使用
app.useGlobalPipes(new MYValidationPipe());
  • 编写dto:
import { IsNotEmpty, IsString } from 'class-validator';

export class CreateUserDto {
  @IsString({ message: '用户名必须为字符串类型' })
  @IsNotEmpty({ message: '用户名不能为空' })
  username: string;

  @IsString({ message: '密码必须为字符串类型' })
  @IsNotEmpty({ message: '密码不能为空' })
  password: string;
}
  • 导入使用:
async register(@Body() req :CreateUserDto) {
    const data: UserEntityDataType = {
      username: req.username,
      password: req.password,
    };
    console.log(data);
    return await this.userSrv.createUser(data);
  }
  • 验证输入空用户注册,如果返回预期则ok 。
  • 下面制作登录,登录需要返回jwt token 以及验证用户名密码
  • 梳理下流程,首先是未登录状态走login ,会进入local策略,验证用户密码,如果密码ok ,交给jwt服务,分发token。当访问要权限的接口时,进入jwt策略验签,成功验签则继续。单点登录时,走jwt服务时需要用户和token传给redis,而访问权限接口时,进行验签,验签通过则检测redis的token和传来是否一致,不一致则表明用户不是单点登录状态。
  • 首先先不考虑redis,直接将passport策略完成。
  • local策略需要制作local.strategy以及local.guard。

strategy:

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

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private userSrv: UserService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.userSrv.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

guards:

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

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
  • 这样就有了个local策略的守卫,将其装饰到登录接口上:
@UseGuards(LocalAuthGuard)
  @Post('/login')
  async login(@Request() req) {
    //local策略会返回user实例,这个返回是自己写的,
    ///验证通过后去拿user实例的id 去jwt加密
    return await this.userSrv.login(req.user);
  }
  • 别忘记在模块中提供local服务。
  • 此时测试符合预期即可。下面制作jwt策略。
  • 老样子,一个策略加一个守卫,加密需要用到jwt服务,所以要注入module:
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import configuration from '../../config/database.config';
import 'dotenv/config';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configuration().jwtSecret,
    });
  }

  async validate(payload: any) {
    console.log(payload);
    return 'hello';
  }
}
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
JwtModule.register({
      secret: configuration().jwtSecret,
      signOptions: { expiresIn: '60s' },
    }),
  • 修改通过login的逻辑,通过Local后,交给jwt服务加密:
//通过守卫后进入login 到时候交给jwt服务返回token
  async login(user: UserEntity) {
    const payload = { username: user.username, id: user.id };
    const token = await this.jwtService.sign(payload);
    return {
      access_token: token,
    };
  }
  • 这样可以测试下,符合预期就ok。
  • 下面整redis,整个windows版本凑合用https://github.com/microsoftarchive/redis/releases/tag/win-3.0.504
  • 可视化链接工具:
  • 链接:https://pan.baidu.com/s/1jBjxXvrwJsdRIFLW_PJnsQ
    提取码:xnu1
  • 连接本机redis无问题就ok
  • 下面安装几个库:
npm install nestjs-redis ioredis
  • 生成其模块及服务:
>nest g mo redisUtils
>nest g s redisUtils
  • 注册为全局模块:
import { Module, Global } from '@nestjs/common';
import { RedisModule } from 'nestjs-redis';
import { RedisUtilsService } from './redis-utils.service';

@Global()
@Module({
  imports: [
    RedisModule.register({
      port: 6379,
      host: '127.0.0.1',
      password: '',
      db: 0,
    }),
  ],
  providers: [RedisUtilsService],
  exports: [RedisUtilsService],
})
export class RedisUtilsModule {}
  • 封装服务:
import { Injectable } from '@nestjs/common';
import { RedisService } from 'nestjs-redis';
import { Redis } from 'ioredis';

@Injectable()
export class RedisUtilsService {
  public client: Redis;
  constructor(private redisService: RedisService) {}

  onModuleInit(): void {
    this.getClient();
  }

  public getClient(): void {
    this.client = this.redisService.getClient();
  }

  public async set(
    key: string,
    value: { [propsName: string]: any } | string,
    second?: number,
  ): Promise<'OK'> {
    value = JSON.stringify(value);
    if (!second) {
      return await this.client.setex(key, 24 * 60 * 60, value);
    } else {
      return await this.client.set(key, value, 'EX', second);
    }
  }

  public async get(key: string): Promise<any> {
    const data = await this.client.get(key);
    if (data) {
      return JSON.parse(data);
    } else {
      return null;
    }
  }

  public async del(key: string): Promise<number> {
    return await this.client.del(key);
  }

  public async flushall(): Promise<'OK'> {
    return await this.client.flushall();
  }
}
  • 然后在redis 加密时存入redis :
//通过守卫后进入login 到时候交给jwt服务返回token
  async login(user: UserEntity) {
    const payload = { username: user.username, id: user.id };
    const token = await this.jwtService.sign(payload);
    //存入redis
    const redisData = {
      token,
      user,
    };
    this.redisSrv.set(String(user.id), redisData);
    return {
      access_token: token,
    };
  }
  • 修改jwt验签后流程,检查token是否一致:
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import configuration from '../../config/database.config';
import 'dotenv/config';
import { RedisUtilsService } from 'src/redis-utils/redis-utils.service';
import { Request } from 'express';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private redisSrv: RedisUtilsService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configuration().jwtSecret,
      passReqToCallback: true,
    });
  }

  async validate(req: Request, payload: any) {
    //payload为token解码后内容,能过来说明已验签成功,不管是不是多点登录
    const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
    const redisData = await this.redisSrv.get(payload.id);
    //确认token身份, 查redis即可,因为写入redis时查了库
    if (redisData.user.username !== payload.username) {
      throw new UnauthorizedException('invalid token');
    }
    //对比token,看是否一致,不一致说明多点登录了
    if (token !== redisData.token) {
      throw new UnauthorizedException('you have logged in another place');
    }
    return payload;
  }
}
  • 制作get接口测试:
@UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
  • 此时可以测试下,post生成token,开个接口放上jwt守卫,用第一次token能过,然后post第二个token,此时用旧token会提示你已别处登录。新token可以正常登录。

swagger文档

  • 基本上前面就已经实现完成了,最后用swagger做个接口文档:
npm install --save @nestjs/swagger swagger-ui-express
  • nest官方对swagger的文档位置:https://docs.nestjs.com/openapi/introduction
  • 中文文档这方面比英文要详细:https://docs.nestjs.cn/7/introduction
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
const PREFIX = 'nest-passport-sso-demo';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new MYValidationPipe());

  const options = new DocumentBuilder()
    .setTitle('nest framework  api文档')
    .setDescription('nest framework  api接口文档')
    .addBearerAuth({ type: 'apiKey', in: 'header', name: 'Authorization' })
    .setVersion('0.0.1')
    .build();

  const document = SwaggerModule.createDocument(app, options);
  SwaggerModule.setup(`${PREFIX}`, app, document); //这里是swagger的路径
  console.log('3000', 'ok');
  await app.listen(3000);
}
bootstrap();
  • prefix即为swagger的路径,启动后可以访问即ok。
  • 控制器中加入说明:
@ApiTags('用户登录')
@Controller('user')
  • 这个会在该控制器接口上方来个大字。
  • dto加属性:
export class CreateUserDto {
  @ApiProperty()
  @IsString({ message: '用户名必须为字符串类型' })
  @IsNotEmpty({ message: '用户名不能为空' })
  username: string;
  @ApiProperty()
  @IsString({ message: '密码必须为字符串类型' })
  @IsNotEmpty({ message: '密码不能为空' })
  password: string;
}
  • 然后这么使用:
@ApiOperation({
    summary: '用户登录',
    description: '用户名和密码登录',
  })
  @ApiBearerAuth()
  @ApiBody({ type: CreateUserDto })
  @UseGuards(LocalAuthGuard)
  @Post('/login')
  async login(@Request() req) {
    //local策略会返回user实例,这个返回是自己写的,
    ///验证通过后去拿user实例的id 去jwt加密
    return await this.userSrv.login(req.user);
  }
  @ApiOperation({
    summary: '用户注册',
    description: '用户名和密码注册',
  })
  @Post('/register')
  async register(@Body() req: CreateUserDto) {
    const data: UserEntityDataType = {
      username: req.username,
      password: req.password,
    };
    return await this.userSrv.createUser(data);
  }

  @ApiOperation({
    summary: '测试接口',
    description: '需求权限的测试接口',
  })
  @ApiBearerAuth()
  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
  • 本文代码地址:https://github.com/yehuozhili/nest-passport-sso-demo