auth changes, exception filters, two new services
This commit is contained in:
parent
529e4a96d7
commit
b6460f1cda
22
apps/assets/src/assets.controller.spec.ts
Normal file
22
apps/assets/src/assets.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
|
||||
describe('AssetsController', () => {
|
||||
let assetsController: AssetsController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AssetsController],
|
||||
providers: [AssetsService],
|
||||
}).compile();
|
||||
|
||||
assetsController = app.get<AssetsController>(AssetsController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(assetsController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
12
apps/assets/src/assets.controller.ts
Normal file
12
apps/assets/src/assets.controller.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AssetsService } from './assets.service';
|
||||
|
||||
@Controller()
|
||||
export class AssetsController {
|
||||
constructor(private readonly assetsService: AssetsService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.assetsService.getHello();
|
||||
}
|
||||
}
|
29
apps/assets/src/assets.module.ts
Normal file
29
apps/assets/src/assets.module.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { makeKnex, makeTypeOrm, natsClient } from '@freeblox/shared';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClientsModule } from '@nestjs/microservices';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'development',
|
||||
load: [makeKnex('assets', __dirname), makeTypeOrm('assets')],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => config.get('typeorm'),
|
||||
}),
|
||||
ClientsModule.register([
|
||||
natsClient('assets'),
|
||||
natsClient('auth'),
|
||||
natsClient('player'),
|
||||
]),
|
||||
],
|
||||
controllers: [AssetsController],
|
||||
providers: [AssetsService],
|
||||
})
|
||||
export class AssetsModule {}
|
8
apps/assets/src/assets.service.ts
Normal file
8
apps/assets/src/assets.service.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AssetsService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
18
apps/assets/src/main.ts
Normal file
18
apps/assets/src/main.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AssetsModule } from './assets.module';
|
||||
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
|
||||
AssetsModule,
|
||||
{
|
||||
transport: Transport.NATS,
|
||||
options: {
|
||||
servers: [String(process.env.NATS_ENTRYPOINT)],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await app.listen();
|
||||
}
|
||||
bootstrap();
|
24
apps/assets/test/app.e2e-spec.ts
Normal file
24
apps/assets/test/app.e2e-spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AssetsModule } from './../src/assets.module';
|
||||
|
||||
describe('AssetsController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AssetsModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
9
apps/assets/test/jest-e2e.json
Normal file
9
apps/assets/test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
9
apps/assets/tsconfig.app.json
Normal file
9
apps/assets/tsconfig.app.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"outDir": "../../dist/apps/assets"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
@ -20,7 +20,7 @@ export class AuthController {
|
||||
|
||||
@MessagePattern('auth.verify')
|
||||
verify({ token }: { token: string }) {
|
||||
return this.authService.verifyToken(token);
|
||||
return this.authService.verifyAccessToken(token);
|
||||
}
|
||||
|
||||
@MessagePattern('auth.getUserById')
|
||||
|
@ -3,11 +3,14 @@ import {
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
import { PrivilegeEntity } from './privilege.entity';
|
||||
|
||||
@Entity('bans')
|
||||
@Expose()
|
||||
@ -18,6 +21,9 @@ export class BanEntity {
|
||||
@Column({ nullable: false })
|
||||
reason: string;
|
||||
|
||||
@Column({ default: false, name: 'privilege_ban' })
|
||||
privilegeBan: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
ip: string;
|
||||
|
||||
@ -39,4 +45,12 @@ export class BanEntity {
|
||||
@JoinColumn({ name: 'admin_id' })
|
||||
@Exclude()
|
||||
admin: UserEntity;
|
||||
|
||||
@ManyToMany(() => PrivilegeEntity, { onDelete: 'CASCADE' })
|
||||
@JoinTable({
|
||||
name: 'ban_privilege',
|
||||
joinColumn: { name: 'ban_id' },
|
||||
inverseJoinColumn: { name: 'privilege_id' },
|
||||
})
|
||||
privileges: PrivilegeEntity[];
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { Exclude, Expose } from 'class-transformer';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
@ -89,4 +90,8 @@ export class UserEntity extends MetaEntity {
|
||||
})
|
||||
@Exclude()
|
||||
roles: RoleEntity[];
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at' })
|
||||
@Exclude()
|
||||
deletedAt: Date;
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
|
||||
table.timestamps(true, true);
|
||||
table.timestamp('login_at');
|
||||
table.timestamp('deleted_at');
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.increments('id').primary();
|
||||
|
||||
table.text('reason').notNullable();
|
||||
table.boolean('privilege_ban').defaultTo(false);
|
||||
|
||||
table.string('ip', 255).nullable();
|
||||
table.integer('cidr', 2).nullable().defaultTo(32).unsigned();
|
||||
|
@ -0,0 +1,18 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
return knex.schema.createTable('ban_privilege', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('privilege_id').nullable().unsigned();
|
||||
table.integer('ban_id').nullable();
|
||||
table
|
||||
.foreign('privilege_id')
|
||||
.references('privileges.id')
|
||||
.onDelete('CASCADE');
|
||||
table.foreign('ban_id').references('bans.id').onDelete('CASCADE');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
return knex.schema.dropTable('ban_privilege');
|
||||
}
|
@ -1,21 +1,36 @@
|
||||
import { hash } from 'bcrypt';
|
||||
import { hashSync } from 'bcrypt';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
const userExists = await knex('users').where({ username: 'freeblox' });
|
||||
if (userExists?.length) return;
|
||||
|
||||
await knex('users').insert([
|
||||
const initialUsers = [
|
||||
{
|
||||
username: 'freeblox',
|
||||
email: 'freeblox@icynet.eu',
|
||||
phone: null,
|
||||
country: 'ee',
|
||||
language: 'en',
|
||||
password: await hash('FBLXAdmin123', 12),
|
||||
password: hashSync('FBLXAdmin123', 12),
|
||||
display_name: 'Freeblox',
|
||||
verified: true,
|
||||
activated: true,
|
||||
},
|
||||
]);
|
||||
{
|
||||
username: 'noob',
|
||||
email: 'noob@icynet.eu',
|
||||
phone: null,
|
||||
country: 'ee',
|
||||
language: 'en',
|
||||
password: hashSync('FBLXNoob123', 12),
|
||||
display_name: 'Noob',
|
||||
verified: true,
|
||||
activated: true,
|
||||
},
|
||||
];
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
for (const user of initialUsers) {
|
||||
const userExists = await knex('users').where({ username: user.username });
|
||||
if (userExists?.length) continue;
|
||||
|
||||
await knex('users').insert(user);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
const initialRoles = [
|
||||
const initialRoles = [
|
||||
{
|
||||
name: 'player',
|
||||
automatic: true,
|
||||
@ -31,14 +30,19 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
id: 0,
|
||||
path: '',
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
const initialPrivileges = [
|
||||
const initialPrivileges = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'web',
|
||||
roles: ['player', 'reduced'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'report',
|
||||
roles: ['player', 'reduced'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'play',
|
||||
@ -51,7 +55,7 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'build',
|
||||
name: 'community',
|
||||
roles: ['player'],
|
||||
},
|
||||
{
|
||||
@ -69,10 +73,40 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
name: 'host',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'create:game',
|
||||
roles: ['player'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'create:clothing',
|
||||
roles: ['player'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'create:accessory',
|
||||
roles: ['member'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'create:character',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'contentmod',
|
||||
roles: ['moderator'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'ban',
|
||||
roles: ['moderator', 'admin'],
|
||||
roles: ['moderator'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'privban',
|
||||
roles: ['moderator'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
@ -81,19 +115,71 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'stopserver',
|
||||
name: 'contentedit',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'banserver',
|
||||
name: 'server:quarantine',
|
||||
roles: ['moderator'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'server:stop',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'server:banhost',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'server:provision',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: 'root',
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
const giveUserRole = async (username: string, role: string) => {
|
||||
const userExists = await knex('users').where({ username });
|
||||
if (!userExists?.length) return;
|
||||
|
||||
const adminRole = initialRoles.find((entry) => entry.name === role);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const giveUserPrivilege = async (username: string, privilege: string[]) => {
|
||||
const userExists = await knex('users').where({ username });
|
||||
if (!userExists?.length) return;
|
||||
|
||||
const privileges = initialPrivileges.filter((entry) =>
|
||||
privilege.includes(entry.name),
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
for (const role of initialRoles) {
|
||||
const exists = await knex('roles').where({
|
||||
@ -170,31 +256,7 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
await giveUserRole('freeblox', 'admin');
|
||||
await giveUserRole('noob', 'player');
|
||||
await giveUserPrivilege('freeblox', ['root', 'host']);
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
||||
import { AuthModule } from './auth.module';
|
||||
import { HttpRpcExceptionFilter } from '@freeblox/shared';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
|
||||
@ -13,6 +14,7 @@ async function bootstrap() {
|
||||
},
|
||||
);
|
||||
|
||||
app.useGlobalFilters(new HttpRpcExceptionFilter());
|
||||
await app.listen();
|
||||
}
|
||||
bootstrap();
|
||||
|
@ -1,9 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
PreconditionFailedException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { LoginRequest } from '../interfaces/auth.interface';
|
||||
import { JWTService } from './jwt.service';
|
||||
import { ILike, Repository } from 'typeorm';
|
||||
@ -13,7 +8,12 @@ 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 {
|
||||
BadRequestRpcException,
|
||||
ForbiddenRpcException,
|
||||
PreconditionFailedRpcException,
|
||||
UserInfo,
|
||||
} from '@freeblox/shared';
|
||||
import { RoleService } from './role.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { RefreshService } from './refresh.service';
|
||||
@ -38,7 +38,7 @@ export class AuthService {
|
||||
*/
|
||||
async login(body: LoginRequest) {
|
||||
if (!body.email || !body.password) {
|
||||
throw new BadRequestException('Invalid username or password');
|
||||
throw new BadRequestRpcException('Invalid username or password');
|
||||
}
|
||||
|
||||
// Prevent wildcards
|
||||
@ -58,20 +58,20 @@ export class AuthService {
|
||||
|
||||
// User not found
|
||||
if (!userEntity) {
|
||||
throw new BadRequestException('Invalid username or password');
|
||||
throw new BadRequestRpcException('Invalid username or password');
|
||||
}
|
||||
|
||||
// Compare passwords
|
||||
const passwordMatch = await compare(body.password, userEntity.password);
|
||||
if (!passwordMatch) {
|
||||
throw new BadRequestException('Invalid username or password');
|
||||
throw new BadRequestRpcException('Invalid username or password');
|
||||
}
|
||||
|
||||
// Check TOTP
|
||||
const userOTPToken = await this.otpService.getUserTOTP(userEntity);
|
||||
if (userOTPToken) {
|
||||
if (!body.totpToken) {
|
||||
throw new PreconditionFailedException('TOTP Token required');
|
||||
throw new PreconditionFailedRpcException('TOTP Token required');
|
||||
}
|
||||
|
||||
const validate = this.otpService.validateTOTP(
|
||||
@ -80,13 +80,13 @@ export class AuthService {
|
||||
);
|
||||
|
||||
if (!validate) {
|
||||
throw new ForbiddenException('Invalid TOTP Token');
|
||||
throw new ForbiddenRpcException('Invalid TOTP Token');
|
||||
}
|
||||
}
|
||||
|
||||
// Issue access token
|
||||
const exp = this.config.get('security.jwtTokenExpiry');
|
||||
const issuedToken = await this.issueToken(userEntity);
|
||||
const issuedToken = await this.issueAccessToken(userEntity);
|
||||
|
||||
// Issue refresh token
|
||||
const refreshToken = await this.refreshService.issueRefreshToken(
|
||||
@ -112,7 +112,7 @@ export class AuthService {
|
||||
|
||||
// Issue new access token
|
||||
const exp = this.config.get('security.jwtTokenExpiry');
|
||||
const issuedToken = await this.issueToken(userEntity);
|
||||
const issuedToken = await this.issueAccessToken(userEntity);
|
||||
|
||||
// Set login time to now
|
||||
await this.userRepository.update(
|
||||
@ -133,7 +133,7 @@ export class AuthService {
|
||||
* @returns User entity
|
||||
*/
|
||||
async getUserFromToken(token: string) {
|
||||
const tokenInfo = await this.verifyToken(token);
|
||||
const tokenInfo = await this.verifyAccessToken(token);
|
||||
const user = await this.userRepository.findOneByOrFail({
|
||||
id: tokenInfo.sub,
|
||||
activated: true,
|
||||
@ -146,12 +146,11 @@ export class AuthService {
|
||||
* @param token JWT token
|
||||
* @returns User token info
|
||||
*/
|
||||
async verifyToken(token: string) {
|
||||
async verifyAccessToken(token: string) {
|
||||
try {
|
||||
return await this.jwtService.verify(token);
|
||||
} catch (e) {
|
||||
console.error(token, e);
|
||||
throw new ForbiddenException('Invalid token');
|
||||
throw new ForbiddenRpcException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,7 +160,7 @@ export class AuthService {
|
||||
* @returns User entity
|
||||
*/
|
||||
async getUserById(id: string) {
|
||||
if (!id) throw new BadRequestException('ID is required');
|
||||
if (!id) throw new BadRequestRpcException('ID is required');
|
||||
return instanceToPlain(await this.userRepository.findOneByOrFail({ id }));
|
||||
}
|
||||
|
||||
@ -178,13 +177,26 @@ export class AuthService {
|
||||
return instanceToPlain(bans);
|
||||
}
|
||||
|
||||
private async issueToken(user: UserEntity) {
|
||||
/**
|
||||
* Issue new JWT for user
|
||||
* @private
|
||||
* @param user
|
||||
* @returns JWT
|
||||
*/
|
||||
async issueAccessToken(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);
|
||||
const bannedPrivileges = bans.reduce(
|
||||
(privileges, ban) => [...privileges, ...(ban.privileges || [])],
|
||||
[],
|
||||
);
|
||||
const privileges = await this.roleService.getUserPrivileges(
|
||||
user,
|
||||
bannedPrivileges,
|
||||
);
|
||||
|
||||
// Issue new token
|
||||
return this.jwtService.sign({
|
||||
|
@ -23,12 +23,16 @@ export class BanService {
|
||||
user: { id: user.id },
|
||||
},
|
||||
],
|
||||
relations: ['privileges'],
|
||||
});
|
||||
}
|
||||
|
||||
async getAllBansForUser(user: UserEntity) {
|
||||
return this.banRepository.findBy({
|
||||
return this.banRepository.find({
|
||||
where: {
|
||||
user: { id: user.id },
|
||||
},
|
||||
relations: ['privileges'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { ForbiddenRpcException } from '@freeblox/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EncryptJWT, JWK, KeyLike, SignJWT, jwtDecrypt, jwtVerify } from 'jose';
|
||||
|
||||
@ -34,7 +35,7 @@ export class JWTService {
|
||||
audience,
|
||||
});
|
||||
if (protectedHeader.alg !== alg)
|
||||
throw new ForbiddenException('Provided JWT contains invalid headers.');
|
||||
throw new ForbiddenRpcException('Provided JWT contains invalid headers.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
@ -63,7 +64,7 @@ export class JWTService {
|
||||
audience,
|
||||
});
|
||||
if (protectedHeader.enc !== alg)
|
||||
throw new ForbiddenException('Provided JWT contains invalid headers.');
|
||||
throw new ForbiddenRpcException('Provided JWT contains invalid headers.');
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable } 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 {
|
||||
UnauthorizedRpcException,
|
||||
UserTokenType,
|
||||
generateString,
|
||||
} from '@freeblox/shared';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
@ -36,11 +40,11 @@ export class RefreshService {
|
||||
* @returns New refresh token
|
||||
*/
|
||||
async useRefreshToken(token: string) {
|
||||
if (!token) throw new UnauthorizedException('Invalid refresh token');
|
||||
if (!token) throw new UnauthorizedRpcException('Invalid refresh token');
|
||||
const decrypted = await this.jwt.decrypt(token);
|
||||
|
||||
if (!decrypted.token || !decrypted.sub)
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
throw new UnauthorizedRpcException('Invalid refresh token');
|
||||
|
||||
const tokenEntity = await this.userTokenRepository.findOneOrFail({
|
||||
where: {
|
||||
@ -51,7 +55,7 @@ export class RefreshService {
|
||||
});
|
||||
|
||||
if (decrypted.sub !== tokenEntity.user.id)
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
throw new UnauthorizedRpcException('Invalid refresh token');
|
||||
|
||||
// Using an expired refresh token
|
||||
if (tokenEntity.expiresAt.getTime() < Date.now()) {
|
||||
@ -62,7 +66,7 @@ export class RefreshService {
|
||||
await this.userTokenRepository.remove(tokenEntity);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
throw new UnauthorizedRpcException('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Mark old token as expired
|
||||
|
@ -48,7 +48,7 @@ export class RoleService {
|
||||
* @param user User
|
||||
* @returns Unique privilege list
|
||||
*/
|
||||
async getUserPrivileges(user: UserEntity) {
|
||||
async getUserPrivileges(user: UserEntity, exclude: PrivilegeEntity[] = []) {
|
||||
if (!user.privileges || !user.roles) {
|
||||
user = await this.userRepository.findOne({
|
||||
where: { id: user.id },
|
||||
@ -56,7 +56,15 @@ export class RoleService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.getApplicablePrivileges(user.roles, user.privileges);
|
||||
const applicable = await this.getApplicablePrivileges(
|
||||
user.roles,
|
||||
user.privileges,
|
||||
);
|
||||
|
||||
// Exclude privileges provided
|
||||
return applicable.filter(
|
||||
(privilege) => !exclude.some((entry) => entry.id === privilege.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
22
apps/bank/src/bank.controller.spec.ts
Normal file
22
apps/bank/src/bank.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BankController } from './bank.controller';
|
||||
import { BankService } from './bank.service';
|
||||
|
||||
describe('BankController', () => {
|
||||
let bankController: BankController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [BankController],
|
||||
providers: [BankService],
|
||||
}).compile();
|
||||
|
||||
bankController = app.get<BankController>(BankController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(bankController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
12
apps/bank/src/bank.controller.ts
Normal file
12
apps/bank/src/bank.controller.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { BankService } from './bank.service';
|
||||
|
||||
@Controller()
|
||||
export class BankController {
|
||||
constructor(private readonly bankService: BankService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.bankService.getHello();
|
||||
}
|
||||
}
|
29
apps/bank/src/bank.module.ts
Normal file
29
apps/bank/src/bank.module.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BankController } from './bank.controller';
|
||||
import { BankService } from './bank.service';
|
||||
import { makeKnex, makeTypeOrm, natsClient } from '@freeblox/shared';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClientsModule } from '@nestjs/microservices';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'development',
|
||||
load: [makeKnex('bank', __dirname), makeTypeOrm('bank')],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => config.get('typeorm'),
|
||||
}),
|
||||
ClientsModule.register([
|
||||
natsClient('bank'),
|
||||
natsClient('auth'),
|
||||
natsClient('player'),
|
||||
]),
|
||||
],
|
||||
controllers: [BankController],
|
||||
providers: [BankService],
|
||||
})
|
||||
export class BankModule {}
|
8
apps/bank/src/bank.service.ts
Normal file
8
apps/bank/src/bank.service.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class BankService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
18
apps/bank/src/main.ts
Normal file
18
apps/bank/src/main.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { BankModule } from './bank.module';
|
||||
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
|
||||
BankModule,
|
||||
{
|
||||
transport: Transport.NATS,
|
||||
options: {
|
||||
servers: [String(process.env.NATS_ENTRYPOINT)],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await app.listen();
|
||||
}
|
||||
bootstrap();
|
24
apps/bank/test/app.e2e-spec.ts
Normal file
24
apps/bank/test/app.e2e-spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { BankModule } from './../src/bank.module';
|
||||
|
||||
describe('BankController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [BankModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
9
apps/bank/test/jest-e2e.json
Normal file
9
apps/bank/test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
9
apps/bank/tsconfig.app.json
Normal file
9
apps/bank/tsconfig.app.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"outDir": "../../dist/apps/bank"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
25
apps/freeblox-web-service/src/filters/exception.filter.ts
Normal file
25
apps/freeblox-web-service/src/filters/exception.filter.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const status = exception.getStatus();
|
||||
const message = exception.message;
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
@ -21,7 +23,9 @@ export class AuthGuard implements CanActivate {
|
||||
const [, token] = request.headers.authorization.split(' ');
|
||||
const user = await lastValueFrom(
|
||||
this.authClient.send('auth.verify', { token }),
|
||||
);
|
||||
).catch((err) => {
|
||||
throw new HttpException(err.response, err.status || HttpStatus.FORBIDDEN);
|
||||
});
|
||||
|
||||
// Add token contents to locals
|
||||
response.locals.user = user;
|
||||
|
@ -0,0 +1,26 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, catchError, throwError } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class CatchRpcExceptionInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
catchError((err) => {
|
||||
return throwError(
|
||||
() =>
|
||||
new HttpException(
|
||||
err.response,
|
||||
err.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { CatchRpcExceptionInterceptor } from './interceptors/catch-rpc-exception.interceptor';
|
||||
import { HttpExceptionFilter } from './filters/exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
@ -14,6 +16,9 @@ async function bootstrap() {
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api', app, document);
|
||||
|
||||
app.useGlobalInterceptors(new CatchRpcExceptionInterceptor());
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
@ -13,7 +13,7 @@ services:
|
||||
container_name: fblx-postgres
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
- POSTGRES_MULTIPLE_DATABASES=auth,catalog,game,player,server,session
|
||||
- POSTGRES_MULTIPLE_DATABASES=auth,catalog,game,player,server,session,bank,assets
|
||||
- POSTGRES_USER=freeblox
|
||||
- POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123
|
||||
volumes:
|
||||
@ -127,6 +127,40 @@ services:
|
||||
volumes:
|
||||
- ./apps:/usr/src/app/apps
|
||||
- ./libs:/usr/src/app/libs
|
||||
bank:
|
||||
container_name: fblx-bank
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
- SERVICE=bank
|
||||
networks:
|
||||
- fblx
|
||||
environment:
|
||||
- NATS_ENTRYPOINT=nats://nats:4222
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=freeblox
|
||||
- POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123
|
||||
volumes:
|
||||
- ./apps:/usr/src/app/apps
|
||||
- ./libs:/usr/src/app/libs
|
||||
assets:
|
||||
container_name: fblx-assets
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
- SERVICE=assets
|
||||
networks:
|
||||
- fblx
|
||||
environment:
|
||||
- NATS_ENTRYPOINT=nats://nats:4222
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=freeblox
|
||||
- POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123
|
||||
volumes:
|
||||
- ./apps:/usr/src/app/apps
|
||||
- ./libs:/usr/src/app/libs
|
||||
web-service:
|
||||
container_name: fblx-web-service
|
||||
build:
|
||||
|
55
libs/shared/src/exception/rpc.exception.ts
Normal file
55
libs/shared/src/exception/rpc.exception.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class HttpRpcException {
|
||||
isRpcException = true;
|
||||
|
||||
constructor(
|
||||
public readonly error: string | object,
|
||||
public status: HttpStatus,
|
||||
public stack?: string,
|
||||
) {
|
||||
Error.captureStackTrace(this, HttpRpcException);
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getError() {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
get message() {
|
||||
return this.error.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestRpcException extends HttpRpcException {
|
||||
constructor(message: string | object) {
|
||||
super(message, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundRpcException extends HttpRpcException {
|
||||
constructor(message: string | object) {
|
||||
super(message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenRpcException extends HttpRpcException {
|
||||
constructor(message: string | object) {
|
||||
super(message, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedRpcException extends HttpRpcException {
|
||||
constructor(message: string | object) {
|
||||
super(message, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
export class PreconditionFailedRpcException extends HttpRpcException {
|
||||
constructor(message: string | object) {
|
||||
super(message, HttpStatus.PRECONDITION_FAILED);
|
||||
}
|
||||
}
|
22
libs/shared/src/filters/rpc-exception.filter.ts
Normal file
22
libs/shared/src/filters/rpc-exception.filter.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import {
|
||||
Catch,
|
||||
RpcExceptionFilter,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { HttpRpcException } from '../exception/rpc.exception';
|
||||
|
||||
/**
|
||||
* Rethrow the custom exceptions
|
||||
*/
|
||||
@Catch(HttpRpcException)
|
||||
export class HttpRpcExceptionFilter
|
||||
implements RpcExceptionFilter<HttpRpcException>
|
||||
{
|
||||
catch(exception: HttpRpcException, host: ArgumentsHost): Observable<any> {
|
||||
return throwError(
|
||||
() => new HttpException(exception.error, exception.getStatus()),
|
||||
);
|
||||
}
|
||||
}
|
@ -7,3 +7,5 @@ export * from './database/make-knex';
|
||||
export * from './database/metaentity';
|
||||
export * from './types/user-token.enum';
|
||||
export * from './types/userinfo';
|
||||
export * from './exception/rpc.exception';
|
||||
export * from './filters/rpc-exception.filter';
|
||||
|
@ -80,6 +80,24 @@
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "apps/catalog/tsconfig.app.json"
|
||||
}
|
||||
},
|
||||
"bank": {
|
||||
"type": "application",
|
||||
"root": "apps/bank",
|
||||
"entryFile": "main",
|
||||
"sourceRoot": "apps/bank/src",
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "apps/bank/tsconfig.app.json"
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"type": "application",
|
||||
"root": "apps/assets",
|
||||
"entryFile": "main",
|
||||
"sourceRoot": "apps/assets/src",
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "apps/assets/tsconfig.app.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -12,7 +12,7 @@
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start:prod": "node dist/apps/freeblox-web-service/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
|
Loading…
Reference in New Issue
Block a user