From 1904fe94ed0dbd2977cc01eb9bcba81f483fefd1 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Fri, 30 Jun 2023 19:47:29 +0300 Subject: [PATCH] some auth progress --- apps/auth/src/auth.controller.ts | 5 + apps/auth/src/auth.module.ts | 18 +- apps/auth/src/database/entities/ban.entity.ts | 42 ++++ .../database/entities/user-token.entity.ts | 33 ++++ .../auth/src/database/entities/user.entity.ts | 58 ++++++ .../database/migrations/20230630153343_ban.ts | 25 +++ .../migrations/20230630155000_user-token.ts | 34 ++++ .../migrations/20230630155155_privilege.ts | 21 ++ .../20230630160302_user-privilege.ts | 17 ++ .../migrations/20230630160541_role.ts | 23 +++ .../migrations/20230630160546_user-role.ts | 14 ++ .../20230630160552_role-privilege.ts | 17 ++ ...initial-users.ts => 0001-initial-users.ts} | 0 .../database/seeds/0002-initial-privileges.ts | 187 ++++++++++++++++++ apps/auth/src/interfaces/auth.interface.ts | 1 + apps/auth/src/services/auth.service.ts | 124 +++++++++++- apps/auth/src/services/ban.service.ts | 34 ++++ apps/auth/src/services/otp.service.ts | 87 ++++++++ libs/shared/src/database/make-typeorm.ts | 1 - libs/shared/src/database/metaentity.ts | 12 ++ libs/shared/src/index.ts | 4 + libs/shared/src/types/user-token.enum.ts | 11 ++ libs/shared/src/types/userinfo.ts | 8 + libs/shared/src/utils/tokens.ts | 13 ++ package.json | 4 +- pnpm-lock.yaml | 52 +++++ 26 files changed, 839 insertions(+), 6 deletions(-) create mode 100644 apps/auth/src/database/entities/ban.entity.ts create mode 100644 apps/auth/src/database/entities/user-token.entity.ts create mode 100644 apps/auth/src/database/entities/user.entity.ts create mode 100644 apps/auth/src/database/migrations/20230630153343_ban.ts create mode 100644 apps/auth/src/database/migrations/20230630155000_user-token.ts create mode 100644 apps/auth/src/database/migrations/20230630155155_privilege.ts create mode 100644 apps/auth/src/database/migrations/20230630160302_user-privilege.ts create mode 100644 apps/auth/src/database/migrations/20230630160541_role.ts create mode 100644 apps/auth/src/database/migrations/20230630160546_user-role.ts create mode 100644 apps/auth/src/database/migrations/20230630160552_role-privilege.ts rename apps/auth/src/database/seeds/{initial-users.ts => 0001-initial-users.ts} (100%) create mode 100644 apps/auth/src/database/seeds/0002-initial-privileges.ts create mode 100644 apps/auth/src/services/ban.service.ts create mode 100644 apps/auth/src/services/otp.service.ts create mode 100644 libs/shared/src/database/metaentity.ts create mode 100644 libs/shared/src/types/user-token.enum.ts create mode 100644 libs/shared/src/types/userinfo.ts create mode 100644 libs/shared/src/utils/tokens.ts diff --git a/apps/auth/src/auth.controller.ts b/apps/auth/src/auth.controller.ts index aaaf71c..b87f312 100644 --- a/apps/auth/src/auth.controller.ts +++ b/apps/auth/src/auth.controller.ts @@ -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); + } } diff --git a/apps/auth/src/auth.module.ts b/apps/auth/src/auth.module.ts index b6b7a2f..3a45c75 100644 --- a/apps/auth/src/auth.module.ts +++ b/apps/auth/src/auth.module.ts @@ -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) {} diff --git a/apps/auth/src/database/entities/ban.entity.ts b/apps/auth/src/database/entities/ban.entity.ts new file mode 100644 index 0000000..65835d4 --- /dev/null +++ b/apps/auth/src/database/entities/ban.entity.ts @@ -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; +} diff --git a/apps/auth/src/database/entities/user-token.entity.ts b/apps/auth/src/database/entities/user-token.entity.ts new file mode 100644 index 0000000..f7d60d5 --- /dev/null +++ b/apps/auth/src/database/entities/user-token.entity.ts @@ -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; +} diff --git a/apps/auth/src/database/entities/user.entity.ts b/apps/auth/src/database/entities/user.entity.ts new file mode 100644 index 0000000..91b427b --- /dev/null +++ b/apps/auth/src/database/entities/user.entity.ts @@ -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; +} diff --git a/apps/auth/src/database/migrations/20230630153343_ban.ts b/apps/auth/src/database/migrations/20230630153343_ban.ts new file mode 100644 index 0000000..b61e383 --- /dev/null +++ b/apps/auth/src/database/migrations/20230630153343_ban.ts @@ -0,0 +1,25 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + return knex.schema.dropTable('bans'); +} diff --git a/apps/auth/src/database/migrations/20230630155000_user-token.ts b/apps/auth/src/database/migrations/20230630155000_user-token.ts new file mode 100644 index 0000000..02e3a4d --- /dev/null +++ b/apps/auth/src/database/migrations/20230630155000_user-token.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + return knex.schema.dropTable('user_tokens'); +} diff --git a/apps/auth/src/database/migrations/20230630155155_privilege.ts b/apps/auth/src/database/migrations/20230630155155_privilege.ts new file mode 100644 index 0000000..f97d7b3 --- /dev/null +++ b/apps/auth/src/database/migrations/20230630155155_privilege.ts @@ -0,0 +1,21 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + return knex.schema.dropTable('privileges'); +} diff --git a/apps/auth/src/database/migrations/20230630160302_user-privilege.ts b/apps/auth/src/database/migrations/20230630160302_user-privilege.ts new file mode 100644 index 0000000..746ab8e --- /dev/null +++ b/apps/auth/src/database/migrations/20230630160302_user-privilege.ts @@ -0,0 +1,17 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + return knex.schema.dropTable('user_privilege'); +} diff --git a/apps/auth/src/database/migrations/20230630160541_role.ts b/apps/auth/src/database/migrations/20230630160541_role.ts new file mode 100644 index 0000000..fcf0741 --- /dev/null +++ b/apps/auth/src/database/migrations/20230630160541_role.ts @@ -0,0 +1,23 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + return knex.schema.dropTable('roles'); +} diff --git a/apps/auth/src/database/migrations/20230630160546_user-role.ts b/apps/auth/src/database/migrations/20230630160546_user-role.ts new file mode 100644 index 0000000..d6ef87a --- /dev/null +++ b/apps/auth/src/database/migrations/20230630160546_user-role.ts @@ -0,0 +1,14 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + return knex.schema.dropTable('user_role'); +} diff --git a/apps/auth/src/database/migrations/20230630160552_role-privilege.ts b/apps/auth/src/database/migrations/20230630160552_role-privilege.ts new file mode 100644 index 0000000..21a30e2 --- /dev/null +++ b/apps/auth/src/database/migrations/20230630160552_role-privilege.ts @@ -0,0 +1,17 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + return knex.schema.dropTable('role_privilege'); +} diff --git a/apps/auth/src/database/seeds/initial-users.ts b/apps/auth/src/database/seeds/0001-initial-users.ts similarity index 100% rename from apps/auth/src/database/seeds/initial-users.ts rename to apps/auth/src/database/seeds/0001-initial-users.ts diff --git a/apps/auth/src/database/seeds/0002-initial-privileges.ts b/apps/auth/src/database/seeds/0002-initial-privileges.ts new file mode 100644 index 0000000..3125326 --- /dev/null +++ b/apps/auth/src/database/seeds/0002-initial-privileges.ts @@ -0,0 +1,187 @@ +import { Knex } from 'knex'; + +export async function seed(knex: Knex): Promise { + 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); + } + }), + ); +} diff --git a/apps/auth/src/interfaces/auth.interface.ts b/apps/auth/src/interfaces/auth.interface.ts index df54fd0..5cef833 100644 --- a/apps/auth/src/interfaces/auth.interface.ts +++ b/apps/auth/src/interfaces/auth.interface.ts @@ -1,4 +1,5 @@ export interface LoginRequest { email: string; password: string; + totpToken?: string; } diff --git a/apps/auth/src/services/auth.service.ts b/apps/auth/src/services/auth.service.ts index db9e049..2818da5 100644 --- a/apps/auth/src/services/auth.service.ts +++ b/apps/auth/src/services/auth.service.ts @@ -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, + ) {} + + /** + * 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); } } diff --git a/apps/auth/src/services/ban.service.ts b/apps/auth/src/services/ban.service.ts new file mode 100644 index 0000000..2a94b21 --- /dev/null +++ b/apps/auth/src/services/ban.service.ts @@ -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, + ) {} + + 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 }, + }); + } +} diff --git a/apps/auth/src/services/otp.service.ts b/apps/auth/src/services/otp.service.ts new file mode 100644 index 0000000..25b53c5 --- /dev/null +++ b/apps/auth/src/services/otp.service.ts @@ -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, + ) {} + + /** + * 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 { + 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 { + 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 { + 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 { + if (!token) { + return; + } + + await this.userTokenRepository.delete({ + type: UserTokenType.RECOVERY, + user: { id: token.user.id }, + }); + await this.userTokenRepository.remove(token); + } +} diff --git a/libs/shared/src/database/make-typeorm.ts b/libs/shared/src/database/make-typeorm.ts index ad0a4d4..37eed79 100644 --- a/libs/shared/src/database/make-typeorm.ts +++ b/libs/shared/src/database/make-typeorm.ts @@ -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), ); diff --git a/libs/shared/src/database/metaentity.ts b/libs/shared/src/database/metaentity.ts new file mode 100644 index 0000000..fa04b24 --- /dev/null +++ b/libs/shared/src/database/metaentity.ts @@ -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; +} diff --git a/libs/shared/src/index.ts b/libs/shared/src/index.ts index a720a19..ac2abdb 100644 --- a/libs/shared/src/index.ts +++ b/libs/shared/src/index.ts @@ -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'; diff --git a/libs/shared/src/types/user-token.enum.ts b/libs/shared/src/types/user-token.enum.ts new file mode 100644 index 0000000..d41ab32 --- /dev/null +++ b/libs/shared/src/types/user-token.enum.ts @@ -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', +} diff --git a/libs/shared/src/types/userinfo.ts b/libs/shared/src/types/userinfo.ts new file mode 100644 index 0000000..2c9631b --- /dev/null +++ b/libs/shared/src/types/userinfo.ts @@ -0,0 +1,8 @@ +export interface UserInfo { + sub: string; + privileges?: string[]; + username: string; + display_name: string; + language: string; + banned?: boolean; +} diff --git a/libs/shared/src/utils/tokens.ts b/libs/shared/src/utils/tokens.ts new file mode 100644 index 0000000..447a09b --- /dev/null +++ b/libs/shared/src/utils/tokens.ts @@ -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(); diff --git a/package.json b/package.json index 1c86f31..a125ab2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3004828..9c69c15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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