some auth progress

This commit is contained in:
Evert Prants 2023-06-30 19:47:29 +03:00
parent efdbad4ed7
commit 1904fe94ed
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
26 changed files with 839 additions and 6 deletions

View File

@ -11,4 +11,9 @@ export class AuthController {
login({ body }: { body: LoginRequest }) {
return this.authService.login(body);
}
@MessagePattern('auth.verify')
verify({ token }: { token: string }) {
return this.authService.getUserFromToken(token);
}
}

View File

@ -9,6 +9,11 @@ import knex from 'knex';
import { security } from './config/security.config';
import { keysProviders } from './providers/keys.providers';
import { JWTService } from './services/jwt.service';
import { UserEntity } from './database/entities/user.entity';
import { UserTokenEntity } from './database/entities/user-token.entity';
import { OTPService } from './services/otp.service';
import { BanEntity } from './database/entities/ban.entity';
import { BanService } from './services/ban.service';
@Module({
imports: [
@ -19,12 +24,21 @@ import { JWTService } from './services/jwt.service';
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => config.get('typeorm'),
useFactory: (config: ConfigService) => ({
...config.get('typeorm'),
}),
}),
TypeOrmModule.forFeature([UserEntity, UserTokenEntity, BanEntity]),
ClientsModule.register([natsClient('auth')]),
],
controllers: [AuthController],
providers: [AuthService, ...keysProviders, JWTService],
providers: [
...keysProviders,
JWTService,
OTPService,
AuthService,
BanService,
],
})
export class AuthModule implements OnModuleInit {
constructor(private readonly config: ConfigService) {}

View File

@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { UserEntity } from './user.entity';
import { Exclude, Expose } from 'class-transformer';
@Entity('bans')
@Expose()
export class BanEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: false })
reason: string;
@Column({ nullable: true })
ip: string;
@Column({ nullable: true, default: 32 })
cidr: number;
@Column({ type: 'timestamp', name: 'expires_at', nullable: true })
expiresAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
@Exclude()
user: UserEntity;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'admin_id' })
@Exclude()
admin: UserEntity;
}

View File

@ -0,0 +1,33 @@
import { UserTokenType } from '@freeblox/shared';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
} from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('user_tokens')
export class UserTokenEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: false, type: 'text' })
token: string;
@Column({ nullable: true, type: 'text' })
nonce: string;
@Column({ type: 'enum', enum: UserTokenType, nullable: false })
type: UserTokenType;
@Column({ type: 'timestamp', nullable: true })
expires_at: Date;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
user: UserEntity;
}

View File

@ -0,0 +1,58 @@
import { MetaEntity } from '@freeblox/shared';
import { Exclude, Expose } from 'class-transformer';
import { IsOptional, IsString } from 'class-validator';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('user_entity')
@Expose()
export class UserEntity extends MetaEntity {
@PrimaryGeneratedColumn('uuid')
@IsString()
id: string;
@Column()
@IsString()
username: string;
@Column()
@IsString()
email: string;
@Column()
@IsString()
@IsOptional()
phone: string;
@Column({ length: 2 })
@IsString()
@IsOptional()
country: string;
@Column({ default: 'en', length: 2 })
@IsString()
@IsOptional()
language: string;
@Column()
@IsString()
@Exclude()
password: string;
@Column({ nullable: true, name: 'display_name' })
@IsString()
@IsOptional()
displayName: string;
@Column({ default: false })
@IsString()
verified: boolean;
@Column({ default: true })
@IsString()
activated: boolean;
@Column({ name: 'login_at' })
@IsString()
@IsOptional()
loginAt: Date;
}

View File

@ -0,0 +1,25 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('bans', (table) => {
table.increments('id').primary();
table.text('reason').notNullable();
table.string('ip', 255).nullable();
table.integer('cidr', 2).nullable().unsigned();
table.uuid('user_id').nullable();
table.uuid('admin_id').nullable();
table.timestamp('expires_at').nullable();
table.timestamp('created_at').notNullable().defaultTo('now()');
table.foreign('user_id').references('users.id').onDelete('CASCADE');
table.foreign('admin_id').references('users.id').onDelete('SET NULL');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('bans');
}

View File

@ -0,0 +1,34 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('user_tokens', (table) => {
table.increments('id').primary();
table.text('token').notNullable();
table.text('nonce').nullable();
table
.enum('type', [
'generic',
'activation',
'deactivation',
'password',
'login',
'gdpr',
'totp',
'public_key',
'recovery',
])
.notNullable();
table.uuid('user_id').notNullable();
table.timestamp('expires_at').nullable();
table.timestamp('created_at').notNullable().defaultTo('now()');
table.foreign('user_id').references('users.id').onDelete('CASCADE');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('user_tokens');
}

View File

@ -0,0 +1,21 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('privileges', (table) => {
table.increments('id').primary();
table.text('privilege').notNullable();
table.boolean('automatic').defaultTo(false);
table.uuid('created_by').nullable();
table.uuid('updated_by').nullable();
table.timestamps(true, true);
table.foreign('created_by').references('users.id').onDelete('SET NULL');
table.foreign('updated_by').references('users.id').onDelete('SET NULL');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('privileges');
}

View File

@ -0,0 +1,17 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('user_privilege', (table) => {
table.integer('privilege_id').nullable().unsigned();
table.uuid('user_id').nullable();
table
.foreign('privilege_id')
.references('privileges.id')
.onDelete('CASCADE');
table.foreign('user_id').references('users.id').onDelete('CASCADE');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('user_privilege');
}

View File

@ -0,0 +1,23 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('roles', (table) => {
table.increments('id').primary();
table.text('role').notNullable();
table.integer('parent_id').nullable().unsigned();
table.boolean('automatic').defaultTo(false);
table.uuid('created_by').nullable();
table.uuid('updated_by').nullable();
table.timestamps(true, true);
table.foreign('parent_id').references('roles.id').onDelete('SET NULL');
table.foreign('created_by').references('users.id').onDelete('SET NULL');
table.foreign('updated_by').references('users.id').onDelete('SET NULL');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('roles');
}

View File

@ -0,0 +1,14 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('user_role', (table) => {
table.integer('role_id').nullable().unsigned();
table.uuid('user_id').nullable();
table.foreign('role_id').references('roles.id').onDelete('CASCADE');
table.foreign('user_id').references('users.id').onDelete('CASCADE');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('user_role');
}

View File

@ -0,0 +1,17 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('role_privilege', (table) => {
table.integer('role_id').nullable().unsigned();
table.integer('privilege_id').nullable().unsigned();
table.foreign('role_id').references('roles.id').onDelete('CASCADE');
table
.foreign('privilege_id')
.references('privileges.id')
.onDelete('CASCADE');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('role_privilege');
}

View File

@ -0,0 +1,187 @@
import { Knex } from 'knex';
export async function seed(knex: Knex): Promise<void> {
const initialRoles = [
{
name: 'player',
automatic: true,
id: 0,
},
{
name: 'member',
id: 0,
parent: 'player',
},
{
name: 'moderator',
id: 0,
parent: 'member',
},
{
name: 'admin',
id: 0,
parent: 'moderator',
},
{
name: 'reduced',
id: 0,
},
];
const initialPrivileges = [
{
id: 0,
name: 'web',
roles: ['player', 'reduced'],
},
{
id: 0,
name: 'play',
roles: ['player'],
},
{
id: 0,
name: 'shop',
roles: ['player'],
},
{
id: 0,
name: 'build',
roles: ['player'],
},
{
id: 0,
name: 'trade',
roles: ['player'],
},
{
id: 0,
name: 'oidc',
roles: ['player'],
},
{
id: 0,
name: 'host',
roles: [],
},
{
id: 0,
name: 'ban',
roles: ['moderator', 'admin'],
},
{
id: 0,
name: 'permaban',
roles: ['admin'],
},
{
id: 0,
name: 'stopserver',
roles: ['admin'],
},
{
id: 0,
name: 'banserver',
roles: ['admin'],
},
{
id: 0,
name: 'root',
},
];
for (const role of initialRoles) {
const exists = await knex('roles').where({
role: role.name,
});
if (exists?.length) {
role.id = exists[0].id;
continue;
}
let parentId: number | null = null;
if (role.parent) {
const findRole = initialRoles.find(
(parent) => parent.name === role.parent,
);
parentId = findRole?.id;
}
const [created] = await knex('roles')
.insert([
{
role: role.name,
automatic: role?.automatic,
parent_id: parentId,
created_at: new Date(),
},
])
.returning(['id']);
role.id = created.id;
}
for (const privilege of initialPrivileges) {
const exists = await knex('privileges').where({
privilege: privilege.name,
});
if (exists?.length) {
privilege.id = exists[0].id;
} else {
const [created] = await knex('privileges')
.insert([
{
privilege: privilege.name,
automatic: false,
created_at: new Date(),
},
])
.returning(['id']);
privilege.id = created.id;
}
if (privilege.roles?.length) {
for (const role of privilege.roles) {
const foundRole = initialRoles.find((item) => item.name === role);
if (!foundRole) continue;
const body = {
role_id: foundRole.id,
privilege_id: privilege.id,
};
const exists = await knex('role_privilege').where(body);
if (exists?.length) continue;
await knex('role_privilege').insert(body);
}
}
}
// Add roles to initial user
const userExists = await knex('users').where({ username: 'freeblox' });
if (!userExists?.length) return;
const adminRole = initialRoles.find((role) => role.name === 'admin');
const privileges = initialPrivileges.filter((privilege) =>
['host', 'root'].includes(privilege.name),
);
const bodyUserRole = {
user_id: userExists[0].id,
role_id: adminRole.id,
};
if (!(await knex('user_role').where(bodyUserRole))?.length) {
await knex('user_role').insert(bodyUserRole);
}
await Promise.all(
privileges.map(async (privilege) => {
const body = {
user_id: userExists[0].id,
privilege_id: privilege.id,
};
if (!(await knex('user_privilege').where(body))?.length) {
await knex('user_privilege').insert(body);
}
}),
);
}

View File

@ -1,4 +1,5 @@
export interface LoginRequest {
email: string;
password: string;
totpToken?: string;
}

View File

@ -1,9 +1,129 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
Injectable,
PreconditionFailedException,
} from '@nestjs/common';
import { LoginRequest } from '../interfaces/auth.interface';
import { JWTService } from './jwt.service';
import { ILike, Repository } from 'typeorm';
import { UserEntity } from '../database/entities/user.entity';
import { compare } from 'bcrypt';
import { instanceToPlain } from 'class-transformer';
import { InjectRepository } from '@nestjs/typeorm';
import { OTPService } from './otp.service';
import { BanService } from './ban.service';
import { UserInfo } from '@freeblox/shared';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JWTService,
private readonly otpService: OTPService,
private readonly banService: BanService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
/**
* Login by username/email and password
* @param body Username/email and password
* @returns JWT token
*/
async login(body: LoginRequest) {
return { test: body.email };
if (!body.email || !body.password) {
throw new BadRequestException('Invalid username or password');
}
// Prevent wildcards
const userInput = body.email?.replace(/%/g, '');
const userEntity = await this.userRepository.findOne({
where: [
{
username: ILike(userInput),
activated: true,
},
{
email: ILike(userInput),
activated: true,
},
],
});
// User not found
if (!userEntity) {
throw new BadRequestException('Invalid username or password');
}
// Compare passwords
const passwordMatch = await compare(body.password, userEntity.password);
if (!passwordMatch) {
throw new BadRequestException('Invalid username or password');
}
// Check TOTP
const userOTPToken = await this.otpService.getUserTOTP(userEntity);
if (userOTPToken) {
if (!body.totpToken) {
throw new PreconditionFailedException('TOTP Token required');
}
const validate = this.otpService.validateTOTP(
userOTPToken.token,
body.totpToken,
);
if (!validate) {
throw new ForbiddenException('Invalid TOTP Token');
}
}
const bans = await this.banService.getActiveBansForUser(userEntity);
const banned = !!bans.length;
// Issue token
const issuedToken = await this.jwtService.sign({
sub: userEntity.id,
username: userEntity.username,
display_name: userEntity.displayName,
language: userEntity.language,
banned: banned,
privileges: banned ? [] : ['freeblox'],
});
// Set login time to now
await this.userRepository.update(
{ id: userEntity.id },
{ loginAt: new Date() },
);
return issuedToken;
}
/**
* Validate user token
* @param token JWT Token
* @returns User entity
*/
async getUserFromToken(token: string) {
const tokenInfo = await this.jwtService.verify(token);
const user = await this.userRepository.findOneByOrFail({
id: tokenInfo.sub,
activated: true,
});
return instanceToPlain(user);
}
/**
* Get user bans
* @param tokeninfo
*/
async getUserBans(userInfo: UserInfo) {
const user = await this.userRepository.findOneByOrFail({
id: userInfo.sub,
activated: true,
});
const bans = await this.banService.getAllBansForUser(user);
return instanceToPlain(bans);
}
}

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BanEntity } from '../database/entities/ban.entity';
import { MoreThan, Repository } from 'typeorm';
import { UserEntity } from '../database/entities/user.entity';
@Injectable()
export class BanService {
constructor(
@InjectRepository(BanEntity)
private readonly banRepository: Repository<BanEntity>,
) {}
async getActiveBansForUser(user: UserEntity) {
return this.banRepository.find({
where: [
{
expiresAt: null,
user: { id: user.id },
},
{
expiresAt: MoreThan(new Date()),
user: { id: user.id },
},
],
});
}
async getAllBansForUser(user: UserEntity) {
return this.banRepository.findBy({
user: { id: user.id },
});
}
}

View File

@ -0,0 +1,87 @@
import { InjectRepository } from '@nestjs/typeorm';
import { UserTokenEntity } from '../database/entities/user-token.entity';
import { authenticator as totp } from 'otplib';
import { Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { UserEntity } from '../database/entities/user.entity';
import { UserTokenType, generateString } from '@freeblox/shared';
totp.options = {
window: 2,
};
@Injectable()
export class OTPService {
constructor(
@InjectRepository(UserTokenEntity)
private readonly userTokenRepository: Repository<UserTokenEntity>,
) {}
/**
* Check if the user has TOTP enabled
* @param user User object
* @returns true if the user has TOTP enabled
*/
public async userHasTOTP(user: UserEntity): Promise<boolean> {
return !!(await this.getUserTOTP(user));
}
/**
* Get the TOTP token of a user
* @param user User object
* @returns TOTP token
*/
public async getUserTOTP(user: UserEntity): Promise<UserTokenEntity> {
return this.userTokenRepository.findOne({
where: { user: { id: user.id }, type: UserTokenType.TOTP },
relations: ['user'],
});
}
public validateTOTP(secret: string, token: string): boolean {
return totp.verify({ token, secret });
}
public getTOTPURL(secret: string, username: string): string {
return totp.keyuri(username, 'Freeblox', secret);
}
public createTOTPSecret(): string {
return totp.generateSecret();
}
public async activateTOTP(
user: UserEntity,
secret: string,
): Promise<UserTokenEntity[]> {
const totp = new UserTokenEntity();
const recovery = new UserTokenEntity();
totp.user = user;
totp.token = secret;
totp.type = UserTokenType.TOTP;
recovery.user = user;
recovery.token = Array.from({ length: 8 }, () => generateString(8)).join(
' ',
);
recovery.type = UserTokenType.RECOVERY;
await this.userTokenRepository.save(totp);
await this.userTokenRepository.save(recovery);
return [totp, recovery];
}
public async deactivateTOTP(token: UserTokenEntity): Promise<void> {
if (!token) {
return;
}
await this.userTokenRepository.delete({
type: UserTokenType.RECOVERY,
user: { id: token.user.id },
});
await this.userTokenRepository.remove(token);
}
}

View File

@ -12,6 +12,5 @@ export const makeTypeOrm = (database: string) =>
username: String(process.env.POSTGRES_USER),
password: String(process.env.POSTGRES_PASSWORD),
database,
autoLoadEntities: true,
} as TypeOrmModuleOptions),
);

View File

@ -0,0 +1,12 @@
import { Exclude } from 'class-transformer';
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
export class MetaEntity {
@CreateDateColumn({ name: 'created_at' })
@Exclude()
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
@Exclude()
updatedAt: Date;
}

View File

@ -1,5 +1,9 @@
export * from './shared.module';
export * from './shared.service';
export * from './utils/nats-client';
export * from './utils/tokens';
export * from './database/make-typeorm';
export * from './database/make-knex';
export * from './database/metaentity';
export * from './types/user-token.enum';
export * from './types/userinfo';

View File

@ -0,0 +1,11 @@
export enum UserTokenType {
GENERIC = 'generic',
ACTIVATION = 'activation',
DEACTIVATION = 'deactivation',
PASSWORD = 'password',
LOGIN = 'login',
GDPR = 'gdpr',
TOTP = 'totp',
PUBLIC_KEY = 'public_key',
RECOVERY = 'recovery',
}

View File

@ -0,0 +1,8 @@
export interface UserInfo {
sub: string;
privileges?: string[];
username: string;
display_name: string;
language: string;
banned?: boolean;
}

View File

@ -0,0 +1,13 @@
import * as crypto from 'crypto';
import { v4 } from 'uuid';
export const generateString = (length: number): string =>
crypto.randomBytes(length).toString('hex').slice(0, length);
export const generateSecret = (): string =>
crypto.randomBytes(256 / 8).toString('hex');
export const insecureHash = (input: string): string =>
crypto.createHash('md5').update(input).digest('hex');
export const createUUID = (): string => v4();

View File

@ -37,11 +37,13 @@
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"nats": "^2.15.1",
"otplib": "^12.0.1",
"pg": "^8.11.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^5.0.1",
"rxjs": "^7.8.1",
"typeorm": "^0.3.17"
"typeorm": "^0.3.17",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.5",

52
pnpm-lock.yaml generated
View File

@ -53,6 +53,9 @@ dependencies:
nats:
specifier: ^2.15.1
version: 2.15.1
otplib:
specifier: ^12.0.1
version: 12.0.1
pg:
specifier: ^8.11.1
version: 8.11.1
@ -68,6 +71,9 @@ dependencies:
typeorm:
specifier: ^0.3.17
version: 0.3.17(pg@8.11.1)(ts-node@10.9.1)
uuid:
specifier: ^9.0.0
version: 9.0.0
devDependencies:
'@nestjs/cli':
@ -1236,6 +1242,39 @@ packages:
transitivePeerDependencies:
- encoding
/@otplib/core@12.0.1:
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
dev: false
/@otplib/plugin-crypto@12.0.1:
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
dependencies:
'@otplib/core': 12.0.1
dev: false
/@otplib/plugin-thirty-two@12.0.1:
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
dependencies:
'@otplib/core': 12.0.1
thirty-two: 1.0.2
dev: false
/@otplib/preset-default@12.0.1:
resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-crypto': 12.0.1
'@otplib/plugin-thirty-two': 12.0.1
dev: false
/@otplib/preset-v11@12.0.1:
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-crypto': 12.0.1
'@otplib/plugin-thirty-two': 12.0.1
dev: false
/@pkgjs/parseargs@0.11.0:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -4519,6 +4558,14 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/otplib@12.0.1:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/preset-default': 12.0.1
'@otplib/preset-v11': 12.0.1
dev: false
/p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@ -5402,6 +5449,11 @@ packages:
any-promise: 1.3.0
dev: false
/thirty-two@1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
dev: false
/through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: true