mysql+typerom+redis+jwt 实现鉴权单点登录
本小节代码建立在上一章的代码基础之上
- 项目预览: linlingqin.top:3000/admin
- 国内镜像: gitee.com/linlingqin/…
- github:github.com/linlingqin7…
- 从零到一项目搭建教程
- 本小节案例
目录结构
- 需要新建以下几个目录结构
创建user模块
之后创建个 user 的 CRUD 模块:
nest g resource modules/system/user
user.entity.ts
新建user实体文件
/modules/system/user/entities/user.entity.ts
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
/* 用户Id */
@PrimaryGeneratedColumn({
name: 'user_id',
comment: '用户ID',
})
@IsNumber()
userId: number;
/* 用户账号 */
@Column({
name: 'user_name',
comment: '用户账号',
length: 30,
})
@IsString()
userName: string;
/* 用户昵称 */
@Column({
name: 'nick_name',
comment: '用户昵称',
length: 30,
})
@IsString()
nickName: string;
/* 用户类型 */
@Column({
name: 'user_type',
comment: '用户类型(00系统用户)',
length: 2,
default: '00',
})
@IsOptional()
@IsString()
userType?: string;
/* 用户邮箱 */
@Column({
comment: '用户邮箱',
length: 50,
default: null,
})
@IsOptional()
@IsString()
email?: string;
/* 手机号码 */
@Column({
comment: '手机号码',
length: 11,
default: null,
})
@IsOptional()
@IsString()
phonenumber?: string;
@Column({
comment: '用户性别(0男 1女 2未知)',
type: 'char',
length: 1,
default: '0',
})
@IsOptional()
@IsString()
sex: string;
/* 头像地址 */
@Column({
comment: '头像地址',
length: 100,
default: '',
})
@IsOptional()
@IsString()
avatar?: string;
/* 密码 */
@Column({
comment: '密码',
length: 100,
default: '',
select: false,
})
@IsString()
password: string;
@Column({
comment: '盐加密',
length: 100,
default: '',
select: false,
})
salt: string;
/* 帐号状态 */
@Column({
comment: '帐号状态(0正常 1停用)',
type: 'char',
length: 1,
default: '0',
})
@IsString()
@IsString()
status: string;
@Column({
name: 'del_flag',
comment: '删除标志(0代表存在 2代表删除)',
type: 'char',
length: 1,
default: '0',
})
delFlag: string;
/* 最后登录IP */
@Column({
name: 'login_ip',
comment: '最后登录IP',
length: 128,
default: '',
})
@IsOptional()
@IsString()
loginIp?: string;
/* 最后登录时间 */
@Column({
name: 'login_date',
comment: '最后登录时间',
default: null,
})
@IsOptional()
@IsString()
loginDate?: Date;
}
req-user.dto.ts
import { OmitType, PartialType } from '@nestjs/swagger';
import { User } from '../entities/user.entity';
/* 新增用户 */
export class ReqAddUserDto extends OmitType(User, ['userId'] as const) {}
user.module.ts
import { Global, Module } 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 { Body, Controller } 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调用接口 新增用户
- 可以看到数据库用户表新增了一条数据
鉴权守卫
Passport JWT 策略
/modules/system/auth/strateties/jwt-auth.stratety.ts
import { ExecutionContext, Injectable } 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(
context: ExecutionContext,
): 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 { ExecutionContext, Injectable } 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();
}
context: ExecutionContext;
canActivate(
context: ExecutionContext,
): 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_KEY, true);
jwt-auth.guard.ts
import { ExecutionContext, Injectable } 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(
context: ExecutionContext,
): 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 { ExecutionContext, Injectable } 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();
}
context: ExecutionContext;
canActivate(
context: ExecutionContext,
): 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: [AuthService, JwtStrategy, LocalStrategy],
})
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
现在我们在范围 user/add 这个接口的时候 就会抛出401
为接口加上pubilc这个装饰器 ,表示访问不需要token

创建login模块
req-login.dto.ts
import { IsString } from 'class-validator';
export class ReqLoginDto {
/* uuid码 */
@IsString()
uuid: string;
/* 验证码code */
@IsString()
code: string;
/* 用户名 */
@IsString()
username: string;
/* 密码 */
@IsString()
password: string;
}
login.controller.ts
import { Body, Controller, Get, Post, Req } 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 {
userId: number;
pv: number;
}
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 payload: Payload = { userId: user.userId, pv: 1 };
//生成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
noise: 3, // 干扰线条的数量
color: true, // 验证码的字符是否有颜色,默认没有,如果设定了背景,则默认有
// background: '#cc9966', // 验证码图片背景颜色
width: 115.5,
height: 38,
});
const svgBuffer = Buffer.from(data).toString('base64');
const result = {
img: svgBuffer,
uuid: this.sharedService.generateUUID(),
};
console.log(text, 'text');
await this.redis.set(
`${CAPTCHA_IMG_KEY}:${result.uuid}`,
text,
'EX',
60 * 5,
);
return result;
}
}
测试
1.获取验证码
2.调用登录接口
3.访问接口
不带token访问/text2
带上token
4.测试单点登录
- 重新登录一次 获取新的token
- 使用旧的token访问 /text2接口 提示token失效
到处为止 我们的登录模块就写好啦!!!