diff --git a/.gitignore b/.gitignore index 3c3629e..39fed95 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +todo.txt diff --git a/backend/src/collections/syspreferencesdb/syspreferencedb.service.ts b/backend/src/collections/syspreferencesdb/syspreferencedb.service.ts index cdf53ec..55c73f5 100644 --- a/backend/src/collections/syspreferencesdb/syspreferencedb.service.ts +++ b/backend/src/collections/syspreferencesdb/syspreferencedb.service.ts @@ -61,7 +61,9 @@ export class SysPreferenceService { ESysPreferenceBackend, foundSysPreference, ); - const errors = await validate(foundSysPreference); + const errors = await validate(foundSysPreference, { + forbidUnknownValues: true, + }); if (errors.length > 0) { this.logger.warn(errors); return Fail('Invalid preference'); @@ -85,7 +87,9 @@ export class SysPreferenceService { verifySysPreference.key = key as SysPreferences; verifySysPreference.value = value; - const errors = await validate(verifySysPreference); + const errors = await validate(verifySysPreference, { + forbidUnknownValues: true, + }); if (errors.length > 0) { this.logger.warn(errors); return Fail('Invalid preference'); diff --git a/backend/src/decorators/authenticated.ts b/backend/src/decorators/authenticated.ts deleted file mode 100644 index 278e22f..0000000 --- a/backend/src/decorators/authenticated.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { CanActivate, UseGuards } from '@nestjs/common'; -import { AdminGuard } from '../managers/auth/guards/admin.guard'; -import { MainAuthGuard } from '../managers/auth/guards/main.guard'; - -export const Authenticated = (adminOnly: boolean = false) => { - const guards: (Function | CanActivate)[] = [MainAuthGuard]; - if (adminOnly) guards.push(AdminGuard); - - return UseGuards(...guards); -}; diff --git a/backend/src/decorators/multipart.ts b/backend/src/decorators/multipart.decorator.ts similarity index 100% rename from backend/src/decorators/multipart.ts rename to backend/src/decorators/multipart.decorator.ts diff --git a/backend/src/decorators/roles.decorator.ts b/backend/src/decorators/roles.decorator.ts new file mode 100644 index 0000000..6f3e87a --- /dev/null +++ b/backend/src/decorators/roles.decorator.ts @@ -0,0 +1,21 @@ +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 aabdb8a..c258a97 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,5 +1,5 @@ import { ValidationPipe } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; +import { NestFactory, Reflector } from '@nestjs/core'; import { FastifyAdapter, NestFastifyApplication @@ -10,6 +10,7 @@ import { HostConfigService } from './config/host.config.service'; import { MainExceptionFilter } from './layers/httpexception/httpexception.filter'; import { SuccessInterceptor } from './layers/success/success.interceptor'; import { PicsurLoggerService } from './logger/logger.service'; +import { MainAuthGuard } from './managers/auth/guards/main.guard'; async function bootstrap() { const fastifyAdapter = new FastifyAdapter(); @@ -32,6 +33,7 @@ async function bootstrap() { forbidUnknownValues: true, }), ); + app.useGlobalGuards(new MainAuthGuard(new Reflector())); app.useLogger(app.get(PicsurLoggerService)); diff --git a/backend/src/managers/auth/auth.module.ts b/backend/src/managers/auth/auth.module.ts index 3f8b149..0896c8a 100644 --- a/backend/src/managers/auth/auth.module.ts +++ b/backend/src/managers/auth/auth.module.ts @@ -14,6 +14,7 @@ import { AuthManagerService } from './auth.service'; import { GuestStrategy } from './guards/guest.strategy'; import { JwtStrategy } from './guards/jwt.strategy'; import { LocalAuthStrategy } from './guards/localauth.strategy'; +import { GuestService } from './guest.service'; @Module({ imports: [ @@ -32,6 +33,7 @@ import { LocalAuthStrategy } from './guards/localauth.strategy'; JwtStrategy, GuestStrategy, JwtSecretProvider, + GuestService, ], exports: [AuthManagerService], }) diff --git a/backend/src/managers/auth/guards/guest.strategy.ts b/backend/src/managers/auth/guards/guest.strategy.ts index 6fd0c57..a4b1ce0 100644 --- a/backend/src/managers/auth/guards/guest.strategy.ts +++ b/backend/src/managers/auth/guards/guest.strategy.ts @@ -4,6 +4,7 @@ import { Request } from 'express'; import { ParamsDictionary } from 'express-serve-static-core'; import { Strategy } from 'passport-strategy'; import { ParsedQs } from 'qs'; +import { GuestService } from '../guest.service'; type ReqType = Request< ParamsDictionary, @@ -18,10 +19,9 @@ class GuestPassportStrategy extends Strategy { return undefined; } - override authenticate(req: ReqType, options?: any): void { - const user = this.validate(req); - req['user'] = user; - this.pass(); + override async authenticate(req: ReqType, options?: any) { + const user = await this.validate(req); + this.success(user); } } @@ -32,8 +32,11 @@ export class GuestStrategy extends PassportStrategy( ) { private readonly logger = new Logger('GuestStrategy'); + constructor(private guestService: GuestService) { + super(); + } + override async validate(payload: any) { - // TODO: add guest user - return; + return this.guestService.createGuest(); } } diff --git a/backend/src/managers/auth/guards/main.guard.ts b/backend/src/managers/auth/guards/main.guard.ts index c4924ad..9c06f2c 100644 --- a/backend/src/managers/auth/guards/main.guard.ts +++ b/backend/src/managers/auth/guards/main.guard.ts @@ -1,5 +1,84 @@ -import { Injectable } from '@nestjs/common'; +import { + ExecutionContext, + Injectable, + InternalServerErrorException, + Logger +} from '@nestjs/common'; +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 { Fail, Failable, HasFailed } from 'picsur-shared/dist/types'; +import { EUserBackend } from '../../../models/entities/user.entity'; @Injectable() -export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) {} +export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) { + private readonly logger = new Logger('MainAuthGuard'); + + constructor(private reflector: Reflector) { + super(); + } + + override async canActivate(context: ExecutionContext): Promise { + const result = await super.canActivate(context); + if (result !== true) { + this.logger.error('Main Auth has denied access, this should not happen'); + return false; + } + + const user = await this.validateUser( + context.switchToHttp().getRequest().user, + ); + + const roles = this.extractRoles(context); + if (HasFailed(roles)) { + this.logger.warn(roles.getReason()); + return false; + } + + // User must have all roles + return roles.every((role) => user.roles.includes(role)); + } + + private extractRoles(context: ExecutionContext): Failable { + const handlerName = context.getHandler().name; + const roles = + this.reflector.get('roles', context.getHandler()) ?? + this.reflector.get('roles', context.getClass()); + + if (roles === undefined) { + return Fail( + `${handlerName} does not have any roles defined, denying access`, + ); + } + + if (!this.isRolesArray(roles)) { + return Fail(`Roles for ${handlerName} is not a string array`); + } + + return roles; + } + + private isRolesArray(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; + return true; + } + + private async validateUser(user: EUserBackend): Promise { + const userClass = plainToClass(EUserBackend, user); + const errors = await validate(userClass, { + forbidUnknownValues: true, + }); + + if (errors.length > 0) { + this.logger.error( + 'Invalid user object, where it should always be valid: ' + errors, + ); + throw new InternalServerErrorException(); + } + return userClass; + } +} diff --git a/backend/src/managers/auth/guest.service.ts b/backend/src/managers/auth/guest.service.ts new file mode 100644 index 0000000..d3770ca --- /dev/null +++ b/backend/src/managers/auth/guest.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { Roles } from 'picsur-shared/dist/dto/roles.dto'; +import { EUserBackend } from '../../models/entities/user.entity'; + +@Injectable() +export class GuestService { + public createGuest(): EUserBackend { + const guest = new EUserBackend(); + guest.id = -1; + guest.roles = this.createGuestRoles(); + guest.username = 'guest'; + + return guest; + } + + private createGuestRoles(): Roles { + return []; + } +} diff --git a/backend/src/routes/api/auth/auth.controller.ts b/backend/src/routes/api/auth/auth.controller.ts index fa7e212..b257a89 100644 --- a/backend/src/routes/api/auth/auth.controller.ts +++ b/backend/src/routes/api/auth/auth.controller.ts @@ -4,8 +4,7 @@ import { Get, InternalServerErrorException, Post, - Request, - UseGuards + Request } from '@nestjs/common'; import { AuthDeleteRequest, @@ -14,9 +13,8 @@ import { AuthRegisterRequest } from 'picsur-shared/dist/dto/auth.dto'; import { HasFailed } from 'picsur-shared/dist/types'; -import { Authenticated } from '../../../decorators/authenticated'; +import { Admin, UseLocalAuth, User } from '../../../decorators/roles.decorator'; import { AuthManagerService } from '../../../managers/auth/auth.service'; -import { LocalAuthGuard } from '../../../managers/auth/guards/localauth.guard'; import AuthFasityRequest from '../../../models/dto/authrequest.dto'; @Controller('api/auth') @@ -24,7 +22,7 @@ export class AuthController { constructor(private authService: AuthManagerService) {} @Post('login') - @UseGuards(LocalAuthGuard) + @UseLocalAuth() async login(@Request() req: AuthFasityRequest) { const response: AuthLoginResponse = { jwt_token: await this.authService.createToken(req.user), @@ -34,7 +32,7 @@ export class AuthController { } @Post('create') - @Authenticated(true) + @Admin() async register( @Request() req: AuthFasityRequest, @Body() register: AuthRegisterRequest, @@ -56,7 +54,7 @@ export class AuthController { } @Post('delete') - @Authenticated(true) + @Admin() async delete( @Request() req: AuthFasityRequest, @Body() deleteData: AuthDeleteRequest, @@ -71,7 +69,7 @@ export class AuthController { } @Get('list') - @Authenticated(true) + @Admin() async listUsers(@Request() req: AuthFasityRequest) { const users = this.authService.listUsers(); if (HasFailed(users)) { @@ -83,7 +81,7 @@ export class AuthController { } @Get('me') - @Authenticated() + @User() async me(@Request() req: AuthFasityRequest) { const meResponse: AuthMeResponse = new AuthMeResponse(); meResponse.user = req.user; diff --git a/backend/src/routes/api/experiment/experiment.controller.ts b/backend/src/routes/api/experiment/experiment.controller.ts index f28aadf..31cbe3e 100644 --- a/backend/src/routes/api/experiment/experiment.controller.ts +++ b/backend/src/routes/api/experiment/experiment.controller.ts @@ -1,13 +1,13 @@ -import { Controller, Get, Request, UseGuards } from '@nestjs/common'; -import { MainAuthGuard } from '../../../managers/auth/guards/main.guard'; +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() - @UseGuards(MainAuthGuard) + @Guest() async testRoute(@Request() req: AuthFasityRequest) { - console.log("calledroutes") 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 58b4c42..61fca0a 100644 --- a/backend/src/routes/api/pref/pref.controller.ts +++ b/backend/src/routes/api/pref/pref.controller.ts @@ -12,10 +12,10 @@ import { } from 'picsur-shared/dist/dto/syspreferences.dto'; import { HasFailed } from 'picsur-shared/dist/types'; import { SysPreferenceService } from '../../../collections/syspreferencesdb/syspreferencedb.service'; -import { Authenticated } from '../../../decorators/authenticated'; +import { Admin } from '../../../decorators/roles.decorator'; @Controller('api/pref') -@Authenticated(true) +@Admin() export class PrefController { constructor(private prefService: SysPreferenceService) {} diff --git a/backend/src/routes/image/imageroute.controller.ts b/backend/src/routes/image/imageroute.controller.ts index 48a6985..4301a98 100644 --- a/backend/src/routes/image/imageroute.controller.ts +++ b/backend/src/routes/image/imageroute.controller.ts @@ -12,11 +12,13 @@ import { import { isHash } from 'class-validator'; import { FastifyReply, FastifyRequest } from 'fastify'; import { HasFailed } from 'picsur-shared/dist/types'; -import { MultiPart } from '../../decorators/multipart'; +import { MultiPart } from '../../decorators/multipart.decorator'; +import { Guest } from '../../decorators/roles.decorator'; import { ImageManagerService } from '../../managers/imagemanager/imagemanager.service'; import { ImageUploadDto } from '../../models/dto/imageroute.dto'; @Controller('i') +@Guest() export class ImageController { constructor(private readonly imagesService: ImageManagerService) {} @@ -51,6 +53,7 @@ export class ImageController { } @Post() + //@User() async uploadImage( @Req() req: FastifyRequest, @MultiPart(ImageUploadDto) multipart: ImageUploadDto, diff --git a/shared/src/util/decorator.ts b/shared/src/util/decorator.ts new file mode 100644 index 0000000..148472d --- /dev/null +++ b/shared/src/util/decorator.ts @@ -0,0 +1,9 @@ +type FCDecorator = MethodDecorator & ClassDecorator; + +export function CombineDecorators(...decorators: FCDecorator[]) { + return (target: any, key: string, descriptor: PropertyDescriptor) => { + decorators.forEach(decorator => { + decorator(target, key, descriptor); + }); + } +}