more auth stuff

This commit is contained in:
Evert Prants 2023-06-30 21:29:34 +03:00
parent 1904fe94ed
commit d81fc53819
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
23 changed files with 389 additions and 14 deletions

View File

@ -2,6 +2,7 @@ import { Controller } from '@nestjs/common';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { MessagePattern } from '@nestjs/microservices'; import { MessagePattern } from '@nestjs/microservices';
import { LoginRequest } from './interfaces/auth.interface'; import { LoginRequest } from './interfaces/auth.interface';
import { UserInfo } from '@freeblox/shared';
@Controller() @Controller()
export class AuthController { export class AuthController {
@ -14,6 +15,16 @@ export class AuthController {
@MessagePattern('auth.verify') @MessagePattern('auth.verify')
verify({ token }: { token: string }) { verify({ token }: { token: string }) {
return this.authService.getUserFromToken(token); return this.authService.verifyToken(token);
}
@MessagePattern('auth.getUserById')
getUserById({ id }: { id: string }) {
return this.authService.getUserById(id);
}
@MessagePattern('auth.getUserBans')
getUserBans({ user }: { user: UserInfo }) {
return this.authService.getUserBans(user);
} }
} }

View File

@ -14,6 +14,17 @@ import { UserTokenEntity } from './database/entities/user-token.entity';
import { OTPService } from './services/otp.service'; import { OTPService } from './services/otp.service';
import { BanEntity } from './database/entities/ban.entity'; import { BanEntity } from './database/entities/ban.entity';
import { BanService } from './services/ban.service'; import { BanService } from './services/ban.service';
import { PrivilegeEntity } from './database/entities/privilege.entity';
import { RoleService } from './services/role.service';
import { RoleEntity } from './database/entities/role.entity';
const entities = [
UserEntity,
UserTokenEntity,
BanEntity,
PrivilegeEntity,
RoleEntity,
];
@Module({ @Module({
imports: [ imports: [
@ -26,9 +37,10 @@ import { BanService } from './services/ban.service';
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
...config.get('typeorm'), ...config.get('typeorm'),
entities,
}), }),
}), }),
TypeOrmModule.forFeature([UserEntity, UserTokenEntity, BanEntity]), TypeOrmModule.forFeature(entities),
ClientsModule.register([natsClient('auth')]), ClientsModule.register([natsClient('auth')]),
], ],
controllers: [AuthController], controllers: [AuthController],
@ -36,8 +48,9 @@ import { BanService } from './services/ban.service';
...keysProviders, ...keysProviders,
JWTService, JWTService,
OTPService, OTPService,
AuthService,
BanService, BanService,
RoleService,
AuthService,
], ],
}) })
export class AuthModule implements OnModuleInit { export class AuthModule implements OnModuleInit {

View File

@ -3,6 +3,7 @@ import { resolve } from 'path';
export const security = registerAs('security', () => ({ export const security = registerAs('security', () => ({
algorithm: String(process.env.JWT_ALGORITHM || 'RS512'), algorithm: String(process.env.JWT_ALGORITHM || 'RS512'),
tokenExpiry: Number(process.env.JWT_EXPIRY) || 60 * 60,
privateKeyPath: resolve(String(process.env.PRIVATE_KEY_FILE)), privateKeyPath: resolve(String(process.env.PRIVATE_KEY_FILE)),
publicKeyPath: resolve(String(process.env.PUBLIC_KEY_FILE)), publicKeyPath: resolve(String(process.env.PUBLIC_KEY_FILE)),
})); }));

View File

@ -0,0 +1,18 @@
import { UserMetaEntity } from '@freeblox/shared';
import { Exclude, Expose } from 'class-transformer';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('privileges')
@Exclude()
export class PrivilegeEntity extends UserMetaEntity {
@PrimaryGeneratedColumn()
@Expose()
id: number;
@Column()
@Expose()
privilege: string;
@Column({ default: false })
automatic: boolean;
}

View File

@ -0,0 +1,54 @@
import { UserMetaEntity } from '@freeblox/shared';
import { Exclude, Expose } from 'class-transformer';
import {
Column,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
Tree,
TreeChildren,
TreeParent,
} from 'typeorm';
import { PrivilegeEntity } from './privilege.entity';
@Entity('roles')
@Exclude()
@Tree('materialized-path')
export class RoleEntity extends UserMetaEntity {
@PrimaryGeneratedColumn()
@Expose()
id: number;
@Column()
@Expose()
role: string;
@Column({ name: 'parent_id' })
@Expose()
parentId: number;
@TreeParent()
@JoinColumn({ name: 'parent_id' })
parent: RoleEntity;
@TreeChildren()
children: RoleEntity[];
@Column({ default: false })
automatic: boolean;
@ManyToMany(() => PrivilegeEntity, { eager: true })
@Expose()
@JoinTable({
name: 'role_privilege',
joinColumn: {
name: 'role_id',
},
inverseJoinColumn: {
name: 'privilege_id',
},
})
privileges: PrivilegeEntity[];
}

View File

@ -5,6 +5,7 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
ManyToOne, ManyToOne,
JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { UserEntity } from './user.entity'; import { UserEntity } from './user.entity';
@ -29,5 +30,6 @@ export class UserTokenEntity {
created_at: Date; created_at: Date;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserEntity; user: UserEntity;
} }

View File

@ -1,10 +1,18 @@
import { MetaEntity } from '@freeblox/shared'; import { MetaEntity } from '@freeblox/shared';
import { Exclude, Expose } from 'class-transformer'; import { Exclude, Expose } from 'class-transformer';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; import {
Column,
Entity,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import { PrivilegeEntity } from './privilege.entity';
import { RoleEntity } from './role.entity';
@Entity('user_entity')
@Expose() @Expose()
@Entity('users')
export class UserEntity extends MetaEntity { export class UserEntity extends MetaEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@IsString() @IsString()
@ -55,4 +63,30 @@ export class UserEntity extends MetaEntity {
@IsString() @IsString()
@IsOptional() @IsOptional()
loginAt: Date; loginAt: Date;
@ManyToMany(() => PrivilegeEntity)
@JoinTable({
name: 'user_privilege',
joinColumn: {
name: 'user_id',
},
inverseJoinColumn: {
name: 'privilege_id',
},
})
@Exclude()
privileges: PrivilegeEntity[];
@ManyToMany(() => RoleEntity)
@JoinTable({
name: 'user_role',
joinColumn: {
name: 'user_id',
},
inverseJoinColumn: {
name: 'role_id',
},
})
@Exclude()
roles: RoleEntity[];
} }

View File

@ -7,7 +7,7 @@ export async function up(knex: Knex): Promise<void> {
table.text('reason').notNullable(); table.text('reason').notNullable();
table.string('ip', 255).nullable(); table.string('ip', 255).nullable();
table.integer('cidr', 2).nullable().unsigned(); table.integer('cidr', 2).nullable().defaultTo(32).unsigned();
table.uuid('user_id').nullable(); table.uuid('user_id').nullable();
table.uuid('admin_id').nullable(); table.uuid('admin_id').nullable();

View File

@ -2,6 +2,7 @@ import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('user_privilege', (table) => { return knex.schema.createTable('user_privilege', (table) => {
table.increments('id').primary();
table.integer('privilege_id').nullable().unsigned(); table.integer('privilege_id').nullable().unsigned();
table.uuid('user_id').nullable(); table.uuid('user_id').nullable();
table table

View File

@ -4,6 +4,7 @@ export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('roles', (table) => { return knex.schema.createTable('roles', (table) => {
table.increments('id').primary(); table.increments('id').primary();
table.text('role').notNullable(); table.text('role').notNullable();
table.string('mpath').nullable().defaultTo('');
table.integer('parent_id').nullable().unsigned(); table.integer('parent_id').nullable().unsigned();
table.boolean('automatic').defaultTo(false); table.boolean('automatic').defaultTo(false);

View File

@ -2,6 +2,7 @@ import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('user_role', (table) => { return knex.schema.createTable('user_role', (table) => {
table.increments('id').primary();
table.integer('role_id').nullable().unsigned(); table.integer('role_id').nullable().unsigned();
table.uuid('user_id').nullable(); table.uuid('user_id').nullable();
table.foreign('role_id').references('roles.id').onDelete('CASCADE'); table.foreign('role_id').references('roles.id').onDelete('CASCADE');

View File

@ -2,6 +2,7 @@ import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('role_privilege', (table) => { return knex.schema.createTable('role_privilege', (table) => {
table.increments('id').primary();
table.integer('role_id').nullable().unsigned(); table.integer('role_id').nullable().unsigned();
table.integer('privilege_id').nullable().unsigned(); table.integer('privilege_id').nullable().unsigned();
table.foreign('role_id').references('roles.id').onDelete('CASCADE'); table.foreign('role_id').references('roles.id').onDelete('CASCADE');

View File

@ -6,25 +6,30 @@ export async function seed(knex: Knex): Promise<void> {
name: 'player', name: 'player',
automatic: true, automatic: true,
id: 0, id: 0,
path: '',
}, },
{ {
name: 'member', name: 'member',
id: 0, id: 0,
parent: 'player', parent: 'player',
path: '',
}, },
{ {
name: 'moderator', name: 'moderator',
id: 0, id: 0,
parent: 'member', parent: 'member',
path: '',
}, },
{ {
name: 'admin', name: 'admin',
id: 0, id: 0,
parent: 'moderator', parent: 'moderator',
path: '',
}, },
{ {
name: 'reduced', name: 'reduced',
id: 0, id: 0,
path: '',
}, },
]; ];
@ -105,7 +110,10 @@ export async function seed(knex: Knex): Promise<void> {
const findRole = initialRoles.find( const findRole = initialRoles.find(
(parent) => parent.name === role.parent, (parent) => parent.name === role.parent,
); );
parentId = findRole?.id; if (findRole) {
parentId = findRole.id;
role.path += findRole.path;
}
} }
const [created] = await knex('roles') const [created] = await knex('roles')
@ -113,12 +121,17 @@ export async function seed(knex: Knex): Promise<void> {
{ {
role: role.name, role: role.name,
automatic: role?.automatic, automatic: role?.automatic,
mpath: role.path,
parent_id: parentId, parent_id: parentId,
created_at: new Date(), created_at: new Date(),
}, },
]) ])
.returning(['id']); .returning(['id']);
role.id = created.id; role.id = created.id;
role.path += `${role.path ? '.' : ''}${role.id}`;
await knex('roles').where({ id: role.id }).update({ mpath: role.path });
} }
for (const privilege of initialPrivileges) { for (const privilege of initialPrivileges) {

View File

@ -0,0 +1,4 @@
export interface TokenResponse {
token: string;
expires_in: number;
}

View File

@ -14,6 +14,8 @@ 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 { UserInfo } from '@freeblox/shared';
import { RoleService } from './role.service';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -21,8 +23,10 @@ export class AuthService {
private readonly jwtService: JWTService, private readonly jwtService: JWTService,
private readonly otpService: OTPService, private readonly otpService: OTPService,
private readonly banService: BanService, private readonly banService: BanService,
private readonly roleService: RoleService,
@InjectRepository(UserEntity) @InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>, private readonly userRepository: Repository<UserEntity>,
private readonly config: ConfigService,
) {} ) {}
/** /**
@ -78,17 +82,24 @@ export class AuthService {
} }
} }
// Check for active ban
const bans = await this.banService.getActiveBansForUser(userEntity); const bans = await this.banService.getActiveBansForUser(userEntity);
const banned = !!bans.length; const banned = !!bans.length;
// Get all privileges applicable to user
const privileges = await this.roleService.getUserPrivileges(userEntity);
// Issue token // Issue token
const exp = this.config.get('security.tokenExpiry');
const issuedToken = await this.jwtService.sign({ const issuedToken = await this.jwtService.sign({
sub: userEntity.id, sub: userEntity.id,
username: userEntity.username, username: userEntity.username,
display_name: userEntity.displayName, display_name: userEntity.displayName,
language: userEntity.language, language: userEntity.language,
banned: banned, banned: banned,
privileges: banned ? [] : ['freeblox'], privileges: banned
? []
: privileges.map((privilege) => privilege.privilege),
}); });
// Set login time to now // Set login time to now
@ -97,16 +108,19 @@ export class AuthService {
{ loginAt: new Date() }, { loginAt: new Date() },
); );
return issuedToken; return {
token: issuedToken,
expires_in: exp,
};
} }
/** /**
* Validate user token * Get user from token
* @param token JWT Token * @param token JWT Token
* @returns User entity * @returns User entity
*/ */
async getUserFromToken(token: string) { async getUserFromToken(token: string) {
const tokenInfo = await this.jwtService.verify(token); const tokenInfo = await this.verifyToken(token);
const user = await this.userRepository.findOneByOrFail({ const user = await this.userRepository.findOneByOrFail({
id: tokenInfo.sub, id: tokenInfo.sub,
activated: true, activated: true,
@ -114,6 +128,30 @@ export class AuthService {
return instanceToPlain(user); return instanceToPlain(user);
} }
/**
* Verify user token
* @param token JWT token
* @returns User token info
*/
async verifyToken(token: string) {
try {
return await this.jwtService.verify(token);
} catch (e) {
console.error(token, e);
throw new ForbiddenException('Invalid token');
}
}
/**
* Get user entity by ID
* @param id User ID
* @returns User entity
*/
async getUserById(id: string) {
if (!id) throw new BadRequestException('ID is required');
return instanceToPlain(await this.userRepository.findOneByOrFail({ id }));
}
/** /**
* Get user bans * Get user bans
* @param tokeninfo * @param tokeninfo

View File

@ -13,12 +13,14 @@ export class JWTService {
async sign(data: Record<string, unknown>, audience = 'urn:freeblox:service') { async sign(data: Record<string, unknown>, audience = 'urn:freeblox:service') {
const alg = this.config.get('security.algorithm'); const alg = this.config.get('security.algorithm');
const exp =
this.config.get('security.tokenExpiry') + Math.floor(Date.now() / 1000);
const jwt = await new SignJWT(data) const jwt = await new SignJWT(data)
.setProtectedHeader({ alg }) .setProtectedHeader({ alg })
.setIssuedAt() .setIssuedAt()
.setIssuer('urn:freeblox:auth') .setIssuer('urn:freeblox:auth')
.setAudience(audience) .setAudience(audience)
.setExpirationTime('8d') .setExpirationTime(exp)
.sign(this.privateKey); .sign(this.privateKey);
return jwt; return jwt;
} }

View File

@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { UserEntity } from '../database/entities/user.entity';
import { RoleEntity } from '../database/entities/role.entity';
import { PrivilegeEntity } from '../database/entities/privilege.entity';
@Injectable()
export class RoleService {
constructor(
@InjectEntityManager() private manager: EntityManager,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
/**
* Get all applicable privileges for roles
* @param roles Roles to look for privileges for
* @param startingPrivileges Privileges already included
* @returns Unique list of privileges
*/
async getApplicablePrivileges(
roles: RoleEntity[],
startingPrivileges: PrivilegeEntity[] = [],
) {
const roleTree = await this.manager
.getTreeRepository(RoleEntity)
.findTrees({ relations: ['privileges'] });
const privileges: PrivilegeEntity[] = [...startingPrivileges];
for (const { id } of roles) {
for (const role of roleTree) {
const [result, list] = this.accumulatePrivilegesFromTree(role, id);
if (!result) continue;
privileges.push(...list);
break;
}
}
return privileges.filter(
(value, index, array) =>
array.findIndex((entry) => entry.id === value.id) === index,
);
}
/**
* Get all applicable privileges for user.
* @param user User
* @returns Unique privilege list
*/
async getUserPrivileges(user: UserEntity) {
if (!user.privileges || !user.roles) {
user = await this.userRepository.findOne({
where: { id: user.id },
relations: ['privileges', 'roles'],
});
}
return this.getApplicablePrivileges(user.roles, user.privileges);
}
/**
* Accumulate privileges from a tree structure of roles.
* @param root Node to start from
* @param target Target role ID
* @returns `[<Found role entity>, <Privilege list>]`
*/
private accumulatePrivilegesFromTree(root: RoleEntity, target: number) {
const privileges: PrivilegeEntity[] = [...root.privileges];
if (root.id === target) return [root, privileges];
for (const child of root.children || []) {
const [found, list] = this.accumulatePrivilegesFromTree(child, target);
if (found) return [found, [...privileges, ...list]];
continue;
}
return [null, []];
}
}

View File

@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const response = ctx.switchToHttp().getResponse();
return response.locals.user;
},
);

View File

@ -0,0 +1,30 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { Request, Response } from 'express';
import { lastValueFrom } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(@Inject('auth') private authClient: ClientProxy) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest() as Request;
const response = context.switchToHttp().getResponse() as Response;
if (!request.headers.authorization) return false;
// Verify token by auth microservice
const [, token] = request.headers.authorization.split(' ');
const user = await lastValueFrom(
this.authClient.send('auth.verify', { token }),
);
// Add token contents to locals
response.locals.user = user;
return true;
}
}

View File

@ -9,6 +9,7 @@ async function bootstrap() {
.setTitle('Freeblox Web Service') .setTitle('Freeblox Web Service')
.setDescription('Freeblox Web Service API gateway') .setDescription('Freeblox Web Service API gateway')
.setVersion('1.0') .setVersion('1.0')
.addBearerAuth()
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document); SwaggerModule.setup('api', app, document);

View File

@ -2,18 +2,26 @@ import {
Body, Body,
ClassSerializerInterceptor, ClassSerializerInterceptor,
Controller, Controller,
Get,
Inject, Inject,
Post, Post,
UseGuards,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices'; import { ClientProxy } from '@nestjs/microservices';
import { ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { LoginDto } from './dtos/login.dto'; import { LoginDto } from './dtos/login.dto';
import { User } from '../../decorators/user.decorator';
import { UserInfo } from '@freeblox/shared';
import { lastValueFrom } from 'rxjs';
import { AuthGuard } from '../../guards/auth.guard';
import { UserDto } from './dtos/user.dto';
@Controller({ @Controller({
version: '1', version: '1',
path: 'auth', path: 'auth',
}) })
@ApiBearerAuth()
@ApiTags('Auth') @ApiTags('Auth')
@UseInterceptors(ClassSerializerInterceptor) @UseInterceptors(ClassSerializerInterceptor)
export class AuthController { export class AuthController {
@ -23,4 +31,17 @@ export class AuthController {
async login(@Body() body: LoginDto) { async login(@Body() body: LoginDto) {
return this.auth.send('auth.login', { body }); return this.auth.send('auth.login', { body });
} }
@Get('me')
@ApiOkResponse({ type: UserDto })
@UseGuards(AuthGuard)
async myInfo(@User() user: UserInfo): Promise<UserDto> {
return lastValueFrom(this.auth.send('auth.getUserById', { id: user.sub }));
}
@Get('bans')
@UseGuards(AuthGuard)
async banInfo(@User() user: UserInfo) {
return lastValueFrom(this.auth.send('auth.getUserBans', { user }));
}
} }

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
export class UserDto {
@ApiProperty()
id: string;
@ApiProperty()
username: string;
@ApiProperty()
email: string;
@ApiProperty({ nullable: true })
phone: string;
@ApiProperty({ nullable: true })
country: string;
@ApiProperty()
language: string;
@ApiProperty({ nullable: true })
displayName: string;
@ApiProperty()
verified: boolean;
@ApiProperty()
activated: boolean;
@ApiProperty({ type: Date })
loginAt: string;
}

View File

@ -1,5 +1,5 @@
import { Exclude } from 'class-transformer'; import { Exclude } from 'class-transformer';
import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; import { Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
export class MetaEntity { export class MetaEntity {
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
@ -10,3 +10,13 @@ export class MetaEntity {
@Exclude() @Exclude()
updatedAt: Date; updatedAt: Date;
} }
export class UserMetaEntity extends MetaEntity {
@Column({ name: 'created_by' })
@Exclude()
createdBy: string;
@Column({ name: 'updated_by' })
@Exclude()
updatedBy: string;
}