From b29b88d7b6f02de7343ac4bb68bc2ab84ac3e303 Mon Sep 17 00:00:00 2001 From: rubikscraft Date: Sat, 12 Mar 2022 15:10:22 +0100 Subject: [PATCH] add permissions to roles --- backend/src/app.module.ts | 3 +- .../src/collections/roledb/roledb.module.ts | 84 +++++++++ .../src/collections/roledb/roledb.service.ts | 173 ++++++++++++++++++ .../src/collections/userdb/userdb.module.ts | 50 ++++- .../src/collections/userdb/userdb.service.ts | 113 +++++++----- .../src/decorators/permissions.decorator.ts | 17 ++ backend/src/decorators/roles.decorator.ts | 21 --- backend/src/main.ts | 5 +- backend/src/managers/auth/auth.module.ts | 44 +---- backend/src/managers/auth/auth.service.ts | 43 +---- .../auth/guards/localauth.strategy.ts | 6 +- .../src/managers/auth/guards/main.guard.ts | 52 ++++-- backend/src/managers/auth/guest.service.ts | 7 +- backend/src/models/entities/index.ts | 3 +- backend/src/models/entities/role.entity.ts | 16 ++ .../src/routes/api/auth/auth.controller.ts | 46 +++-- .../api/experiment/experiment.controller.ts | 5 +- .../src/routes/api/pref/pref.controller.ts | 11 +- .../src/routes/image/imageroute.controller.ts | 15 +- frontend/src/app/api/api.service.ts | 14 +- frontend/src/app/api/user.service.ts | 8 +- .../src/app/routes/login/login.component.ts | 4 +- shared/src/dto/auth.dto.ts | 17 +- shared/src/dto/permissions.ts | 20 ++ shared/src/dto/roles.dto.ts | 30 ++- shared/src/entities/role.entity.ts | 14 ++ shared/src/entities/user.entity.ts | 10 +- 27 files changed, 601 insertions(+), 230 deletions(-) create mode 100644 backend/src/collections/roledb/roledb.module.ts create mode 100644 backend/src/collections/roledb/roledb.service.ts create mode 100644 backend/src/decorators/permissions.decorator.ts delete mode 100644 backend/src/decorators/roles.decorator.ts create mode 100644 backend/src/models/entities/role.entity.ts create mode 100644 shared/src/dto/permissions.ts create mode 100644 shared/src/entities/role.entity.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7fa9015..d3fe6cc 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,9 +8,9 @@ import { PicsurLoggerModule } from './logger/logger.module'; import { AuthManagerModule } from './managers/auth/auth.module'; import { DemoManagerModule } from './managers/demo/demomanager.module'; import { AuthModule } from './routes/api/auth/auth.module'; +import { ExperimentModule } from './routes/api/experiment/experiment.module'; import { PrefModule } from './routes/api/pref/pref.module'; import { ImageModule } from './routes/image/imageroute.module'; -import { ExperimentModule } from './routes/api/experiment/experiment.module'; @Module({ imports: [ @@ -22,6 +22,7 @@ import { ExperimentModule } from './routes/api/experiment/experiment.module'; useExisting: ServeStaticConfigService, imports: [PicsurConfigModule], }), + AuthManagerModule, AuthModule, ImageModule, diff --git a/backend/src/collections/roledb/roledb.module.ts b/backend/src/collections/roledb/roledb.module.ts new file mode 100644 index 0000000..1f838e6 --- /dev/null +++ b/backend/src/collections/roledb/roledb.module.ts @@ -0,0 +1,84 @@ +import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { + ImmuteableRolesList, + SystemRoleDefaults, + SystemRoles, + SystemRolesList +} from 'picsur-shared/dist/dto/roles.dto'; +import { HasFailed } from 'picsur-shared/dist/types'; +import { PicsurConfigModule } from '../../config/config.module'; +import { HostConfigService } from '../../config/host.config.service'; +import { ERoleBackend } from '../../models/entities/role.entity'; +import { RolesService } from './roledb.service'; + +@Module({ + imports: [PicsurConfigModule, TypeOrmModule.forFeature([ERoleBackend])], + providers: [RolesService], + exports: [RolesService], +}) +export class RolesModule implements OnModuleInit { + private readonly logger = new Logger('RolesModule'); + + constructor( + private rolesService: RolesService, + private hostConfig: HostConfigService, + ) {} + + async onModuleInit() { + if (!this.hostConfig.isProduction()) { + await this.nukeRoles(); + } + + await this.ensureSystemRolesExist(); + await this.updateImmutableRoles(); + } + + private async nukeRoles() { + this.logger.error('Nuking all roles'); + const result = this.rolesService.nuke(true); + if (HasFailed(result)) { + this.logger.error(`Failed to nuke roles because: ${result.getReason()}`); + } + } + + private async ensureSystemRolesExist() { + for (const systemRole of SystemRolesList as SystemRoles) { + this.logger.debug(`Ensuring system role "${systemRole}" exists`); + + const exists = await this.rolesService.exists(systemRole); + if (exists) continue; + + const newRole = await this.rolesService.create( + systemRole, + SystemRoleDefaults[systemRole], + ); + if (HasFailed(newRole)) { + this.logger.error( + `Failed to create system role "${systemRole}" because: ${newRole.getReason()}`, + ); + continue; + } + } + } + + private async updateImmutableRoles() { + for (const immutableRole of ImmuteableRolesList as SystemRoles) { + this.logger.debug( + `Updating permissions for immutable role "${immutableRole}"`, + ); + + const result = await this.rolesService.setPermissions( + immutableRole, + SystemRoleDefaults[immutableRole], + true, + ); + if (HasFailed(result)) { + this.logger.error( + `Failed to update permissions for immutable role "${immutableRole}" because: ${result.getReason()}`, + ); + continue; + } + } + } +} diff --git a/backend/src/collections/roledb/roledb.service.ts b/backend/src/collections/roledb/roledb.service.ts new file mode 100644 index 0000000..37f5d7d --- /dev/null +++ b/backend/src/collections/roledb/roledb.service.ts @@ -0,0 +1,173 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { plainToClass } from 'class-transformer'; +import { validate } from 'class-validator'; +import { Permissions } from 'picsur-shared/dist/dto/permissions'; +import { + ImmuteableRolesList, + Roles, + SystemRolesList +} from 'picsur-shared/dist/dto/roles.dto'; +import { + AsyncFailable, + Fail, + HasFailed, + HasSuccess +} from 'picsur-shared/dist/types'; +import { Repository } from 'typeorm'; +import { ERoleBackend } from '../../models/entities/role.entity'; + +@Injectable() +export class RolesService { + private readonly logger = new Logger('UsersService'); + + constructor( + @InjectRepository(ERoleBackend) + private rolesRepository: Repository, + ) {} + + public async create( + name: string, + permissions: Permissions, + ): AsyncFailable { + if (await this.exists(name)) return Fail('Role already exists'); + + let role = new ERoleBackend(); + role.name = name; + role.permissions = permissions; + + try { + role = await this.rolesRepository.save(role, { reload: true }); + } catch (e: any) { + return Fail(e?.message); + } + + return plainToClass(ERoleBackend, role); + } + + public async delete( + role: string | ERoleBackend, + ): AsyncFailable { + const roleToModify = await this.resolve(role); + if (HasFailed(roleToModify)) return roleToModify; + + if (SystemRolesList.includes(roleToModify.name)) { + return Fail('Cannot delete system role'); + } + + try { + return await this.rolesRepository.remove(roleToModify); + } catch (e: any) { + return Fail(e?.message); + } + } + + public async getPermissions(roles: Roles): AsyncFailable { + const permissions: Permissions = []; + const foundRoles = await Promise.all( + roles.map((role: string) => this.findOne(role)), + ); + + for (const foundRole of foundRoles) { + if (HasFailed(foundRole)) return foundRole; + permissions.push(...foundRole.permissions); + } + + return [...new Set(...[permissions])]; + } + + public async addPermissions( + role: string | ERoleBackend, + permissions: Permissions, + ): AsyncFailable { + const roleToModify = await this.resolve(role); + if (HasFailed(roleToModify)) return roleToModify; + + // This is stupid + const newPermissions = [ + ...new Set([...roleToModify.permissions, ...permissions]), + ]; + + return this.setPermissions(roleToModify, newPermissions); + } + + public async removePermissions( + role: string | ERoleBackend, + permissions: Permissions, + ): AsyncFailable { + const roleToModify = await this.resolve(role); + if (HasFailed(roleToModify)) return roleToModify; + + const newPermissions = roleToModify.permissions.filter( + (permission) => !permissions.includes(permission), + ); + + return this.setPermissions(roleToModify, newPermissions); + } + + public async setPermissions( + role: string | ERoleBackend, + permissions: Permissions, + allowImmutable: boolean = false, + ): AsyncFailable { + const roleToModify = await this.resolve(role); + if (HasFailed(roleToModify)) return roleToModify; + + if (!allowImmutable && ImmuteableRolesList.includes(roleToModify.name)) { + return Fail('Cannot modify immutable role'); + } + + roleToModify.permissions = permissions; + + try { + await this.rolesRepository.save(roleToModify); + } catch (e: any) { + return Fail(e?.message); + } + + return true; + } + + public async findOne(name: string): AsyncFailable { + try { + const found = await this.rolesRepository.findOne({ + where: { name }, + }); + + if (!found) return Fail('User not found'); + return found as ERoleBackend; + } catch (e: any) { + return Fail(e?.message); + } + } + + public async exists(username: string): Promise { + return HasSuccess(await this.findOne(username)); + } + + public async nuke(iamsure: boolean = false): AsyncFailable { + if (!iamsure) return Fail('Nuke aborted'); + try { + await this.rolesRepository.delete({}); + } catch (e: any) { + return Fail(e?.message); + } + return true; + } + + private async resolve( + user: string | ERoleBackend, + ): AsyncFailable { + if (typeof user === 'string') { + return await this.findOne(user); + } else { + user = plainToClass(ERoleBackend, user); + const errors = await validate(user, { forbidUnknownValues: true }); + if (errors.length > 0) { + this.logger.warn(errors); + return Fail('Invalid role'); + } + return user; + } + } +} diff --git a/backend/src/collections/userdb/userdb.module.ts b/backend/src/collections/userdb/userdb.module.ts index 7b5c90a..c0fc80b 100644 --- a/backend/src/collections/userdb/userdb.module.ts +++ b/backend/src/collections/userdb/userdb.module.ts @@ -1,11 +1,55 @@ -import { Module } from '@nestjs/common'; +import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { HasFailed } from 'picsur-shared/dist/types'; +import { AuthConfigService } from '../../config/auth.config.service'; +import { PicsurConfigModule } from '../../config/config.module'; import { EUserBackend } from '../../models/entities/user.entity'; +import { RolesModule } from '../roledb/roledb.module'; import { UsersService } from './userdb.service'; @Module({ - imports: [TypeOrmModule.forFeature([EUserBackend])], + imports: [ + PicsurConfigModule, + RolesModule, + TypeOrmModule.forFeature([EUserBackend]), + ], providers: [UsersService], exports: [UsersService], }) -export class UsersModule {} +export class UsersModule implements OnModuleInit { + private readonly logger = new Logger('UsersModule'); + + constructor( + private usersService: UsersService, + private authConfigService: AuthConfigService, + ) {} + + async onModuleInit() { + await this.ensureAdminExists(); + } + + private async ensureAdminExists() { + const username = this.authConfigService.getDefaultAdminUsername(); + const password = this.authConfigService.getDefaultAdminPassword(); + this.logger.debug(`Ensuring admin user "${username}" exists`); + + const exists = await this.usersService.exists(username); + if (exists) return; + + const newUser = await this.usersService.create(username, password); + if (HasFailed(newUser)) { + this.logger.error( + `Failed to create admin user "${username}" because: ${newUser.getReason()}`, + ); + return; + } + + const result = await this.usersService.addRoles(newUser, ['admin']); + if (HasFailed(result)) { + this.logger.error( + `Failed to make admin user "${username}" because: ${result.getReason()}`, + ); + return; + } + } +} diff --git a/backend/src/collections/userdb/userdb.service.ts b/backend/src/collections/userdb/userdb.service.ts index 48b05f1..ae55341 100644 --- a/backend/src/collections/userdb/userdb.service.ts +++ b/backend/src/collections/userdb/userdb.service.ts @@ -1,8 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import * as bcrypt from 'bcrypt'; import { plainToClass } from 'class-transformer'; import { validate } from 'class-validator'; -import { Roles } from 'picsur-shared/dist/dto/roles.dto'; +import { Permissions } from 'picsur-shared/dist/dto/permissions'; +import { PermanentRolesList, Roles } from 'picsur-shared/dist/dto/roles.dto'; import { AsyncFailable, Fail, @@ -12,6 +14,7 @@ import { import { Repository } from 'typeorm'; import { EUserBackend } from '../../models/entities/user.entity'; import { GetCols } from '../collectionutils'; +import { RolesService } from '../roledb/roledb.service'; @Injectable() export class UsersService { @@ -20,15 +23,18 @@ export class UsersService { constructor( @InjectRepository(EUserBackend) private usersRepository: Repository, + private rolesService: RolesService, ) {} public async create( username: string, - hashedPassword: string, + password: string, roles?: Roles, ): AsyncFailable { if (await this.exists(username)) return Fail('User already exists'); + const hashedPassword = await bcrypt.hash(password, 12); + let user = new EUserBackend(); user.username = username; user.password = hashedPassword; @@ -57,6 +63,70 @@ export class UsersService { } } + async authenticate( + username: string, + password: string, + ): AsyncFailable { + const user = await this.findOne(username, true); + if (HasFailed(user)) return user; + + if (!(await bcrypt.compare(password, user.password))) + return Fail('Wrong password'); + + return await this.findOne(username); + } + + public async getPermissions( + user: string | EUserBackend, + ): AsyncFailable { + const userToModify = await this.resolve(user); + if (HasFailed(userToModify)) return userToModify; + + return await this.rolesService.getPermissions(userToModify.roles); + } + + public async addRoles( + user: string | EUserBackend, + roles: Roles, + ): AsyncFailable { + const userToModify = await this.resolve(user); + if (HasFailed(userToModify)) return userToModify; + + // This is stupid + userToModify.roles = [...new Set([...userToModify.roles, ...roles])]; + + try { + await this.usersRepository.save(userToModify); + } catch (e: any) { + return Fail(e?.message); + } + + return true; + } + + public async removeRoles( + user: string | EUserBackend, + roles: Roles, + ): AsyncFailable { + const userToModify = await this.resolve(user); + if (HasFailed(userToModify)) return userToModify; + + // Make sure we don't remove unremovable roles + roles = roles.filter((role) => !PermanentRolesList.includes(role)); + + userToModify.roles = userToModify.roles.filter( + (role) => !roles.includes(role), + ); + + try { + await this.usersRepository.save(userToModify); + } catch (e: any) { + return Fail(e?.message); + } + + return true; + } + public async findOne( username: string, getPrivate?: B, @@ -90,45 +160,6 @@ export class UsersService { return HasSuccess(await this.findOne(username)); } - public async addRoles( - user: string | EUserBackend, - roles: Roles, - ): AsyncFailable { - const userToModify = await this.resolve(user); - if (HasFailed(userToModify)) return userToModify; - - // This is stupid - userToModify.roles = [...new Set([...userToModify.roles, ...roles])]; - - try { - await this.usersRepository.save(userToModify); - } catch (e: any) { - return Fail(e?.message); - } - - return true; - } - - public async removeRoles( - user: string | EUserBackend, - roles: Roles, - ): AsyncFailable { - const userToModify = await this.resolve(user); - if (HasFailed(userToModify)) return userToModify; - - userToModify.roles = userToModify.roles.filter( - (role) => !roles.includes(role), - ); - - try { - await this.usersRepository.save(userToModify); - } catch (e: any) { - return Fail(e?.message); - } - - return true; - } - private async resolve( user: string | EUserBackend, ): AsyncFailable { diff --git a/backend/src/decorators/permissions.decorator.ts b/backend/src/decorators/permissions.decorator.ts new file mode 100644 index 0000000..7fe7a5e --- /dev/null +++ b/backend/src/decorators/permissions.decorator.ts @@ -0,0 +1,17 @@ +import { SetMetadata, UseGuards } from '@nestjs/common'; +import { Permissions } from 'picsur-shared/dist/dto/permissions'; +import { CombineDecorators } from 'picsur-shared/dist/util/decorator'; +import { LocalAuthGuard } from '../managers/auth/guards/localauth.guard'; + +export const RequiredPermissions = (...permissions: Permissions) => { + return SetMetadata('permissions', permissions); +}; + +// Easy to read roles +export const NoAuth = () => RequiredPermissions(); + +export const UseLocalAuth = (...permissions: Permissions) => + CombineDecorators( + RequiredPermissions(...permissions), + UseGuards(LocalAuthGuard), + ); diff --git a/backend/src/decorators/roles.decorator.ts b/backend/src/decorators/roles.decorator.ts deleted file mode 100644 index 6f3e87a..0000000 --- a/backend/src/decorators/roles.decorator.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SetMetadata, UseGuards } from '@nestjs/common'; -import { Roles as RolesList } from 'picsur-shared/dist/dto/roles.dto'; -import { CombineDecorators } from 'picsur-shared/dist/util/decorator'; -import { LocalAuthGuard } from '../managers/auth/guards/localauth.guard'; - -export const GuestRoles = (...roles: RolesList) => { - return SetMetadata('roles', roles); -}; - -export const UserRoles = (...roles: RolesList) => { - const fullRoles = [...new Set(['user', ...roles])]; - return SetMetadata('roles', fullRoles); -}; - -// Easy to read roles -export const Guest = () => GuestRoles(); -export const User = () => UserRoles(); -export const Admin = () => UserRoles('admin'); - -export const UseLocalAuth = () => - CombineDecorators(Guest(), UseGuards(LocalAuthGuard)); diff --git a/backend/src/main.ts b/backend/src/main.ts index c258a97..22612b8 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/platform-fastify'; import * as multipart from 'fastify-multipart'; import { AppModule } from './app.module'; +import { UsersService } from './collections/userdb/userdb.service'; import { HostConfigService } from './config/host.config.service'; import { MainExceptionFilter } from './layers/httpexception/httpexception.filter'; import { SuccessInterceptor } from './layers/success/success.interceptor'; @@ -33,7 +34,9 @@ async function bootstrap() { forbidUnknownValues: true, }), ); - app.useGlobalGuards(new MainAuthGuard(new Reflector())); + app.useGlobalGuards( + new MainAuthGuard(app.get(Reflector), app.get(UsersService)), + ); app.useLogger(app.get(PicsurLoggerService)); diff --git a/backend/src/managers/auth/auth.module.ts b/backend/src/managers/auth/auth.module.ts index 0896c8a..4c3e4cf 100644 --- a/backend/src/managers/auth/auth.module.ts +++ b/backend/src/managers/auth/auth.module.ts @@ -1,10 +1,8 @@ -import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { HasFailed } from 'picsur-shared/dist/types'; import { SysPreferenceModule } from '../../collections/syspreferencesdb/syspreferencedb.module'; import { UsersModule } from '../../collections/userdb/userdb.module'; -import { AuthConfigService } from '../../config/auth.config.service'; import { JwtConfigService, JwtSecretProvider @@ -35,42 +33,6 @@ import { GuestService } from './guest.service'; JwtSecretProvider, GuestService, ], - exports: [AuthManagerService], + exports: [UsersModule, AuthManagerService], }) -export class AuthManagerModule implements OnModuleInit { - private readonly logger = new Logger('AuthModule'); - - constructor( - private authService: AuthManagerService, - private authConfigService: AuthConfigService, - ) {} - - async onModuleInit() { - await this.ensureAdminExists(); - } - - private async ensureAdminExists() { - const username = this.authConfigService.getDefaultAdminUsername(); - const password = this.authConfigService.getDefaultAdminPassword(); - this.logger.debug(`Ensuring admin user "${username}" exists`); - - const exists = await this.authService.userExists(username); - if (exists) return; - - const newUser = await this.authService.createUser(username, password); - if (HasFailed(newUser)) { - this.logger.error( - `Failed to create admin user "${username}" because: ${newUser.getReason()}`, - ); - return; - } - - const result = await this.authService.makeAdmin(newUser); - if (HasFailed(result)) { - this.logger.error( - `Failed to make admin user "${username}" because: ${result.getReason()}`, - ); - return; - } - } -} +export class AuthManagerModule {} diff --git a/backend/src/managers/auth/auth.service.ts b/backend/src/managers/auth/auth.service.ts index 05b3ac6..fb1656c 100644 --- a/backend/src/managers/auth/auth.service.ts +++ b/backend/src/managers/auth/auth.service.ts @@ -1,48 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import * as bcrypt from 'bcrypt'; import { instanceToPlain, plainToClass } from 'class-transformer'; import { validate } from 'class-validator'; import { JwtDataDto } from 'picsur-shared/dist/dto/auth.dto'; -import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types'; -import { UsersService } from '../../collections/userdb/userdb.service'; import { EUserBackend } from '../../models/entities/user.entity'; @Injectable() export class AuthManagerService { private readonly logger = new Logger('AuthService'); - constructor( - private usersService: UsersService, - private jwtService: JwtService, - ) {} - - async createUser(username: string, password: string): AsyncFailable { - const hashedPassword = await bcrypt.hash(password, 12); - return this.usersService.create(username, hashedPassword); - } - - async deleteUser(user: string | EUserBackend): AsyncFailable { - return this.usersService.delete(user); - } - - async listUsers(): AsyncFailable { - return this.usersService.findAll(); - } - - async userExists(username: string): Promise { - return this.usersService.exists(username); - } - - async authenticate(username: string, password: string): AsyncFailable { - const user = await this.usersService.findOne(username, true); - if (HasFailed(user)) return user; - - if (!(await bcrypt.compare(password, user.password))) - return Fail('Wrong password'); - - return await this.usersService.findOne(username); - } + constructor(private jwtService: JwtService) {} async createToken(user: EUserBackend): Promise { const jwtData: JwtDataDto = plainToClass(JwtDataDto, { @@ -57,12 +24,4 @@ export class AuthManagerService { return this.jwtService.signAsync(instanceToPlain(jwtData)); } - - async makeAdmin(user: string | EUserBackend): AsyncFailable { - return this.usersService.addRoles(user, ['admin']); - } - - async revokeAdmin(user: string | EUserBackend): AsyncFailable { - return this.usersService.removeRoles(user, ['admin']); - } } diff --git a/backend/src/managers/auth/guards/localauth.strategy.ts b/backend/src/managers/auth/guards/localauth.strategy.ts index 4bc886a..0b334dc 100644 --- a/backend/src/managers/auth/guards/localauth.strategy.ts +++ b/backend/src/managers/auth/guards/localauth.strategy.ts @@ -2,17 +2,17 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-local'; import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types'; +import { UsersService } from '../../../collections/userdb/userdb.service'; import { EUserBackend } from '../../../models/entities/user.entity'; -import { AuthManagerService } from '../auth.service'; @Injectable() export class LocalAuthStrategy extends PassportStrategy(Strategy, 'local') { - constructor(private authService: AuthManagerService) { + constructor(private usersService: UsersService) { super(); } async validate(username: string, password: string): AsyncFailable { - const user = await this.authService.authenticate(username, password); + const user = await this.usersService.authenticate(username, password); if (HasFailed(user)) { throw new UnauthorizedException(); } diff --git a/backend/src/managers/auth/guards/main.guard.ts b/backend/src/managers/auth/guards/main.guard.ts index 9c06f2c..1db211f 100644 --- a/backend/src/managers/auth/guards/main.guard.ts +++ b/backend/src/managers/auth/guards/main.guard.ts @@ -8,15 +8,23 @@ import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { plainToClass } from 'class-transformer'; import { isArray, isEnum, isString, validate } from 'class-validator'; -import { Roles, RolesList } from 'picsur-shared/dist/dto/roles.dto'; +import { + Permissions, + PermissionsList +} from 'picsur-shared/dist/dto/permissions'; +import { Roles } from 'picsur-shared/dist/dto/roles.dto'; import { Fail, Failable, HasFailed } from 'picsur-shared/dist/types'; +import { UsersService } from '../../../collections/userdb/userdb.service'; import { EUserBackend } from '../../../models/entities/user.entity'; @Injectable() export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) { private readonly logger = new Logger('MainAuthGuard'); - constructor(private reflector: Reflector) { + constructor( + private reflector: Reflector, + private usersService: UsersService, + ) { super(); } @@ -31,39 +39,47 @@ export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) { context.switchToHttp().getRequest().user, ); - const roles = this.extractRoles(context); - if (HasFailed(roles)) { - this.logger.warn(roles.getReason()); + const permissions = this.extractPermissions(context); + if (HasFailed(permissions)) { + this.logger.warn(permissions.getReason()); return false; } - // User must have all roles - return roles.every((role) => user.roles.includes(role)); + const userPermissions = await this.usersService.getPermissions(user); + if (HasFailed(userPermissions)) { + this.logger.warn(userPermissions.getReason()); + return false; + } + + return permissions.every((permission) => + userPermissions.includes(permission), + ); } - private extractRoles(context: ExecutionContext): Failable { + private extractPermissions(context: ExecutionContext): Failable { const handlerName = context.getHandler().name; - const roles = - this.reflector.get('roles', context.getHandler()) ?? - this.reflector.get('roles', context.getClass()); + const permissions = + this.reflector.get('permissions', context.getHandler()) ?? + this.reflector.get('permissions', context.getClass()); - if (roles === undefined) { + if (permissions === undefined) { return Fail( - `${handlerName} does not have any roles defined, denying access`, + `${handlerName} does not have any permissions defined, denying access`, ); } - if (!this.isRolesArray(roles)) { - return Fail(`Roles for ${handlerName} is not a string array`); + if (!this.isPermissionsArray(permissions)) { + return Fail(`Permissions for ${handlerName} is not a string array`); } - return roles; + return permissions; } - private isRolesArray(value: any): value is Roles { + private isPermissionsArray(value: any): value is Roles { if (!isArray(value)) return false; if (!value.every((item: unknown) => isString(item))) return false; - if (!value.every((item: string) => isEnum(item, RolesList))) return false; + if (!value.every((item: string) => isEnum(item, PermissionsList))) + return false; return true; } diff --git a/backend/src/managers/auth/guest.service.ts b/backend/src/managers/auth/guest.service.ts index d3770ca..6a31fc0 100644 --- a/backend/src/managers/auth/guest.service.ts +++ b/backend/src/managers/auth/guest.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { Roles } from 'picsur-shared/dist/dto/roles.dto'; import { EUserBackend } from '../../models/entities/user.entity'; @Injectable() @@ -7,13 +6,9 @@ export class GuestService { public createGuest(): EUserBackend { const guest = new EUserBackend(); guest.id = -1; - guest.roles = this.createGuestRoles(); + guest.roles = ['guest']; guest.username = 'guest'; return guest; } - - private createGuestRoles(): Roles { - return []; - } } diff --git a/backend/src/models/entities/index.ts b/backend/src/models/entities/index.ts index 7dedfb8..d4db691 100644 --- a/backend/src/models/entities/index.ts +++ b/backend/src/models/entities/index.ts @@ -1,5 +1,6 @@ import { EImageBackend } from './image.entity'; +import { ERoleBackend } from './role.entity'; import { ESysPreferenceBackend } from './syspreference.entity'; import { EUserBackend } from './user.entity'; -export const EntityList = [EImageBackend, EUserBackend, ESysPreferenceBackend]; +export const EntityList = [EImageBackend, EUserBackend, ERoleBackend, ESysPreferenceBackend]; diff --git a/backend/src/models/entities/role.entity.ts b/backend/src/models/entities/role.entity.ts new file mode 100644 index 0000000..5deef65 --- /dev/null +++ b/backend/src/models/entities/role.entity.ts @@ -0,0 +1,16 @@ +import { Permissions } from 'picsur-shared/dist/dto/permissions'; +import { ERole } from 'picsur-shared/dist/entities/role.entity'; +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class ERoleBackend extends ERole { + @PrimaryGeneratedColumn() + override id?: number; + + @Index() + @Column({ nullable: false, unique: true }) + override name: string; + + @Column('text', { nullable: false, array: true }) + override permissions: Permissions; +} diff --git a/backend/src/routes/api/auth/auth.controller.ts b/backend/src/routes/api/auth/auth.controller.ts index b257a89..0d3c796 100644 --- a/backend/src/routes/api/auth/auth.controller.ts +++ b/backend/src/routes/api/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, InternalServerErrorException, + Logger, Post, Request } from '@nestjs/common'; @@ -13,16 +14,22 @@ import { AuthRegisterRequest } from 'picsur-shared/dist/dto/auth.dto'; import { HasFailed } from 'picsur-shared/dist/types'; -import { Admin, UseLocalAuth, User } from '../../../decorators/roles.decorator'; +import { UsersService } from '../../../collections/userdb/userdb.service'; +import { RequiredPermissions, UseLocalAuth } from '../../../decorators/permissions.decorator'; import { AuthManagerService } from '../../../managers/auth/auth.service'; import AuthFasityRequest from '../../../models/dto/authrequest.dto'; @Controller('api/auth') export class AuthController { - constructor(private authService: AuthManagerService) {} + private readonly logger = new Logger('AuthController'); + + constructor( + private usersService: UsersService, + private authService: AuthManagerService, + ) {} @Post('login') - @UseLocalAuth() + @UseLocalAuth('user-login') async login(@Request() req: AuthFasityRequest) { const response: AuthLoginResponse = { jwt_token: await this.authService.createToken(req.user), @@ -31,37 +38,37 @@ export class AuthController { return response; } - @Post('create') - @Admin() + @Post('register') + @RequiredPermissions('user-register') async register( @Request() req: AuthFasityRequest, @Body() register: AuthRegisterRequest, ) { - const user = await this.authService.createUser( + const user = await this.usersService.create( register.username, register.password, ); if (HasFailed(user)) { - console.warn(user.getReason()); - throw new InternalServerErrorException('Could not create user'); + this.logger.warn(user.getReason()); + throw new InternalServerErrorException('Could not register user'); } if (register.isAdmin) { - await this.authService.makeAdmin(user); + await this.usersService.addRoles(user, ['admin']); } return user; } @Post('delete') - @Admin() + @RequiredPermissions('user-manage') async delete( @Request() req: AuthFasityRequest, @Body() deleteData: AuthDeleteRequest, ) { - const user = await this.authService.deleteUser(deleteData.username); + const user = await this.usersService.delete(deleteData.username); if (HasFailed(user)) { - console.warn(user.getReason()); + this.logger.warn(user.getReason()); throw new InternalServerErrorException('Could not delete user'); } @@ -69,11 +76,11 @@ export class AuthController { } @Get('list') - @Admin() + @RequiredPermissions('user-manage') async listUsers(@Request() req: AuthFasityRequest) { - const users = this.authService.listUsers(); + const users = this.usersService.findAll(); if (HasFailed(users)) { - console.warn(users.getReason()); + this.logger.warn(users.getReason()); throw new InternalServerErrorException('Could not list users'); } @@ -81,10 +88,17 @@ export class AuthController { } @Get('me') - @User() + @RequiredPermissions('user-view') async me(@Request() req: AuthFasityRequest) { + const permissions = await this.usersService.getPermissions(req.user); + if (HasFailed(permissions)) { + this.logger.warn(permissions.getReason()); + throw new InternalServerErrorException('Could not get permissions'); + } + const meResponse: AuthMeResponse = new AuthMeResponse(); meResponse.user = req.user; + meResponse.permissions = permissions; meResponse.newJwtToken = await this.authService.createToken(req.user); return meResponse; diff --git a/backend/src/routes/api/experiment/experiment.controller.ts b/backend/src/routes/api/experiment/experiment.controller.ts index 31cbe3e..777052b 100644 --- a/backend/src/routes/api/experiment/experiment.controller.ts +++ b/backend/src/routes/api/experiment/experiment.controller.ts @@ -1,12 +1,11 @@ import { Controller, Get, Request } from '@nestjs/common'; -import { Guest } from '../../../decorators/roles.decorator'; import AuthFasityRequest from '../../../models/dto/authrequest.dto'; + @Controller('api/experiment') export class ExperimentController { - @Get() - @Guest() + // @Guest() async testRoute(@Request() req: AuthFasityRequest) { return { message: req.user, diff --git a/backend/src/routes/api/pref/pref.controller.ts b/backend/src/routes/api/pref/pref.controller.ts index 61fca0a..0834ca9 100644 --- a/backend/src/routes/api/pref/pref.controller.ts +++ b/backend/src/routes/api/pref/pref.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, InternalServerErrorException, + Logger, Param, Post } from '@nestjs/common'; @@ -12,11 +13,13 @@ import { } from 'picsur-shared/dist/dto/syspreferences.dto'; import { HasFailed } from 'picsur-shared/dist/types'; import { SysPreferenceService } from '../../../collections/syspreferencesdb/syspreferencedb.service'; -import { Admin } from '../../../decorators/roles.decorator'; +import { RequiredPermissions } from '../../../decorators/permissions.decorator'; @Controller('api/pref') -@Admin() +@RequiredPermissions('syspref-manage') export class PrefController { + private readonly logger = new Logger('PrefController'); + constructor(private prefService: SysPreferenceService) {} @Get('sys/:key') @@ -25,7 +28,7 @@ export class PrefController { key as SysPreferences, ); if (HasFailed(returned)) { - console.warn(returned.getReason()); + this.logger.warn(returned.getReason()); throw new InternalServerErrorException('Could not get preference'); } @@ -43,7 +46,7 @@ export class PrefController { value, ); if (HasFailed(returned)) { - console.warn(returned.getReason()); + this.logger.warn(returned.getReason()); throw new InternalServerErrorException('Could not set preference'); } diff --git a/backend/src/routes/image/imageroute.controller.ts b/backend/src/routes/image/imageroute.controller.ts index 4301a98..608f16f 100644 --- a/backend/src/routes/image/imageroute.controller.ts +++ b/backend/src/routes/image/imageroute.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, InternalServerErrorException, + Logger, NotFoundException, Param, Post, @@ -13,13 +14,15 @@ import { isHash } from 'class-validator'; import { FastifyReply, FastifyRequest } from 'fastify'; import { HasFailed } from 'picsur-shared/dist/types'; import { MultiPart } from '../../decorators/multipart.decorator'; -import { Guest } from '../../decorators/roles.decorator'; +import { RequiredPermissions } from '../../decorators/permissions.decorator'; import { ImageManagerService } from '../../managers/imagemanager/imagemanager.service'; import { ImageUploadDto } from '../../models/dto/imageroute.dto'; @Controller('i') -@Guest() +@RequiredPermissions('image-view') export class ImageController { + private readonly logger = new Logger('ImageController'); + constructor(private readonly imagesService: ImageManagerService) {} @Get(':hash') @@ -31,7 +34,7 @@ export class ImageController { const image = await this.imagesService.retrieveComplete(hash); if (HasFailed(image)) { - console.warn(image.getReason()); + this.logger.warn(image.getReason()); throw new NotFoundException('Could not find image'); } @@ -45,7 +48,7 @@ export class ImageController { const image = await this.imagesService.retrieveInfo(hash); if (HasFailed(image)) { - console.warn(image.getReason()); + this.logger.warn(image.getReason()); throw new NotFoundException('Could not find image'); } @@ -53,7 +56,7 @@ export class ImageController { } @Post() - //@User() + @RequiredPermissions('image-upload') async uploadImage( @Req() req: FastifyRequest, @MultiPart(ImageUploadDto) multipart: ImageUploadDto, @@ -61,7 +64,7 @@ export class ImageController { const fileBuffer = await multipart.image.toBuffer(); const image = await this.imagesService.upload(fileBuffer); if (HasFailed(image)) { - console.warn(image.getReason()); + this.logger.warn(image.getReason()); throw new InternalServerErrorException('Could not upload image'); } diff --git a/frontend/src/app/api/api.service.ts b/frontend/src/app/api/api.service.ts index 2f6add0..7b7f7d6 100644 --- a/frontend/src/app/api/api.service.ts +++ b/frontend/src/app/api/api.service.ts @@ -14,6 +14,8 @@ import { KeyService } from './key.service'; providedIn: 'root', }) export class ApiService { + private readonly logger = console; + constructor(private keyService: KeyService) {} public async get( @@ -32,7 +34,7 @@ export class ApiService { const sendClass = plainToClass(sendType, data); const errors = await validate(sendClass); if (errors.length > 0) { - console.warn(errors); + this.logger.warn(errors); return Fail('Something went wrong'); } @@ -69,14 +71,14 @@ export class ApiService { >(ApiSuccessResponse, result); const resultErrors = await validate(resultClass); if (resultErrors.length > 0) { - console.warn('result', resultErrors); + this.logger.warn('result', resultErrors); return Fail('Something went wrong'); } const dataClass = plainToClass(type, result.data); const dataErrors = await validate(dataClass); if (dataErrors.length > 0) { - console.warn('data', dataErrors); + this.logger.warn('data', dataErrors); return Fail('Something went wrong'); } @@ -94,7 +96,7 @@ export class ApiService { try { return await response.json(); } catch (e) { - console.warn(e); + this.logger.warn(e); return Fail('Something went wrong'); } } @@ -109,7 +111,7 @@ export class ApiService { try { return await response.arrayBuffer(); } catch (e) { - console.warn(e); + this.logger.warn(e); return Fail('Something went wrong'); } } @@ -130,7 +132,7 @@ export class ApiService { return await window.fetch(url, options); } catch (e: any) { - console.warn(e); + this.logger.warn(e); return Fail('Something went wrong'); } } diff --git a/frontend/src/app/api/user.service.ts b/frontend/src/app/api/user.service.ts index ab65aef..3e0b8ed 100644 --- a/frontend/src/app/api/user.service.ts +++ b/frontend/src/app/api/user.service.ts @@ -21,6 +21,8 @@ import { KeyService } from './key.service'; providedIn: 'root', }) export class UserService { + private readonly logger = console; + public get liveUser() { return this.userSubject; } @@ -79,7 +81,7 @@ export class UserService { const user = await this.extractUser(apikey); if (HasFailed(user)) { - console.warn(user.getReason()); + this.logger.warn(user.getReason()); await this.logout(); return; } @@ -88,7 +90,7 @@ export class UserService { const fetchedUser = await this.fetchUser(); if (HasFailed(fetchedUser)) { - console.warn(fetchedUser.getReason()); + this.logger.warn(fetchedUser.getReason()); await this.logout(); return; } @@ -107,7 +109,7 @@ export class UserService { const jwtData = plainToClass(JwtDataDto, decoded); const errors = await validate(jwtData); if (errors.length > 0) { - console.warn(errors); + this.logger.warn(errors); return Fail('Invalid token data'); } diff --git a/frontend/src/app/routes/login/login.component.ts b/frontend/src/app/routes/login/login.component.ts index 2a47a48..160e52f 100644 --- a/frontend/src/app/routes/login/login.component.ts +++ b/frontend/src/app/routes/login/login.component.ts @@ -12,6 +12,8 @@ import { LoginControl } from './login.model'; styleUrls: ['./login.component.scss'], }) export class LoginComponent implements OnInit { + private readonly logger = console; + model = new LoginControl(); loginFail = false; @@ -35,7 +37,7 @@ export class LoginComponent implements OnInit { const user = await this.userService.login(data.username, data.password); if (HasFailed(user)) { - console.warn(user); + this.logger.warn(user); this.loginFail = true; return; } diff --git a/shared/src/dto/auth.dto.ts b/shared/src/dto/auth.dto.ts index 3fac9e6..6e85a88 100644 --- a/shared/src/dto/auth.dto.ts +++ b/shared/src/dto/auth.dto.ts @@ -1,14 +1,12 @@ +import { Type } from 'class-transformer'; import { - IsBoolean, - IsDefined, - IsNotEmpty, + IsArray, IsBoolean, + IsDefined, IsEnum, IsInt, IsNotEmpty, IsOptional, - IsString, - IsInt, - ValidateNested, + IsString, ValidateNested } from 'class-validator'; import { EUser } from '../entities/user.entity'; -import { Type } from 'class-transformer'; +import { Permissions, PermissionsList } from './permissions'; // Api @@ -56,6 +54,11 @@ export class AuthMeResponse { @Type(() => EUser) user: EUser; + @IsDefined() + @IsArray() + @IsEnum(PermissionsList, { each: true }) + permissions: Permissions; + @IsString() @IsDefined() newJwtToken: string; diff --git a/shared/src/dto/permissions.ts b/shared/src/dto/permissions.ts new file mode 100644 index 0000000..03cd694 --- /dev/null +++ b/shared/src/dto/permissions.ts @@ -0,0 +1,20 @@ +import tuple from '../types/tuple'; + +// Config + +const PermissionsTuple = tuple( + 'image-view', + 'image-upload', + 'user-login', // Ability to log in + 'user-register', // Ability to register + 'user-view', // Ability to view user info, only granted if logged in + 'user-manage', + 'syspref-manage', +); + +// Derivatives + +export const PermissionsList: string[] = PermissionsTuple; + +export type Permission = typeof PermissionsTuple[number]; +export type Permissions = Permission[]; diff --git a/shared/src/dto/roles.dto.ts b/shared/src/dto/roles.dto.ts index 3ae6867..8cf14c4 100644 --- a/shared/src/dto/roles.dto.ts +++ b/shared/src/dto/roles.dto.ts @@ -1,12 +1,36 @@ import tuple from '../types/tuple'; +import { Permissions, PermissionsList } from './permissions'; // Config -const RolesTuple = tuple('user', 'admin'); +// These roles can never be removed from a user +const PermanentRolesTuple = tuple('guest', 'user'); +// These reles can never be modified +const ImmuteableRolesTuple = tuple('admin'); +// These roles can never be removed from the server +const SystemRolesTuple = tuple(...PermanentRolesTuple, ...ImmuteableRolesTuple); // Derivatives -export const RolesList: string[] = RolesTuple; +export const PermanentRolesList: string[] = PermanentRolesTuple; +export const ImmuteableRolesList: string[] = ImmuteableRolesTuple; +export const SystemRolesList: string[] = SystemRolesTuple; -export type Role = typeof RolesTuple[number]; +export type SystemRole = typeof SystemRolesTuple[number]; +export type SystemRoles = SystemRole[]; + +// Defaults + +export const SystemRoleDefaults: { + [key in SystemRole]: Permissions; +} = { + guest: ['image-view', 'user-login'], + user: ['image-view', 'user-view', 'user-login', 'image-upload'], + // Grant all permissions to admin + admin: PermissionsList as Permissions, +}; + +// Normal roles types + +export type Role = SystemRole | string; export type Roles = Role[]; diff --git a/shared/src/entities/role.entity.ts b/shared/src/entities/role.entity.ts new file mode 100644 index 0000000..c55d62a --- /dev/null +++ b/shared/src/entities/role.entity.ts @@ -0,0 +1,14 @@ +import { IsArray, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; +import { Permissions, PermissionsList } from '../dto/permissions'; + +export class ERole { + @IsOptional() + id?: number; + + @IsNotEmpty() + name: string; + + @IsArray() + @IsEnum(PermissionsList, { each: true }) + permissions: Permissions; +} diff --git a/shared/src/entities/user.entity.ts b/shared/src/entities/user.entity.ts index f2ece99..601c3bd 100644 --- a/shared/src/entities/user.entity.ts +++ b/shared/src/entities/user.entity.ts @@ -1,6 +1,10 @@ import { Exclude } from 'class-transformer'; -import { IsArray, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; -import { Roles, RolesList } from '../dto/roles.dto'; +import { + IsArray, IsNotEmpty, + IsOptional, + IsString +} from 'class-validator'; +import { Roles } from '../dto/roles.dto'; export class EUser { @IsOptional() @@ -10,7 +14,7 @@ export class EUser { username: string; @IsArray() - @IsEnum(RolesList, { each: true }) + @IsString({ each: true }) roles: Roles; @IsOptional()