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 { MessagePattern } from '@nestjs/microservices';
|
||||
import { LoginRequest } from './interfaces/auth.interface';
|
||||
import { UserInfo } from '@freeblox/shared';
|
||||
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
@ -11,4 +12,24 @@ export class AuthController {
|
||||
login({ body }: { body: LoginRequest }) {
|
||||
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 { keysProviders } from './providers/keys.providers';
|
||||
import { JWTService } from './services/jwt.service';
|
||||
import { UserEntity } from './database/entities/user.entity';
|
||||
import { UserTokenEntity } from './database/entities/user-token.entity';
|
||||
import { OTPService } from './services/otp.service';
|
||||
import { BanEntity } from './database/entities/ban.entity';
|
||||
import { BanService } from './services/ban.service';
|
||||
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({
|
||||
imports: [
|
||||
@ -19,12 +36,24 @@ import { JWTService } from './services/jwt.service';
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => config.get('typeorm'),
|
||||
useFactory: (config: ConfigService) => ({
|
||||
...config.get('typeorm'),
|
||||
entities,
|
||||
}),
|
||||
}),
|
||||
TypeOrmModule.forFeature(entities),
|
||||
ClientsModule.register([natsClient('auth')]),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, ...keysProviders, JWTService],
|
||||
providers: [
|
||||
...keysProviders,
|
||||
JWTService,
|
||||
OTPService,
|
||||
BanService,
|
||||
RoleService,
|
||||
RefreshService,
|
||||
AuthService,
|
||||
],
|
||||
})
|
||||
export class AuthModule implements OnModuleInit {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
@ -2,7 +2,11 @@ import { registerAs } from '@nestjs/config';
|
||||
import { resolve } from 'path';
|
||||
|
||||
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)),
|
||||
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 {
|
||||
email: 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')
|
||||
.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>{
|
||||
provide: 'APP_PUBLIC_KEY',
|
||||
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 { 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()
|
||||
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) {
|
||||
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 { ConfigService } from '@nestjs/config';
|
||||
import { JWK, KeyLike, SignJWT, jwtVerify } from 'jose';
|
||||
import { EncryptJWT, JWK, KeyLike, SignJWT, jwtDecrypt, jwtVerify } from 'jose';
|
||||
|
||||
@Injectable()
|
||||
export class JWTService {
|
||||
constructor(
|
||||
@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_JWK') private readonly publicKeyJWK: JWK,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
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)
|
||||
.setProtectedHeader({ alg })
|
||||
.setIssuedAt()
|
||||
.setIssuer('urn:freeblox:auth')
|
||||
.setAudience(audience)
|
||||
.setExpirationTime('8d')
|
||||
.setExpirationTime(exp)
|
||||
.sign(this.privateKey);
|
||||
return jwt;
|
||||
}
|
||||
|
||||
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, {
|
||||
issuer: 'urn:freeblox:auth',
|
||||
audience,
|
||||
@ -33,4 +37,33 @@ export class JWTService {
|
||||
throw new ForbiddenException('Provided JWT contains invalid headers.');
|
||||
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')
|
||||
.setDescription('Freeblox Web Service API gateway')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api', app, document);
|
||||
|
@ -2,25 +2,64 @@ import {
|
||||
Body,
|
||||
ClassSerializerInterceptor,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
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 { 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({
|
||||
version: '1',
|
||||
path: 'auth',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('Auth')
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
export class AuthController {
|
||||
constructor(@Inject('auth') private auth: ClientProxy) {}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login by username or email and password' })
|
||||
@ApiOkResponse({ type: LoginResponseDto })
|
||||
async login(@Body() body: LoginDto) {
|
||||
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
|
||||
- PRIVATE_KEY_FILE=private/jwt.private.pem
|
||||
- PUBLIC_KEY_FILE=private/jwt.public.pem
|
||||
- SECRET_KEY=mkt9Hngcmhbd9wX4EzGbGysDWzCo793XvvswOS+wolTVM83I1K2b/j41WwsCfsv1iS901N2rTHu2hZHbsYO3RQ==
|
||||
volumes:
|
||||
- ./apps:/usr/src/app/apps
|
||||
- ./libs:/usr/src/app/libs
|
||||
|
@ -12,6 +12,5 @@ export const makeTypeOrm = (database: string) =>
|
||||
username: String(process.env.POSTGRES_USER),
|
||||
password: String(process.env.POSTGRES_PASSWORD),
|
||||
database,
|
||||
autoLoadEntities: true,
|
||||
} as TypeOrmModuleOptions),
|
||||
);
|
||||
|
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.service';
|
||||
export * from './utils/nats-client';
|
||||
export * from './utils/tokens';
|
||||
export * from './database/make-typeorm';
|
||||
export * from './database/make-knex';
|
||||
export * from './database/metaentity';
|
||||
export * from './types/user-token.enum';
|
||||
export * from './types/userinfo';
|
||||
|
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",
|
||||
"knex": "^2.4.2",
|
||||
"nats": "^2.15.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pg": "^8.11.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.17"
|
||||
"typeorm": "^0.3.17",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.5",
|
||||
|
@ -53,6 +53,9 @@ dependencies:
|
||||
nats:
|
||||
specifier: ^2.15.1
|
||||
version: 2.15.1
|
||||
otplib:
|
||||
specifier: ^12.0.1
|
||||
version: 12.0.1
|
||||
pg:
|
||||
specifier: ^8.11.1
|
||||
version: 8.11.1
|
||||
@ -68,6 +71,9 @@ dependencies:
|
||||
typeorm:
|
||||
specifier: ^0.3.17
|
||||
version: 0.3.17(pg@8.11.1)(ts-node@10.9.1)
|
||||
uuid:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0
|
||||
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
@ -1236,6 +1242,39 @@ packages:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
/@otplib/core@12.0.1:
|
||||
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
|
||||
dev: false
|
||||
|
||||
/@otplib/plugin-crypto@12.0.1:
|
||||
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
dev: false
|
||||
|
||||
/@otplib/plugin-thirty-two@12.0.1:
|
||||
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
thirty-two: 1.0.2
|
||||
dev: false
|
||||
|
||||
/@otplib/preset-default@12.0.1:
|
||||
resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
'@otplib/plugin-crypto': 12.0.1
|
||||
'@otplib/plugin-thirty-two': 12.0.1
|
||||
dev: false
|
||||
|
||||
/@otplib/preset-v11@12.0.1:
|
||||
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
'@otplib/plugin-crypto': 12.0.1
|
||||
'@otplib/plugin-thirty-two': 12.0.1
|
||||
dev: false
|
||||
|
||||
/@pkgjs/parseargs@0.11.0:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@ -4519,6 +4558,14 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/otplib@12.0.1:
|
||||
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
'@otplib/preset-default': 12.0.1
|
||||
'@otplib/preset-v11': 12.0.1
|
||||
dev: false
|
||||
|
||||
/p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
@ -5402,6 +5449,11 @@ packages:
|
||||
any-promise: 1.3.0
|
||||
dev: false
|
||||
|
||||
/thirty-two@1.0.2:
|
||||
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
|
||||
engines: {node: '>=0.2.6'}
|
||||
dev: false
|
||||
|
||||
/through@2.3.8:
|
||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||
dev: true
|
||||
|
Loading…
Reference in New Issue
Block a user