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') @MessagePattern('auth.verify')
verify({ token }: { token: string }) { verify({ token }: { token: string }) {
return this.authService.verifyToken(token); return this.authService.verifyAccessToken(token);
} }
@MessagePattern('auth.getUserById') @MessagePattern('auth.getUserById')

View File

@ -3,11 +3,14 @@ import {
CreateDateColumn, CreateDateColumn,
Entity, Entity,
JoinColumn, JoinColumn,
JoinTable,
ManyToMany,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm'; } from 'typeorm';
import { UserEntity } from './user.entity'; import { UserEntity } from './user.entity';
import { Exclude, Expose } from 'class-transformer'; import { Exclude, Expose } from 'class-transformer';
import { PrivilegeEntity } from './privilege.entity';
@Entity('bans') @Entity('bans')
@Expose() @Expose()
@ -18,6 +21,9 @@ export class BanEntity {
@Column({ nullable: false }) @Column({ nullable: false })
reason: string; reason: string;
@Column({ default: false, name: 'privilege_ban' })
privilegeBan: boolean;
@Column({ nullable: true }) @Column({ nullable: true })
ip: string; ip: string;
@ -39,4 +45,12 @@ export class BanEntity {
@JoinColumn({ name: 'admin_id' }) @JoinColumn({ name: 'admin_id' })
@Exclude() @Exclude()
admin: UserEntity; 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 { IsOptional, IsString } from 'class-validator';
import { import {
Column, Column,
DeleteDateColumn,
Entity, Entity,
JoinTable, JoinTable,
ManyToMany, ManyToMany,
@ -89,4 +90,8 @@ export class UserEntity extends MetaEntity {
}) })
@Exclude() @Exclude()
roles: RoleEntity[]; 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.timestamps(true, true);
table.timestamp('login_at'); 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.increments('id').primary();
table.text('reason').notNullable(); table.text('reason').notNullable();
table.boolean('privilege_ban').defaultTo(false);
table.string('ip', 255).nullable(); table.string('ip', 255).nullable();
table.integer('cidr', 2).nullable().defaultTo(32).unsigned(); 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'; import { Knex } from 'knex';
export async function seed(knex: Knex): Promise<void> { const initialUsers = [
const userExists = await knex('users').where({ username: 'freeblox' }); {
if (userExists?.length) return; 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([ export async function seed(knex: Knex): Promise<void> {
{ for (const user of initialUsers) {
username: 'freeblox', const userExists = await knex('users').where({ username: user.username });
email: 'freeblox@icynet.eu', if (userExists?.length) continue;
phone: null,
country: 'ee', await knex('users').insert(user);
language: 'en', }
password: await hash('FBLXAdmin123', 12),
display_name: 'Freeblox',
verified: true,
activated: true,
},
]);
} }

View File

@ -1,99 +1,185 @@
import { Knex } from 'knex'; import { Knex } from 'knex';
export async function seed(knex: Knex): Promise<void> { const initialRoles = [
const initialRoles = [ {
{ name: 'player',
name: 'player', automatic: true,
automatic: true, id: 0,
id: 0, path: '',
path: '', },
}, {
{ name: 'member',
name: 'member', id: 0,
id: 0, parent: 'player',
parent: 'player', path: '',
path: '', },
}, {
{ name: 'moderator',
name: 'moderator', id: 0,
id: 0, parent: 'member',
parent: 'member', path: '',
path: '', },
}, {
{ name: 'admin',
name: 'admin', id: 0,
id: 0, parent: 'moderator',
parent: 'moderator', path: '',
path: '', },
}, {
{ name: 'reduced',
name: 'reduced', id: 0,
id: 0, path: '',
path: '', },
}, ];
];
const initialPrivileges = [ const initialPrivileges = [
{ {
id: 0, id: 0,
name: 'web', name: 'web',
roles: ['player', 'reduced'], roles: ['player', 'reduced'],
}, },
{ {
id: 0, id: 0,
name: 'play', name: 'report',
roles: ['player'], roles: ['player', 'reduced'],
}, },
{ {
id: 0, id: 0,
name: 'shop', name: 'play',
roles: ['player'], roles: ['player'],
}, },
{ {
id: 0, id: 0,
name: 'build', name: 'shop',
roles: ['player'], roles: ['player'],
}, },
{ {
id: 0, id: 0,
name: 'trade', name: 'community',
roles: ['player'], roles: ['player'],
}, },
{ {
id: 0, id: 0,
name: 'oidc', name: 'trade',
roles: ['player'], roles: ['player'],
}, },
{ {
id: 0, id: 0,
name: 'host', name: 'oidc',
roles: [], roles: ['player'],
}, },
{ {
id: 0, id: 0,
name: 'ban', name: 'host',
roles: ['moderator', 'admin'], roles: [],
}, },
{ {
id: 0, id: 0,
name: 'permaban', name: 'create:game',
roles: ['admin'], roles: ['player'],
}, },
{ {
id: 0, id: 0,
name: 'stopserver', name: 'create:clothing',
roles: ['admin'], roles: ['player'],
}, },
{ {
id: 0, id: 0,
name: 'banserver', name: 'create:accessory',
roles: ['admin'], roles: ['member'],
}, },
{ {
id: 0, id: 0,
name: 'root', 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) { for (const role of initialRoles) {
const exists = await knex('roles').where({ const exists = await knex('roles').where({
@ -170,31 +256,7 @@ export async function seed(knex: Knex): Promise<void> {
} }
// Add roles to initial user // Add roles to initial user
const userExists = await knex('users').where({ username: 'freeblox' }); await giveUserRole('freeblox', 'admin');
if (!userExists?.length) return; await giveUserRole('noob', 'player');
await giveUserPrivilege('freeblox', ['root', 'host']);
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);
}
}),
);
} }

View File

@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices'; import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AuthModule } from './auth.module'; import { AuthModule } from './auth.module';
import { HttpRpcExceptionFilter } from '@freeblox/shared';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>( const app = await NestFactory.createMicroservice<MicroserviceOptions>(
@ -13,6 +14,7 @@ async function bootstrap() {
}, },
); );
app.useGlobalFilters(new HttpRpcExceptionFilter());
await app.listen(); await app.listen();
} }
bootstrap(); bootstrap();

View File

@ -1,9 +1,4 @@
import { import { Injectable } from '@nestjs/common';
BadRequestException,
ForbiddenException,
Injectable,
PreconditionFailedException,
} from '@nestjs/common';
import { LoginRequest } from '../interfaces/auth.interface'; import { LoginRequest } from '../interfaces/auth.interface';
import { JWTService } from './jwt.service'; import { JWTService } from './jwt.service';
import { ILike, Repository } from 'typeorm'; import { ILike, Repository } from 'typeorm';
@ -13,7 +8,12 @@ import { instanceToPlain } from 'class-transformer';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { OTPService } from './otp.service'; import { OTPService } from './otp.service';
import { BanService } from './ban.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 { RoleService } from './role.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { RefreshService } from './refresh.service'; import { RefreshService } from './refresh.service';
@ -38,7 +38,7 @@ export class AuthService {
*/ */
async login(body: LoginRequest) { async login(body: LoginRequest) {
if (!body.email || !body.password) { if (!body.email || !body.password) {
throw new BadRequestException('Invalid username or password'); throw new BadRequestRpcException('Invalid username or password');
} }
// Prevent wildcards // Prevent wildcards
@ -58,20 +58,20 @@ export class AuthService {
// User not found // User not found
if (!userEntity) { if (!userEntity) {
throw new BadRequestException('Invalid username or password'); throw new BadRequestRpcException('Invalid username or password');
} }
// Compare passwords // Compare passwords
const passwordMatch = await compare(body.password, userEntity.password); const passwordMatch = await compare(body.password, userEntity.password);
if (!passwordMatch) { if (!passwordMatch) {
throw new BadRequestException('Invalid username or password'); throw new BadRequestRpcException('Invalid username or password');
} }
// Check TOTP // Check TOTP
const userOTPToken = await this.otpService.getUserTOTP(userEntity); const userOTPToken = await this.otpService.getUserTOTP(userEntity);
if (userOTPToken) { if (userOTPToken) {
if (!body.totpToken) { if (!body.totpToken) {
throw new PreconditionFailedException('TOTP Token required'); throw new PreconditionFailedRpcException('TOTP Token required');
} }
const validate = this.otpService.validateTOTP( const validate = this.otpService.validateTOTP(
@ -80,13 +80,13 @@ export class AuthService {
); );
if (!validate) { if (!validate) {
throw new ForbiddenException('Invalid TOTP Token'); throw new ForbiddenRpcException('Invalid TOTP Token');
} }
} }
// Issue access token // Issue access token
const exp = this.config.get('security.jwtTokenExpiry'); const exp = this.config.get('security.jwtTokenExpiry');
const issuedToken = await this.issueToken(userEntity); const issuedToken = await this.issueAccessToken(userEntity);
// Issue refresh token // Issue refresh token
const refreshToken = await this.refreshService.issueRefreshToken( const refreshToken = await this.refreshService.issueRefreshToken(
@ -112,7 +112,7 @@ export class AuthService {
// Issue new access token // Issue new access token
const exp = this.config.get('security.jwtTokenExpiry'); const exp = this.config.get('security.jwtTokenExpiry');
const issuedToken = await this.issueToken(userEntity); const issuedToken = await this.issueAccessToken(userEntity);
// Set login time to now // Set login time to now
await this.userRepository.update( await this.userRepository.update(
@ -133,7 +133,7 @@ export class AuthService {
* @returns User entity * @returns User entity
*/ */
async getUserFromToken(token: string) { async getUserFromToken(token: string) {
const tokenInfo = await this.verifyToken(token); const tokenInfo = await this.verifyAccessToken(token);
const user = await this.userRepository.findOneByOrFail({ const user = await this.userRepository.findOneByOrFail({
id: tokenInfo.sub, id: tokenInfo.sub,
activated: true, activated: true,
@ -146,12 +146,11 @@ export class AuthService {
* @param token JWT token * @param token JWT token
* @returns User token info * @returns User token info
*/ */
async verifyToken(token: string) { async verifyAccessToken(token: string) {
try { try {
return await this.jwtService.verify(token); return await this.jwtService.verify(token);
} catch (e) { } catch (e) {
console.error(token, e); throw new ForbiddenRpcException('Invalid token');
throw new ForbiddenException('Invalid token');
} }
} }
@ -161,7 +160,7 @@ export class AuthService {
* @returns User entity * @returns User entity
*/ */
async getUserById(id: string) { 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 })); return instanceToPlain(await this.userRepository.findOneByOrFail({ id }));
} }
@ -178,13 +177,26 @@ export class AuthService {
return instanceToPlain(bans); 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 // Check for active ban
const bans = await this.banService.getActiveBansForUser(user); const bans = await this.banService.getActiveBansForUser(user);
const banned = !!bans.length; const banned = !!bans.length;
// Get all privileges applicable to user // 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 // Issue new token
return this.jwtService.sign({ return this.jwtService.sign({

View File

@ -23,12 +23,16 @@ export class BanService {
user: { id: user.id }, user: { id: user.id },
}, },
], ],
relations: ['privileges'],
}); });
} }
async getAllBansForUser(user: UserEntity) { async getAllBansForUser(user: UserEntity) {
return this.banRepository.findBy({ return this.banRepository.find({
user: { id: user.id }, 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 { ConfigService } from '@nestjs/config';
import { EncryptJWT, JWK, KeyLike, SignJWT, jwtDecrypt, jwtVerify } from 'jose'; import { EncryptJWT, JWK, KeyLike, SignJWT, jwtDecrypt, jwtVerify } from 'jose';
@ -34,7 +35,7 @@ export class JWTService {
audience, audience,
}); });
if (protectedHeader.alg !== alg) if (protectedHeader.alg !== alg)
throw new ForbiddenException('Provided JWT contains invalid headers.'); throw new ForbiddenRpcException('Provided JWT contains invalid headers.');
return payload; return payload;
} }
@ -63,7 +64,7 @@ export class JWTService {
audience, audience,
}); });
if (protectedHeader.enc !== alg) if (protectedHeader.enc !== alg)
throw new ForbiddenException('Provided JWT contains invalid headers.'); throw new ForbiddenRpcException('Provided JWT contains invalid headers.');
return payload; 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 { InjectRepository } from '@nestjs/typeorm';
import { UserTokenEntity } from '../database/entities/user-token.entity'; import { UserTokenEntity } from '../database/entities/user-token.entity';
import { JWTService } from './jwt.service'; import { JWTService } from './jwt.service';
import { UserEntity } from '../database/entities/user.entity'; import { UserEntity } from '../database/entities/user.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { UserTokenType, generateString } from '@freeblox/shared'; import {
UnauthorizedRpcException,
UserTokenType,
generateString,
} from '@freeblox/shared';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
@ -36,11 +40,11 @@ export class RefreshService {
* @returns New refresh token * @returns New refresh token
*/ */
async useRefreshToken(token: string) { 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); const decrypted = await this.jwt.decrypt(token);
if (!decrypted.token || !decrypted.sub) if (!decrypted.token || !decrypted.sub)
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedRpcException('Invalid refresh token');
const tokenEntity = await this.userTokenRepository.findOneOrFail({ const tokenEntity = await this.userTokenRepository.findOneOrFail({
where: { where: {
@ -51,7 +55,7 @@ export class RefreshService {
}); });
if (decrypted.sub !== tokenEntity.user.id) if (decrypted.sub !== tokenEntity.user.id)
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedRpcException('Invalid refresh token');
// Using an expired refresh token // Using an expired refresh token
if (tokenEntity.expiresAt.getTime() < Date.now()) { if (tokenEntity.expiresAt.getTime() < Date.now()) {
@ -62,7 +66,7 @@ export class RefreshService {
await this.userTokenRepository.remove(tokenEntity); await this.userTokenRepository.remove(tokenEntity);
} }
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedRpcException('Invalid refresh token');
} }
// Mark old token as expired // Mark old token as expired

View File

@ -48,7 +48,7 @@ export class RoleService {
* @param user User * @param user User
* @returns Unique privilege list * @returns Unique privilege list
*/ */
async getUserPrivileges(user: UserEntity) { async getUserPrivileges(user: UserEntity, exclude: PrivilegeEntity[] = []) {
if (!user.privileges || !user.roles) { if (!user.privileges || !user.roles) {
user = await this.userRepository.findOne({ user = await this.userRepository.findOne({
where: { id: user.id }, 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 { import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
HttpException,
HttpStatus,
Inject, Inject,
Injectable, Injectable,
} from '@nestjs/common'; } from '@nestjs/common';
@ -21,7 +23,9 @@ export class AuthGuard implements CanActivate {
const [, token] = request.headers.authorization.split(' '); const [, token] = request.headers.authorization.split(' ');
const user = await lastValueFrom( const user = await lastValueFrom(
this.authClient.send('auth.verify', { token }), this.authClient.send('auth.verify', { token }),
); ).catch((err) => {
throw new HttpException(err.response, err.status || HttpStatus.FORBIDDEN);
});
// Add token contents to locals // Add token contents to locals
response.locals.user = user; 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 { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { CatchRpcExceptionInterceptor } from './interceptors/catch-rpc-exception.interceptor';
import { HttpExceptionFilter } from './filters/exception.filter';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@ -14,6 +16,9 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document); SwaggerModule.setup('api', app, document);
app.useGlobalInterceptors(new CatchRpcExceptionInterceptor());
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000); await app.listen(3000);
} }
bootstrap(); bootstrap();

View File

@ -13,7 +13,7 @@ services:
container_name: fblx-postgres container_name: fblx-postgres
image: postgres:15-alpine image: postgres:15-alpine
environment: 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_USER=freeblox
- POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123 - POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123
volumes: volumes:
@ -127,6 +127,40 @@ services:
volumes: volumes:
- ./apps:/usr/src/app/apps - ./apps:/usr/src/app/apps
- ./libs:/usr/src/app/libs - ./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: web-service:
container_name: fblx-web-service container_name: fblx-web-service
build: 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 './database/metaentity';
export * from './types/user-token.enum'; export * from './types/user-token.enum';
export * from './types/userinfo'; export * from './types/userinfo';
export * from './exception/rpc.exception';
export * from './filters/rpc-exception.filter';

View File

@ -80,6 +80,24 @@
"compilerOptions": { "compilerOptions": {
"tsConfigPath": "apps/catalog/tsconfig.app.json" "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": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --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", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
@ -94,4 +94,4 @@
"^@freeblox/shared(|/.*)$": "<rootDir>/libs/shared/src/$1" "^@freeblox/shared(|/.*)$": "<rootDir>/libs/shared/src/$1"
} }
} }
} }