auth changes, exception filters, two new services

This commit is contained in:
Evert Prants 2023-07-22 12:01:26 +03:00
parent 529e4a96d7
commit b6460f1cda
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
40 changed files with 776 additions and 176 deletions

View 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!');
});
});
});

View 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();
}
}

View 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 {}

View 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
View 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();

View 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!');
});
});

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false,
"outDir": "../../dist/apps/assets"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@ -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')

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -18,6 +18,7 @@ export async function up(knex: Knex): Promise<void> {
table.timestamps(true, true);
table.timestamp('login_at');
table.timestamp('deleted_at');
});
}

View File

@ -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();

View File

@ -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');
}

View File

@ -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;
const initialUsers = [
{
username: 'freeblox',
email: 'freeblox@icynet.eu',
phone: null,
country: 'ee',
language: 'en',
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,
},
];
await knex('users').insert([
{
username: 'freeblox',
email: 'freeblox@icynet.eu',
phone: null,
country: 'ee',
language: 'en',
password: await hash('FBLXAdmin123', 12),
display_name: 'Freeblox',
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);
}
}

View File

@ -1,99 +1,185 @@
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 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',
},
];
const initialPrivileges = [
{
id: 0,
name: 'web',
roles: ['player', 'reduced'],
},
{
id: 0,
name: 'report',
roles: ['player', 'reduced'],
},
{
id: 0,
name: 'play',
roles: ['player'],
},
{
id: 0,
name: 'shop',
roles: ['player'],
},
{
id: 0,
name: 'community',
roles: ['player'],
},
{
id: 0,
name: 'trade',
roles: ['player'],
},
{
id: 0,
name: 'oidc',
roles: ['player'],
},
{
id: 0,
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'],
},
{
id: 0,
name: 'privban',
roles: ['moderator'],
},
{
id: 0,
name: 'permaban',
roles: ['admin'],
},
{
id: 0,
name: 'contentedit',
roles: ['admin'],
},
{
id: 0,
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']);
}

View File

@ -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();

View File

@ -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({

View File

@ -23,12 +23,16 @@ export class BanService {
user: { id: user.id },
},
],
relations: ['privileges'],
});
}
async getAllBansForUser(user: UserEntity) {
return this.banRepository.findBy({
user: { id: user.id },
return this.banRepository.find({
where: {
user: { id: user.id },
},
relations: ['privileges'],
});
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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),
);
}
/**

View 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!');
});
});
});

View 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();
}
}

View 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 {}

View 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
View 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();

View 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!');
});
});

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false,
"outDir": "../../dist/apps/bank"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View 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,
});
}
}

View File

@ -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;

View File

@ -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,
),
);
}),
);
}
}

View File

@ -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();

View File

@ -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:

View 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);
}
}

View 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()),
);
}
}

View File

@ -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';

View File

@ -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"
}
}
}
}

View File

@ -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",
@ -94,4 +94,4 @@
"^@freeblox/shared(|/.*)$": "<rootDir>/libs/shared/src/$1"
}
}
}
}