Compare commits
3 Commits
efdbad4ed7
...
529e4a96d7
Author | SHA1 | Date | |
---|---|---|---|
529e4a96d7 | |||
d81fc53819 | |||
1904fe94ed |
@ -2,6 +2,7 @@ import { Controller } from '@nestjs/common';
|
|||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
import { MessagePattern } from '@nestjs/microservices';
|
import { MessagePattern } from '@nestjs/microservices';
|
||||||
import { LoginRequest } from './interfaces/auth.interface';
|
import { LoginRequest } from './interfaces/auth.interface';
|
||||||
|
import { UserInfo } from '@freeblox/shared';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@ -11,4 +12,24 @@ export class AuthController {
|
|||||||
login({ body }: { body: LoginRequest }) {
|
login({ body }: { body: LoginRequest }) {
|
||||||
return this.authService.login(body);
|
return this.authService.login(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MessagePattern('auth.loginByRefreshToken')
|
||||||
|
loginByRefreshToken({ token }: { token: string }) {
|
||||||
|
return this.authService.loginByRefreshToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@MessagePattern('auth.verify')
|
||||||
|
verify({ token }: { token: string }) {
|
||||||
|
return this.authService.verifyToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@MessagePattern('auth.getUserById')
|
||||||
|
getUserById({ id }: { id: string }) {
|
||||||
|
return this.authService.getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@MessagePattern('auth.getUserBans')
|
||||||
|
getUserBans({ user }: { user: UserInfo }) {
|
||||||
|
return this.authService.getUserBans(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,23 @@ 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';
|
||||||
|
import { PrivilegeEntity } from './database/entities/privilege.entity';
|
||||||
|
import { RoleService } from './services/role.service';
|
||||||
|
import { RoleEntity } from './database/entities/role.entity';
|
||||||
|
import { RefreshService } from './services/refresh.service';
|
||||||
|
|
||||||
|
const entities = [
|
||||||
|
UserEntity,
|
||||||
|
UserTokenEntity,
|
||||||
|
BanEntity,
|
||||||
|
PrivilegeEntity,
|
||||||
|
RoleEntity,
|
||||||
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -19,12 +36,24 @@ 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'),
|
||||||
|
entities,
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forFeature(entities),
|
||||||
ClientsModule.register([natsClient('auth')]),
|
ClientsModule.register([natsClient('auth')]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, ...keysProviders, JWTService],
|
providers: [
|
||||||
|
...keysProviders,
|
||||||
|
JWTService,
|
||||||
|
OTPService,
|
||||||
|
BanService,
|
||||||
|
RoleService,
|
||||||
|
RefreshService,
|
||||||
|
AuthService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule implements OnModuleInit {
|
export class AuthModule implements OnModuleInit {
|
||||||
constructor(private readonly config: ConfigService) {}
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
@ -2,7 +2,11 @@ import { registerAs } from '@nestjs/config';
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
|
||||||
export const security = registerAs('security', () => ({
|
export const security = registerAs('security', () => ({
|
||||||
algorithm: String(process.env.JWT_ALGORITHM || 'RS512'),
|
jwtAlgorithm: String(process.env.JWT_ALGORITHM || 'RS512'),
|
||||||
|
jwtTokenExpiry: Number(process.env.JWT_EXPIRY) || 60 * 60,
|
||||||
|
refreshTokenExpiry: Number(process.env.REFRESH_EXPIRY) || 30 * 60 * 60,
|
||||||
privateKeyPath: resolve(String(process.env.PRIVATE_KEY_FILE)),
|
privateKeyPath: resolve(String(process.env.PRIVATE_KEY_FILE)),
|
||||||
publicKeyPath: resolve(String(process.env.PUBLIC_KEY_FILE)),
|
publicKeyPath: resolve(String(process.env.PUBLIC_KEY_FILE)),
|
||||||
|
secretKey: String(process.env.SECRET_KEY),
|
||||||
|
secretAlgorithm: String(process.env.REFRESH_ALGORITHM || 'A256CBC-HS512'),
|
||||||
}));
|
}));
|
||||||
|
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;
|
||||||
|
}
|
18
apps/auth/src/database/entities/privilege.entity.ts
Normal file
18
apps/auth/src/database/entities/privilege.entity.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { UserMetaEntity } from '@freeblox/shared';
|
||||||
|
import { Exclude, Expose } from 'class-transformer';
|
||||||
|
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('privileges')
|
||||||
|
@Exclude()
|
||||||
|
export class PrivilegeEntity extends UserMetaEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Expose()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
@Expose()
|
||||||
|
privilege: string;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
automatic: boolean;
|
||||||
|
}
|
54
apps/auth/src/database/entities/role.entity.ts
Normal file
54
apps/auth/src/database/entities/role.entity.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { UserMetaEntity } from '@freeblox/shared';
|
||||||
|
import { Exclude, Expose } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Tree,
|
||||||
|
TreeChildren,
|
||||||
|
TreeParent,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { PrivilegeEntity } from './privilege.entity';
|
||||||
|
|
||||||
|
@Entity('roles')
|
||||||
|
@Exclude()
|
||||||
|
@Tree('materialized-path')
|
||||||
|
export class RoleEntity extends UserMetaEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Expose()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
@Expose()
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
@Column({ name: 'parent_id' })
|
||||||
|
@Expose()
|
||||||
|
parentId: number;
|
||||||
|
|
||||||
|
@TreeParent()
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent: RoleEntity;
|
||||||
|
|
||||||
|
@TreeChildren()
|
||||||
|
children: RoleEntity[];
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
automatic: boolean;
|
||||||
|
|
||||||
|
@ManyToMany(() => PrivilegeEntity, { eager: true })
|
||||||
|
@Expose()
|
||||||
|
@JoinTable({
|
||||||
|
name: 'role_privilege',
|
||||||
|
joinColumn: {
|
||||||
|
name: 'role_id',
|
||||||
|
},
|
||||||
|
inverseJoinColumn: {
|
||||||
|
name: 'privilege_id',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
privileges: PrivilegeEntity[];
|
||||||
|
}
|
39
apps/auth/src/database/entities/user-token.entity.ts
Normal file
39
apps/auth/src/database/entities/user-token.entity.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { UserTokenType } from '@freeblox/shared';
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} 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', name: 'expires_at', nullable: true })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: UserEntity;
|
||||||
|
|
||||||
|
@ManyToOne(() => UserTokenEntity, { onDelete: 'CASCADE', nullable: true })
|
||||||
|
@JoinColumn({ name: 'previous_id' })
|
||||||
|
previous: UserTokenEntity;
|
||||||
|
}
|
92
apps/auth/src/database/entities/user.entity.ts
Normal file
92
apps/auth/src/database/entities/user.entity.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { MetaEntity } from '@freeblox/shared';
|
||||||
|
import { Exclude, Expose } from 'class-transformer';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { PrivilegeEntity } from './privilege.entity';
|
||||||
|
import { RoleEntity } from './role.entity';
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@Entity('users')
|
||||||
|
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;
|
||||||
|
|
||||||
|
@ManyToMany(() => PrivilegeEntity)
|
||||||
|
@JoinTable({
|
||||||
|
name: 'user_privilege',
|
||||||
|
joinColumn: {
|
||||||
|
name: 'user_id',
|
||||||
|
},
|
||||||
|
inverseJoinColumn: {
|
||||||
|
name: 'privilege_id',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Exclude()
|
||||||
|
privileges: PrivilegeEntity[];
|
||||||
|
|
||||||
|
@ManyToMany(() => RoleEntity)
|
||||||
|
@JoinTable({
|
||||||
|
name: 'user_role',
|
||||||
|
joinColumn: {
|
||||||
|
name: 'user_id',
|
||||||
|
},
|
||||||
|
inverseJoinColumn: {
|
||||||
|
name: 'role_id',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Exclude()
|
||||||
|
roles: RoleEntity[];
|
||||||
|
}
|
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().defaultTo(32).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,40 @@
|
|||||||
|
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',
|
||||||
|
'refresh',
|
||||||
|
])
|
||||||
|
.notNullable();
|
||||||
|
|
||||||
|
table.uuid('user_id').notNullable();
|
||||||
|
table.integer('previous_id').nullable().unsigned();
|
||||||
|
|
||||||
|
table.timestamp('expires_at').nullable();
|
||||||
|
table.timestamp('created_at').notNullable().defaultTo('now()');
|
||||||
|
|
||||||
|
table.foreign('user_id').references('users.id').onDelete('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('previous_id')
|
||||||
|
.references('user_tokens.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,18 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
return knex.schema.createTable('user_privilege', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
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');
|
||||||
|
}
|
24
apps/auth/src/database/migrations/20230630160541_role.ts
Normal file
24
apps/auth/src/database/migrations/20230630160541_role.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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.string('mpath').nullable().defaultTo('');
|
||||||
|
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,15 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
return knex.schema.createTable('user_role', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
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,18 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
return knex.schema.createTable('role_privilege', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
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');
|
||||||
|
}
|
200
apps/auth/src/database/seeds/0002-initial-privileges.ts
Normal file
200
apps/auth/src/database/seeds/0002-initial-privileges.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function seed(knex: Knex): Promise<void> {
|
||||||
|
const initialRoles = [
|
||||||
|
{
|
||||||
|
name: 'player',
|
||||||
|
automatic: true,
|
||||||
|
id: 0,
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'member',
|
||||||
|
id: 0,
|
||||||
|
parent: 'player',
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'moderator',
|
||||||
|
id: 0,
|
||||||
|
parent: 'member',
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'admin',
|
||||||
|
id: 0,
|
||||||
|
parent: 'moderator',
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reduced',
|
||||||
|
id: 0,
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
if (findRole) {
|
||||||
|
parentId = findRole.id;
|
||||||
|
role.path += findRole.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await knex('roles')
|
||||||
|
.insert([
|
||||||
|
{
|
||||||
|
role: role.name,
|
||||||
|
automatic: role?.automatic,
|
||||||
|
mpath: role.path,
|
||||||
|
parent_id: parentId,
|
||||||
|
created_at: new Date(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.returning(['id']);
|
||||||
|
|
||||||
|
role.id = created.id;
|
||||||
|
role.path += `${role.path ? '.' : ''}${role.id}`;
|
||||||
|
|
||||||
|
await knex('roles').where({ id: role.id }).update({ mpath: role.path });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
4
apps/auth/src/interfaces/token-response.interface.ts
Normal file
4
apps/auth/src/interfaces/token-response.interface.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface TokenResponse {
|
||||||
|
token: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
@ -16,6 +16,12 @@ export const keysProviders = [
|
|||||||
.readFile(config.get('security.privateKeyPath'), 'utf-8')
|
.readFile(config.get('security.privateKeyPath'), 'utf-8')
|
||||||
.then((key) => jose.importPKCS8(key, 'RS512')),
|
.then((key) => jose.importPKCS8(key, 'RS512')),
|
||||||
},
|
},
|
||||||
|
<FactoryProvider>{
|
||||||
|
provide: 'APP_SECRET_KEY',
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: async (config: ConfigService) =>
|
||||||
|
jose.base64url.decode(config.get('security.secretKey')),
|
||||||
|
},
|
||||||
<FactoryProvider>{
|
<FactoryProvider>{
|
||||||
provide: 'APP_PUBLIC_KEY',
|
provide: 'APP_PUBLIC_KEY',
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
@ -1,9 +1,201 @@
|
|||||||
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';
|
||||||
|
import { RoleService } from './role.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { RefreshService } from './refresh.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JWTService,
|
||||||
|
private readonly otpService: OTPService,
|
||||||
|
private readonly banService: BanService,
|
||||||
|
private readonly roleService: RoleService,
|
||||||
|
private readonly refreshService: RefreshService,
|
||||||
|
@InjectRepository(UserEntity)
|
||||||
|
private readonly userRepository: Repository<UserEntity>,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue access token
|
||||||
|
const exp = this.config.get('security.jwtTokenExpiry');
|
||||||
|
const issuedToken = await this.issueToken(userEntity);
|
||||||
|
|
||||||
|
// Issue refresh token
|
||||||
|
const refreshToken = await this.refreshService.issueRefreshToken(
|
||||||
|
userEntity,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set login time to now
|
||||||
|
await this.userRepository.update(
|
||||||
|
{ id: userEntity.id },
|
||||||
|
{ loginAt: new Date() },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: issuedToken,
|
||||||
|
refresh: refreshToken,
|
||||||
|
expires_in: exp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginByRefreshToken(token: string) {
|
||||||
|
const refreshToken = await this.refreshService.useRefreshToken(token);
|
||||||
|
const userEntity = refreshToken.user;
|
||||||
|
|
||||||
|
// Issue new access token
|
||||||
|
const exp = this.config.get('security.jwtTokenExpiry');
|
||||||
|
const issuedToken = await this.issueToken(userEntity);
|
||||||
|
|
||||||
|
// Set login time to now
|
||||||
|
await this.userRepository.update(
|
||||||
|
{ id: userEntity.id },
|
||||||
|
{ loginAt: new Date() },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: issuedToken,
|
||||||
|
refresh: refreshToken.token,
|
||||||
|
expires_in: exp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user from token
|
||||||
|
* @param token JWT Token
|
||||||
|
* @returns User entity
|
||||||
|
*/
|
||||||
|
async getUserFromToken(token: string) {
|
||||||
|
const tokenInfo = await this.verifyToken(token);
|
||||||
|
const user = await this.userRepository.findOneByOrFail({
|
||||||
|
id: tokenInfo.sub,
|
||||||
|
activated: true,
|
||||||
|
});
|
||||||
|
return instanceToPlain(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify user token
|
||||||
|
* @param token JWT token
|
||||||
|
* @returns User token info
|
||||||
|
*/
|
||||||
|
async verifyToken(token: string) {
|
||||||
|
try {
|
||||||
|
return await this.jwtService.verify(token);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(token, e);
|
||||||
|
throw new ForbiddenException('Invalid token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user entity by ID
|
||||||
|
* @param id User ID
|
||||||
|
* @returns User entity
|
||||||
|
*/
|
||||||
|
async getUserById(id: string) {
|
||||||
|
if (!id) throw new BadRequestException('ID is required');
|
||||||
|
return instanceToPlain(await this.userRepository.findOneByOrFail({ id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueToken(user: UserEntity) {
|
||||||
|
// Check for active ban
|
||||||
|
const bans = await this.banService.getActiveBansForUser(user);
|
||||||
|
const banned = !!bans.length;
|
||||||
|
|
||||||
|
// Get all privileges applicable to user
|
||||||
|
const privileges = await this.roleService.getUserPrivileges(user);
|
||||||
|
|
||||||
|
// Issue new token
|
||||||
|
return this.jwtService.sign({
|
||||||
|
sub: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.displayName,
|
||||||
|
language: user.language,
|
||||||
|
banned: banned,
|
||||||
|
privileges: banned
|
||||||
|
? []
|
||||||
|
: privileges.map((privilege) => privilege.privilege),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,30 +1,34 @@
|
|||||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JWK, KeyLike, SignJWT, jwtVerify } from 'jose';
|
import { EncryptJWT, JWK, KeyLike, SignJWT, jwtDecrypt, jwtVerify } from 'jose';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JWTService {
|
export class JWTService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('APP_PRIVATE_KEY') private readonly privateKey: KeyLike,
|
@Inject('APP_PRIVATE_KEY') private readonly privateKey: KeyLike,
|
||||||
|
@Inject('APP_SECRET_KEY') private readonly secretKey: KeyLike,
|
||||||
@Inject('APP_PUBLIC_KEY') private readonly publicKey: KeyLike,
|
@Inject('APP_PUBLIC_KEY') private readonly publicKey: KeyLike,
|
||||||
@Inject('APP_PUBLIC_KEY_JWK') private readonly publicKeyJWK: JWK,
|
@Inject('APP_PUBLIC_KEY_JWK') private readonly publicKeyJWK: JWK,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async sign(data: Record<string, unknown>, audience = 'urn:freeblox:service') {
|
async sign(data: Record<string, unknown>, audience = 'urn:freeblox:service') {
|
||||||
const alg = this.config.get('security.algorithm');
|
const alg = this.config.get('security.jwtAlgorithm');
|
||||||
|
const exp =
|
||||||
|
this.config.get('security.jwtTokenExpiry') +
|
||||||
|
Math.floor(Date.now() / 1000);
|
||||||
const jwt = await new SignJWT(data)
|
const jwt = await new SignJWT(data)
|
||||||
.setProtectedHeader({ alg })
|
.setProtectedHeader({ alg })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setIssuer('urn:freeblox:auth')
|
.setIssuer('urn:freeblox:auth')
|
||||||
.setAudience(audience)
|
.setAudience(audience)
|
||||||
.setExpirationTime('8d')
|
.setExpirationTime(exp)
|
||||||
.sign(this.privateKey);
|
.sign(this.privateKey);
|
||||||
return jwt;
|
return jwt;
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(jwt: string, audience = 'urn:freeblox:service') {
|
async verify(jwt: string, audience = 'urn:freeblox:service') {
|
||||||
const alg = this.config.get('security.algorithm');
|
const alg = this.config.get('security.jwtAlgorithm');
|
||||||
const { payload, protectedHeader } = await jwtVerify(jwt, this.publicKey, {
|
const { payload, protectedHeader } = await jwtVerify(jwt, this.publicKey, {
|
||||||
issuer: 'urn:freeblox:auth',
|
issuer: 'urn:freeblox:auth',
|
||||||
audience,
|
audience,
|
||||||
@ -33,4 +37,33 @@ export class JWTService {
|
|||||||
throw new ForbiddenException('Provided JWT contains invalid headers.');
|
throw new ForbiddenException('Provided JWT contains invalid headers.');
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async encrypt(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
audience = 'urn:freeblox:service',
|
||||||
|
) {
|
||||||
|
const alg = this.config.get('security.secretAlgorithm');
|
||||||
|
const exp =
|
||||||
|
this.config.get('security.refreshTokenExpiry') +
|
||||||
|
Math.floor(Date.now() / 1000);
|
||||||
|
const jwt = await new EncryptJWT(data)
|
||||||
|
.setProtectedHeader({ alg: 'dir', enc: alg })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setIssuer('urn:freeblox:auth')
|
||||||
|
.setAudience(audience)
|
||||||
|
.setExpirationTime(exp)
|
||||||
|
.encrypt(this.secretKey);
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(jwt: string, audience = 'urn:freeblox:service') {
|
||||||
|
const alg = this.config.get('security.secretAlgorithm');
|
||||||
|
const { payload, protectedHeader } = await jwtDecrypt(jwt, this.secretKey, {
|
||||||
|
issuer: 'urn:freeblox:auth',
|
||||||
|
audience,
|
||||||
|
});
|
||||||
|
if (protectedHeader.enc !== alg)
|
||||||
|
throw new ForbiddenException('Provided JWT contains invalid headers.');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
120
apps/auth/src/services/refresh.service.ts
Normal file
120
apps/auth/src/services/refresh.service.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { UserTokenEntity } from '../database/entities/user-token.entity';
|
||||||
|
import { JWTService } from './jwt.service';
|
||||||
|
import { UserEntity } from '../database/entities/user.entity';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { UserTokenType, generateString } from '@freeblox/shared';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RefreshService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserTokenEntity)
|
||||||
|
private readonly userTokenRepository: Repository<UserTokenEntity>,
|
||||||
|
private readonly jwt: JWTService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find new refresh token for old token
|
||||||
|
* @param token Old token
|
||||||
|
* @returns New Token
|
||||||
|
*/
|
||||||
|
async findNewIteration(token: UserTokenEntity) {
|
||||||
|
return this.userTokenRepository.findOne({
|
||||||
|
where: {
|
||||||
|
previous: { id: token.id },
|
||||||
|
type: UserTokenType.REFRESH,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume a refresh token.
|
||||||
|
* @param token Refresh token
|
||||||
|
* @returns New refresh token
|
||||||
|
*/
|
||||||
|
async useRefreshToken(token: string) {
|
||||||
|
if (!token) throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
const decrypted = await this.jwt.decrypt(token);
|
||||||
|
|
||||||
|
if (!decrypted.token || !decrypted.sub)
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
|
||||||
|
const tokenEntity = await this.userTokenRepository.findOneOrFail({
|
||||||
|
where: {
|
||||||
|
token: decrypted.token as string,
|
||||||
|
type: UserTokenType.REFRESH,
|
||||||
|
},
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decrypted.sub !== tokenEntity.user.id)
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
|
||||||
|
// Using an expired refresh token
|
||||||
|
if (tokenEntity.expiresAt.getTime() < Date.now()) {
|
||||||
|
const newIteration = await this.findNewIteration(tokenEntity);
|
||||||
|
// We have already issued a new refresh token, this is probably stolen
|
||||||
|
// ..so we delete the whole tree.
|
||||||
|
if (newIteration) {
|
||||||
|
await this.userTokenRepository.remove(tokenEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark old token as expired
|
||||||
|
await this.userTokenRepository.update(
|
||||||
|
{ id: tokenEntity.id },
|
||||||
|
{ expiresAt: new Date() },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Issue a new refresh token
|
||||||
|
const newToken = await this.issueRefreshToken(
|
||||||
|
tokenEntity.user,
|
||||||
|
tokenEntity,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: newToken,
|
||||||
|
user: tokenEntity.user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a refresh token.
|
||||||
|
* @param user User
|
||||||
|
* @param previous Previous refresh token
|
||||||
|
* @returns New Refresh token
|
||||||
|
*/
|
||||||
|
async issueRefreshToken(user: UserEntity, previous?: UserTokenEntity) {
|
||||||
|
const newRefreshToken = await this.createRefreshToken(user);
|
||||||
|
const expiry = new Date(
|
||||||
|
Date.now() + this.config.get('security.refreshTokenExpiry') * 1000,
|
||||||
|
);
|
||||||
|
await this.userTokenRepository.save({
|
||||||
|
token: newRefreshToken.token,
|
||||||
|
user,
|
||||||
|
type: UserTokenType.REFRESH,
|
||||||
|
expiresAt: expiry,
|
||||||
|
previous,
|
||||||
|
});
|
||||||
|
return newRefreshToken.encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate refresh token and encrypt it
|
||||||
|
* @param user User to issue the token to
|
||||||
|
* @returns Encrypted and unencrypted tokens
|
||||||
|
*/
|
||||||
|
private async createRefreshToken(user: UserEntity) {
|
||||||
|
const token = generateString(512);
|
||||||
|
const encrypted = await this.jwt.encrypt({ sub: user.id, token });
|
||||||
|
return {
|
||||||
|
encrypted,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
78
apps/auth/src/services/role.service.ts
Normal file
78
apps/auth/src/services/role.service.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
|
import { UserEntity } from '../database/entities/user.entity';
|
||||||
|
import { RoleEntity } from '../database/entities/role.entity';
|
||||||
|
import { PrivilegeEntity } from '../database/entities/privilege.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoleService {
|
||||||
|
constructor(
|
||||||
|
@InjectEntityManager() private manager: EntityManager,
|
||||||
|
@InjectRepository(UserEntity)
|
||||||
|
private readonly userRepository: Repository<UserEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all applicable privileges for roles
|
||||||
|
* @param roles Roles to look for privileges for
|
||||||
|
* @param startingPrivileges Privileges already included
|
||||||
|
* @returns Unique list of privileges
|
||||||
|
*/
|
||||||
|
async getApplicablePrivileges(
|
||||||
|
roles: RoleEntity[],
|
||||||
|
startingPrivileges: PrivilegeEntity[] = [],
|
||||||
|
) {
|
||||||
|
const roleTree = await this.manager
|
||||||
|
.getTreeRepository(RoleEntity)
|
||||||
|
.findTrees({ relations: ['privileges'] });
|
||||||
|
|
||||||
|
const privileges: PrivilegeEntity[] = [...startingPrivileges];
|
||||||
|
for (const { id } of roles) {
|
||||||
|
for (const role of roleTree) {
|
||||||
|
const [result, list] = this.accumulatePrivilegesFromTree(role, id);
|
||||||
|
if (!result) continue;
|
||||||
|
privileges.push(...list);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return privileges.filter(
|
||||||
|
(value, index, array) =>
|
||||||
|
array.findIndex((entry) => entry.id === value.id) === index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all applicable privileges for user.
|
||||||
|
* @param user User
|
||||||
|
* @returns Unique privilege list
|
||||||
|
*/
|
||||||
|
async getUserPrivileges(user: UserEntity) {
|
||||||
|
if (!user.privileges || !user.roles) {
|
||||||
|
user = await this.userRepository.findOne({
|
||||||
|
where: { id: user.id },
|
||||||
|
relations: ['privileges', 'roles'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getApplicablePrivileges(user.roles, user.privileges);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accumulate privileges from a tree structure of roles.
|
||||||
|
* @param root Node to start from
|
||||||
|
* @param target Target role ID
|
||||||
|
* @returns `[<Found role entity>, <Privilege list>]`
|
||||||
|
*/
|
||||||
|
private accumulatePrivilegesFromTree(root: RoleEntity, target: number) {
|
||||||
|
const privileges: PrivilegeEntity[] = [...root.privileges];
|
||||||
|
if (root.id === target) return [root, privileges];
|
||||||
|
for (const child of root.children || []) {
|
||||||
|
const [found, list] = this.accumulatePrivilegesFromTree(child, target);
|
||||||
|
if (found) return [found, [...privileges, ...list]];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return [null, []];
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const User = createParamDecorator(
|
||||||
|
(data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const response = ctx.switchToHttp().getResponse();
|
||||||
|
return response.locals.user;
|
||||||
|
},
|
||||||
|
);
|
30
apps/freeblox-web-service/src/guards/auth.guard.ts
Normal file
30
apps/freeblox-web-service/src/guards/auth.guard.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ClientProxy } from '@nestjs/microservices';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(@Inject('auth') private authClient: ClientProxy) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest() as Request;
|
||||||
|
const response = context.switchToHttp().getResponse() as Response;
|
||||||
|
if (!request.headers.authorization) return false;
|
||||||
|
|
||||||
|
// Verify token by auth microservice
|
||||||
|
const [, token] = request.headers.authorization.split(' ');
|
||||||
|
const user = await lastValueFrom(
|
||||||
|
this.authClient.send('auth.verify', { token }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add token contents to locals
|
||||||
|
response.locals.user = user;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,7 @@ async function bootstrap() {
|
|||||||
.setTitle('Freeblox Web Service')
|
.setTitle('Freeblox Web Service')
|
||||||
.setDescription('Freeblox Web Service API gateway')
|
.setDescription('Freeblox Web Service API gateway')
|
||||||
.setVersion('1.0')
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api', app, document);
|
SwaggerModule.setup('api', app, document);
|
||||||
|
@ -2,25 +2,64 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
ClassSerializerInterceptor,
|
ClassSerializerInterceptor,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
Inject,
|
Inject,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ClientProxy } from '@nestjs/microservices';
|
import { ClientProxy } from '@nestjs/microservices';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { LoginDto } from './dtos/login.dto';
|
import { LoginDto } from './dtos/login.dto';
|
||||||
|
import { User } from '../../decorators/user.decorator';
|
||||||
|
import { UserInfo } from '@freeblox/shared';
|
||||||
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
import { AuthGuard } from '../../guards/auth.guard';
|
||||||
|
import { UserDto } from './dtos/user.dto';
|
||||||
|
import { LoginResponseDto } from './dtos/login-response.dto';
|
||||||
|
import { LoginByRefreshTokenDto } from './dtos/login-refresh-token.dto';
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
version: '1',
|
version: '1',
|
||||||
path: 'auth',
|
path: 'auth',
|
||||||
})
|
})
|
||||||
|
@ApiBearerAuth()
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@UseInterceptors(ClassSerializerInterceptor)
|
@UseInterceptors(ClassSerializerInterceptor)
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(@Inject('auth') private auth: ClientProxy) {}
|
constructor(@Inject('auth') private auth: ClientProxy) {}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: 'Login by username or email and password' })
|
||||||
|
@ApiOkResponse({ type: LoginResponseDto })
|
||||||
async login(@Body() body: LoginDto) {
|
async login(@Body() body: LoginDto) {
|
||||||
return this.auth.send('auth.login', { body });
|
return this.auth.send('auth.login', { body });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@ApiOperation({ summary: 'Login by refresh token' })
|
||||||
|
@ApiOkResponse({ type: LoginResponseDto })
|
||||||
|
async refresh(@Body() body: LoginByRefreshTokenDto) {
|
||||||
|
return this.auth.send('auth.loginByRefreshToken', { token: body.token });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiOperation({ summary: 'Current user information' })
|
||||||
|
@ApiOkResponse({ type: UserDto })
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
async myInfo(@User() user: UserInfo): Promise<UserDto> {
|
||||||
|
return lastValueFrom(this.auth.send('auth.getUserById', { id: user.sub }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('bans')
|
||||||
|
@ApiOperation({ summary: 'Current user ban history' })
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
async banInfo(@User() user: UserInfo) {
|
||||||
|
return lastValueFrom(this.auth.send('auth.getUserBans', { user }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginByRefreshTokenDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
token: string;
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LoginResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
refresh: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
expires_in: number;
|
||||||
|
}
|
33
apps/freeblox-web-service/src/services/auth/dtos/user.dto.ts
Normal file
33
apps/freeblox-web-service/src/services/auth/dtos/user.dto.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UserDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
country: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
language: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
verified: boolean;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
activated: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Date })
|
||||||
|
loginAt: string;
|
||||||
|
}
|
@ -37,6 +37,7 @@ services:
|
|||||||
- POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123
|
- POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123
|
||||||
- PRIVATE_KEY_FILE=private/jwt.private.pem
|
- PRIVATE_KEY_FILE=private/jwt.private.pem
|
||||||
- PUBLIC_KEY_FILE=private/jwt.public.pem
|
- PUBLIC_KEY_FILE=private/jwt.public.pem
|
||||||
|
- SECRET_KEY=mkt9Hngcmhbd9wX4EzGbGysDWzCo793XvvswOS+wolTVM83I1K2b/j41WwsCfsv1iS901N2rTHu2hZHbsYO3RQ==
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps:/usr/src/app/apps
|
- ./apps:/usr/src/app/apps
|
||||||
- ./libs:/usr/src/app/libs
|
- ./libs:/usr/src/app/libs
|
||||||
|
@ -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),
|
||||||
);
|
);
|
||||||
|
22
libs/shared/src/database/metaentity.ts
Normal file
22
libs/shared/src/database/metaentity.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export class MetaEntity {
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
@Exclude()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
@Exclude()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserMetaEntity extends MetaEntity {
|
||||||
|
@Column({ name: 'created_by' })
|
||||||
|
@Exclude()
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by' })
|
||||||
|
@Exclude()
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
@ -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';
|
||||||
|
12
libs/shared/src/types/user-token.enum.ts
Normal file
12
libs/shared/src/types/user-token.enum.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export enum UserTokenType {
|
||||||
|
GENERIC = 'generic',
|
||||||
|
ACTIVATION = 'activation',
|
||||||
|
DEACTIVATION = 'deactivation',
|
||||||
|
PASSWORD = 'password',
|
||||||
|
LOGIN = 'login',
|
||||||
|
GDPR = 'gdpr',
|
||||||
|
TOTP = 'totp',
|
||||||
|
PUBLIC_KEY = 'public_key',
|
||||||
|
RECOVERY = 'recovery',
|
||||||
|
REFRESH = 'refresh',
|
||||||
|
}
|
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