前言
- 本篇利用上一节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