从零到一搭建nest版若依框架(二)jwt单点登录

623 阅读7分钟

mysql+typerom+redis+jwt 实现鉴权单点登录

本小节代码建立在上一章的代码基础之上

目录结构

  • 需要新建以下几个目录结构

1720059733456.png

创建user模块

之后创建个 user 的 CRUD 模块:

 nest g resource modules/system/user

user.entity.ts

新建user实体文件

/modules/system/user/entities/user.entity.ts

import { IsNumberIsOptionalIsString } from 'class-validator';
import { ColumnEntityPrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class User {
  /* 用户Id */
  @PrimaryGeneratedColumn({
    name'user_id',
    comment'用户ID',
  })
  @IsNumber()
  userIdnumber;

  /* 用户账号 */
  @Column({
    name'user_name',
    comment'用户账号',
    length30,
  })
  @IsString()
  userNamestring;

  /* 用户昵称 */
  @Column({
    name'nick_name',
    comment'用户昵称',
    length30,
  })
  @IsString()
  nickNamestring;

  /* 用户类型 */
  @Column({
    name'user_type',
    comment'用户类型(00系统用户)',
    length2,
    default'00',
  })
  @IsOptional()
  @IsString()
  userType?: string;

  /* 用户邮箱 */
  @Column({
    comment'用户邮箱',
    length50,
    defaultnull,
  })
  @IsOptional()
  @IsString()
  email?: string;

  /* 手机号码 */
  @Column({
    comment'手机号码',
    length11,
    defaultnull,
  })
  @IsOptional()
  @IsString()
  phonenumber?: string;

  @Column({
    comment'用户性别(0男 1女 2未知)',
    type'char',
    length1,
    default'0',
  })
  @IsOptional()
  @IsString()
  sexstring;

  /* 头像地址 */
  @Column({
    comment'头像地址',
    length100,
    default'',
  })
  @IsOptional()
  @IsString()
  avatar?: string;

  /* 密码 */
  @Column({
    comment'密码',
    length100,
    default'',
    selectfalse,
  })
  @IsString()
  passwordstring;

  @Column({
    comment'盐加密',
    length100,
    default'',
    selectfalse,
  })
  saltstring;

  /* 帐号状态 */
  @Column({
    comment'帐号状态(0正常 1停用)',
    type'char',
    length1,
    default'0',
  })
  @IsString()
  @IsString()
  statusstring;

  @Column({
    name'del_flag',
    comment'删除标志(0代表存在 2代表删除)',
    type'char',
    length1,
    default'0',
  })
  delFlagstring;

  /* 最后登录IP */
  @Column({
    name'login_ip',
    comment'最后登录IP',
    length128,
    default'',
  })
  @IsOptional()
  @IsString()
  loginIp?: string;

  /* 最后登录时间 */
  @Column({
    name'login_date',
    comment'最后登录时间',
    defaultnull,
  })
  @IsOptional()
  @IsString()
  loginDate?: Date;
}

req-user.dto.ts

import { OmitTypePartialType } from '@nestjs/swagger';
import { User } from '../entities/user.entity';

/* 新增用户 */
export class ReqAddUserDto extends OmitType(User, ['userId'as const) {}

user.module.ts

import { GlobalModule } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
@Global()
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

user.controller.ts

import { BodyController } from '@nestjs/common';
import { UserService } from './user.service';
import * as reqUsrDto from './dto/req-user.dto';
import { Post } from '@nestjs/common';

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

  // 新增用户
  @Post('add')
  add(@Body() ReqAddUserDto: reqUsrDto.ReqAddUserDto) {
    return this.userService.add(ReqAddUserDto);
  }
}

user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import * as reqUsrDto from './dto/req-user.dto';
import { DataObj } from 'src/common/class/data_obj.class';
import { SharedService } from 'src/shared/shared.service';
@Injectable()
export class UserService {
  constructor(
    private readonly sharedService: SharedService,
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  async add(ReqAddUserDto: reqUsrDto.ReqAddUserDto) {
    const newUser = this.userRepository.create();
    newUser.userName = ReqAddUserDto.userName;
    newUser.nickName = ReqAddUserDto.nickName;
    newUser.password = ReqAddUserDto.password;
    newUser.sex = ReqAddUserDto.sex;
    newUser.status = ReqAddUserDto.status;
    newUser.salt = this.sharedService.generateUUID();
    return DataObj.create(await this.userRepository.save(newUser));
  }

  async findOneByUsername(username: string) {
    const user = await this.userRepository
      .createQueryBuilder('user')
      .select('user.userId')
      .addSelect('user.userName')
      .addSelect('user.password')
      .addSelect('user.salt')
      .where({
        userName: username,
        delFlag'0',
        status'0',
      })
      .getOne();

    return user;
  }
}
  • 现在我们使用apifox调用接口 新增用户

d299ec5f5eb5cd91e87ad4bb71d6ffa.png

  • 可以看到数据库用户表新增了一条数据

1720064211315.png

鉴权守卫

Passport JWT 策略

/modules/system/auth/strateties/jwt-auth.stratety.ts

import { ExecutionContextInjectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'node_modules/rxjs/dist/types';
import { PUBLIC_KEY } from 'src/common/contants/decorator.contant';
import { ApiException } from 'src/common/exceptions/api.exception';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }
  canActivate(
    contextExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    // getHandler 值将覆盖 getClass上面的值
    const noInterception = this.reflector.getAllAndOverride(PUBLIC_KEY, [
      context.getClass(),
      context.getHandler(),
    ]);

    if (noInterception) return true;
    return super.canActivate(context);
  }

  /* 主动处理错误 */
  handleRequest(err, user, info) {
    console.log(err, 'err');

    if (err || !user) {
      throw err || new ApiException('登录状态已过期'401);
    }
    return user;
  }
}

Passport LOCAL 策略

/modules/system/auth/strateties/local-auth.stratety.ts

/*
 * @Description: 登录守卫 ,可进行登录日志记录
 */
import { ExecutionContextInjectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { ApiException } from '../exceptions/api.exception';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  constructor() {
    super();
  }
  contextExecutionContext;
  canActivate(
    contextExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    this.context = context;
    return super.canActivate(context);
  }

  /* 主动处理错误,进行日志记录 */
  handleRequest(err, user, info) {
    if (err || !user) {
      const request = this.context.switchToHttp().getRequest();
      request.user = user;
      throw err || new ApiException(err);
    }
    return user;
  }
}

public.decorator.ts

// 不进行jwt鉴权
import { SetMetadata } from '@nestjs/common';
import { PUBLIC_KEY } from '../contants/decorator.contant';
export const Public = () => SetMetadata(PUBLIC_KEYtrue);

jwt-auth.guard.ts

import { ExecutionContextInjectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'node_modules/rxjs/dist/types';
import { PUBLIC_KEY } from 'src/common/contants/decorator.contant';
import { ApiException } from 'src/common/exceptions/api.exception';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }
  canActivate(
    contextExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    // getHandler 值将覆盖 getClass上面的值
    const noInterception = this.reflector.getAllAndOverride(PUBLIC_KEY, [
      context.getClass(),
      context.getHandler(),
    ]);

    if (noInterception) return true;
    return super.canActivate(context);
  }

  /* 主动处理错误 */
  handleRequest(err, user, info) {
    console.log(err, 'err');

    if (err || !user) {
      throw err || new ApiException('登录状态已过期'401);
    }
    return user;
  }
}

local-auth.guard.ts

/*
 * @Description: 登录守卫 ,可进行登录日志记录
 */
import { ExecutionContextInjectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { ApiException } from '../exceptions/api.exception';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  constructor() {
    super();
  }
  contextExecutionContext;
  canActivate(
    contextExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    this.context = context;
    return super.canActivate(context);
  }

  /* 主动处理错误,进行日志记录 */
  handleRequest(err, user, info) {
    if (err || !user) {
      const request = this.context.switchToHttp().getRequest();
      request.user = user;
      throw err || new ApiException(err);
    }
    return user;
  }
}

auth.constants.ts

export const jwtConstants = {
  secret'xiaoqi66',
};

auth.controller.ts

import { Controller } from '@nestjs/common';
import { AuthService } from './auth.service';

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

auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
@Module({
  imports: [PassportModule],
  controllers: [AuthController],
  providers: [AuthServiceJwtStrategyLocalStrategy],
})
export class AuthModule {}

auth.service.ts

import { Injectable } from '@nestjs/common';
import {
  USER_TOKEN_KEY,
  USER_VERSION_KEY,
  CAPTCHA_IMG_KEY,
} from 'src/common/contants/redis.contant';
import { Redis } from 'ioredis';
import { InjectRedis } from '@nestjs-modules/ioredis';
import { ApiException } from 'src/common/exceptions/api.exception';
import { UserService } from '../user/user.service';
import { SharedService } from 'src/shared/shared.service';
import { isEmpty } from 'lodash';

@Injectable()
export class AuthService {
  constructor(
    @InjectRedis()
    private readonly redis: Redis,
    private readonly userService: UserService,
    private readonly sharedService: SharedService,
  ) {}

  // 验证token
  async validateToken(userId: number, pv: number, token: string) {
    //1.查询redis中的token和版本号
    const redisToken = await this.redis.get(`${USER_TOKEN_KEY}:${userId}`);
    const redisPv = parseInt(
      await this.redis.get(`${USER_VERSION_KEY}:${userId}`),
    );
    // 2.与传入的token,pv做比较
    if (redisToken !== token) throw new ApiException('token失效'401);
    if (redisPv !== pv) throw new ApiException('用户信息已被修改'401);
    return true;
  }

  // 验证账号密码是否正确
  async validateUser(username: string, password: string) {
    // 1.根据username查找出用户信息
    const user = await this.userService.findOneByUsername(username);

    // 2.与传入的password做比较
    if (!user) throw new ApiException('用户名或密码错误');
    const comparePassword = this.sharedService.md5(password + user.salt);
    console.log(user.password'user');
    console.log(comparePassword, 'comparePassword');

    if (comparePassword !== user.password)
      throw new ApiException('用户名或密码错误');
    return user;
  }

  /* 判断验证码是否正确 */
  async checkImgCaptcha(uuid: string, code: string) {
    const result = await this.redis.get(`${CAPTCHA_IMG_KEY}:${uuid}`);
    if (isEmpty(result) || code.toLowerCase() !== result.toLowerCase()) {
      throw new ApiException('验证码错误');
    }
    await this.redis.del(`${CAPTCHA_IMG_KEY}:${uuid}`);
  }
}
  • 然后我们将jwt-auth.guard 设置为全局守卫 默认所有的接口都需要携带token

1720065497654.png

现在我们在范围 user/add 这个接口的时候 就会抛出401

1720065599531.png

为接口加上pubilc这个装饰器 ,表示访问不需要token

1720074531234.png

d299ec5f5eb5cd91e87ad4bb71d6ffa.png

创建login模块

req-login.dto.ts

import { IsString } from 'class-validator';
export class ReqLoginDto {
  /* uuid码 */
  @IsString()
  uuidstring;

  /* 验证码code */
  @IsString()
  codestring;

  /* 用户名 */
  @IsString()
  usernamestring;

  /* 密码 */
  @IsString()
  passwordstring;
}

login.controller.ts

import { BodyControllerGetPostReq } from '@nestjs/common';
import { LoginService } from './login.service';
import { ReqLoginDto } from './dto/req-login.dto';
import { Public } from 'src/common/decorators/public.decorator';
import { DataObj } from 'src/common/class/data_obj.class';
import { UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from 'src/common/guards/local-auth.guard';
@Controller()
export class LoginController {
  constructor(private readonly loginService: LoginService) {}
  @Public()
  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Req() req: Request, @Body() body: ReqLoginDto) {
    return await this.loginService.login(req);
  }

  @Public()
  @Get('code')
  async getCode() {
    return await this.loginService.createImageCaptcha();
  }

  @Public()
  @Get('text1')
  tex1() {
    return DataObj.create('text1');
  }
  @Get('text2')
  tex2() {
    return DataObj.create('text2');
  }
}

login.interface.ts

/*
 * @Description: 生成token的接口
 */

export interface Payload {
  userIdnumber;
  pvnumber;
}

login.module.ts

import { LoginController } from './login.controller';
import { LoginService } from './login.service';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from '../system/auth/auth.constants';
@Module({
  imports: [
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn'168h' },
    }),
  ],
  controllers: [LoginController],
  providers: [LoginService],
})
export class LoginModule {}

login.service.ts

import { Injectable } from '@nestjs/common';
import * as svgCaptcha from 'svg-captcha';
import { SharedService } from 'src/shared/shared.service';
import { InjectRedis } from '@nestjs-modules/ioredis';
import { Redis } from 'ioredis';
import {
  CAPTCHA_IMG_KEY,
  USER_TOKEN_KEY,
  USER_VERSION_KEY,
} from 'src/common/contants/redis.contant';
import { Payload } from './login.interface';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class LoginService {
  constructor(
    @InjectRedis()
    private readonly redis: Redis,
    private readonly sharedService: SharedService,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
  ) {}

  /* 登录 */
  async login(request: Request) {
    const { user } = request as any;
    const payloadPayload = { userId: user.userIdpv1 };
    //生成token
    let jwtSign = this.jwtService.sign(payload);
    // //演示环境 复用 token,取消单点登录。
    // if (this.configService.get<boolean>('isDemoEnvironment')) {
    //   const token = await this.redis.get(`${USER_TOKEN_KEY}:${user.userId}`);
    //   if (token) {
    //     jwtSign = token;
    //   }
    // }
    //存储密码版本号,防止登录期间 密码被管理员更改后 还能继续登录
    await this.redis.set(`${USER_VERSION_KEY}:${user.userId}`1);
    //存储token, 防止重复登录问题,设置token过期时间(1天后 token 自动过期),以及主动注销token。
    await this.redis.set(
      `${USER_TOKEN_KEY}:${user.userId}`,
      jwtSign,
      'EX',
      60 * 60 * 24,
    );
    // //调用存储在线用户接口
    // await this.logService.addLogininfor(
    //   request,
    //   '登录成功',
    //   `${USER_TOKEN_KEY}:${user.userId}`,
    // );
    return { token: jwtSign };
  }

  /* 创建验证码图片 */
  async createImageCaptcha() {
    const { data, text } = svgCaptcha.createMathExpr({
      // size: 4, //验证码长度
      // ignoreChars: '0o1i', // 验证码字符中排除 0o1i
      noise3// 干扰线条的数量
      colortrue// 验证码的字符是否有颜色,默认没有,如果设定了背景,则默认有
      // background: '#cc9966', // 验证码图片背景颜色
      width115.5,
      height38,
    });
    const svgBuffer = Buffer.from(data).toString('base64');
    const result = {
      img: svgBuffer,
      uuidthis.sharedService.generateUUID(),
    };
    console.log(text, 'text');

    await this.redis.set(
      `${CAPTCHA_IMG_KEY}:${result.uuid}`,
      text,
      'EX',
      60 * 5,
    );
    return result;
  }
}

测试

1.获取验证码

1720075022359.png

2.调用登录接口

1720075062753.png

3.访问接口

不带token访问/text2

1720075189095.png

带上token

1720075260613.png

4.测试单点登录

  • 重新登录一次 获取新的token

1720075557221.png

  • 使用旧的token访问 /text2接口 提示token失效

1720075649854.png

到处为止 我们的登录模块就写好啦!!!