some auth progress
This commit is contained in:
parent
efdbad4ed7
commit
1904fe94ed
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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) {}
|
||||
|
42
apps/auth/src/database/entities/ban.entity.ts
Normal file
42
apps/auth/src/database/entities/ban.entity.ts
Normal 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;
|
||||
}
|
33
apps/auth/src/database/entities/user-token.entity.ts
Normal file
33
apps/auth/src/database/entities/user-token.entity.ts
Normal 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;
|
||||
}
|
58
apps/auth/src/database/entities/user.entity.ts
Normal file
58
apps/auth/src/database/entities/user.entity.ts
Normal 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;
|
||||
}
|
25
apps/auth/src/database/migrations/20230630153343_ban.ts
Normal file
25
apps/auth/src/database/migrations/20230630153343_ban.ts
Normal 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');
|
||||
}
|
@ -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');
|
||||
}
|
@ -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');
|
||||
}
|
@ -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');
|
||||
}
|
23
apps/auth/src/database/migrations/20230630160541_role.ts
Normal file
23
apps/auth/src/database/migrations/20230630160541_role.ts
Normal 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');
|
||||
}
|
@ -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');
|
||||
}
|
@ -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');
|
||||
}
|
187
apps/auth/src/database/seeds/0002-initial-privileges.ts
Normal file
187
apps/auth/src/database/seeds/0002-initial-privileges.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
totpToken?: string;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
34
apps/auth/src/services/ban.service.ts
Normal file
34
apps/auth/src/services/ban.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
87
apps/auth/src/services/otp.service.ts
Normal file
87
apps/auth/src/services/otp.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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),
|
||||
);
|
||||
|
12
libs/shared/src/database/metaentity.ts
Normal file
12
libs/shared/src/database/metaentity.ts
Normal 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;
|
||||
}
|
@ -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';
|
||||
|
11
libs/shared/src/types/user-token.enum.ts
Normal file
11
libs/shared/src/types/user-token.enum.ts
Normal 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',
|
||||
}
|
8
libs/shared/src/types/userinfo.ts
Normal file
8
libs/shared/src/types/userinfo.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface UserInfo {
|
||||
sub: string;
|
||||
privileges?: string[];
|
||||
username: string;
|
||||
display_name: string;
|
||||
language: string;
|
||||
banned?: boolean;
|
||||
}
|
13
libs/shared/src/utils/tokens.ts
Normal file
13
libs/shared/src/utils/tokens.ts
Normal 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();
|
@ -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
52
pnpm-lock.yaml
generated
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user