some auth progress
This commit is contained in:
parent
efdbad4ed7
commit
1904fe94ed
@ -11,4 +11,9 @@ export class AuthController {
|
|||||||
login({ body }: { body: LoginRequest }) {
|
login({ body }: { body: LoginRequest }) {
|
||||||
return this.authService.login(body);
|
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 { security } from './config/security.config';
|
||||||
import { keysProviders } from './providers/keys.providers';
|
import { keysProviders } from './providers/keys.providers';
|
||||||
import { JWTService } from './services/jwt.service';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -19,12 +24,21 @@ import { JWTService } from './services/jwt.service';
|
|||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService) => config.get('typeorm'),
|
useFactory: (config: ConfigService) => ({
|
||||||
|
...config.get('typeorm'),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
TypeOrmModule.forFeature([UserEntity, UserTokenEntity, BanEntity]),
|
||||||
ClientsModule.register([natsClient('auth')]),
|
ClientsModule.register([natsClient('auth')]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, ...keysProviders, JWTService],
|
providers: [
|
||||||
|
...keysProviders,
|
||||||
|
JWTService,
|
||||||
|
OTPService,
|
||||||
|
AuthService,
|
||||||
|
BanService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule implements OnModuleInit {
|
export class AuthModule implements OnModuleInit {
|
||||||
constructor(private readonly config: ConfigService) {}
|
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 {
|
export interface LoginRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: 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 { 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()
|
@Injectable()
|
||||||
export class AuthService {
|
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) {
|
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),
|
username: String(process.env.POSTGRES_USER),
|
||||||
password: String(process.env.POSTGRES_PASSWORD),
|
password: String(process.env.POSTGRES_PASSWORD),
|
||||||
database,
|
database,
|
||||||
autoLoadEntities: true,
|
|
||||||
} as TypeOrmModuleOptions),
|
} 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.module';
|
||||||
export * from './shared.service';
|
export * from './shared.service';
|
||||||
export * from './utils/nats-client';
|
export * from './utils/nats-client';
|
||||||
|
export * from './utils/tokens';
|
||||||
export * from './database/make-typeorm';
|
export * from './database/make-typeorm';
|
||||||
export * from './database/make-knex';
|
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",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"knex": "^2.4.2",
|
"knex": "^2.4.2",
|
||||||
"nats": "^2.15.1",
|
"nats": "^2.15.1",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"pg": "^8.11.1",
|
"pg": "^8.11.1",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.17"
|
"typeorm": "^0.3.17",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.5",
|
"@nestjs/cli": "^10.0.5",
|
||||||
|
@ -53,6 +53,9 @@ dependencies:
|
|||||||
nats:
|
nats:
|
||||||
specifier: ^2.15.1
|
specifier: ^2.15.1
|
||||||
version: 2.15.1
|
version: 2.15.1
|
||||||
|
otplib:
|
||||||
|
specifier: ^12.0.1
|
||||||
|
version: 12.0.1
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.11.1
|
specifier: ^8.11.1
|
||||||
version: 8.11.1
|
version: 8.11.1
|
||||||
@ -68,6 +71,9 @@ dependencies:
|
|||||||
typeorm:
|
typeorm:
|
||||||
specifier: ^0.3.17
|
specifier: ^0.3.17
|
||||||
version: 0.3.17(pg@8.11.1)(ts-node@10.9.1)
|
version: 0.3.17(pg@8.11.1)(ts-node@10.9.1)
|
||||||
|
uuid:
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.0
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@nestjs/cli':
|
'@nestjs/cli':
|
||||||
@ -1236,6 +1242,39 @@ packages:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- 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:
|
/@pkgjs/parseargs@0.11.0:
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@ -4519,6 +4558,14 @@ packages:
|
|||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
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:
|
/p-limit@2.3.0:
|
||||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -5402,6 +5449,11 @@ packages:
|
|||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
dev: false
|
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:
|
/through@2.3.8:
|
||||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||||
dev: true
|
dev: true
|
||||||
|
Loading…
Reference in New Issue
Block a user