diff --git a/src/decorators/privileges.decorator.ts b/src/decorators/privileges.decorator.ts index f3b3cbf..2613f50 100644 --- a/src/decorators/privileges.decorator.ts +++ b/src/decorators/privileges.decorator.ts @@ -4,5 +4,5 @@ import { SetMetadata } from '@nestjs/common'; * Restrict this route to only these privileges. AND logic! * @param privileges List of privileges for this route */ -export const Privileges = (...privileges: string[]) => +export const Privileges = (...privileges: (string | string[])[]) => SetMetadata('privileges', privileges); diff --git a/src/guards/privileges.guard.ts b/src/guards/privileges.guard.ts index 0b89c65..7a7f30d 100644 --- a/src/guards/privileges.guard.ts +++ b/src/guards/privileges.guard.ts @@ -9,7 +9,7 @@ export class PrivilegesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const privileges = this.reflector.get( + const privileges = this.reflector.get<(string | string[])[]>( 'privileges', context.getHandler(), ); @@ -18,6 +18,20 @@ export class PrivilegesGuard implements CanActivate { } const request = context.switchToHttp().getRequest(); const user = request.user; + + const withOrLogic = privileges.some((entry) => Array.isArray(entry)); + if (withOrLogic) { + return privileges.some((entry) => { + if (Array.isArray(entry)) { + return entry.every((item) => + user.privileges.find(({ name }) => name === item), + ); + } else { + return user.privileges.find(({ name }) => name === entry); + } + }); + } + return privileges.every((item) => user.privileges.find(({ name }) => name === item), ); diff --git a/src/modules/api/admin/admin.service.ts b/src/modules/api/admin/admin.service.ts index 6c235bb..9520671 100644 --- a/src/modules/api/admin/admin.service.ts +++ b/src/modules/api/admin/admin.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; +import { Client } from 'connect-redis'; import { OAuth2Client } from 'src/modules/objects/oauth2-client/oauth2-client.entity'; +import { User } from 'src/modules/objects/user/user.entity'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; +const UNPRIVILEGED_STRIP = ['openid', 'id_token', 'management', 'implicit']; + @Injectable() export class AdminService { constructor(private _form: FormUtilityService) {} @@ -14,4 +18,26 @@ export class AdminService { : null, } as Partial; } + + public userHasPrivilege(user: User, privilege: string): boolean { + return user.privileges.some(({ name }) => name === privilege); + } + + public userCanEditClient(user: User, client: Client): boolean { + if (this.userHasPrivilege(user, 'admin:oauth2')) { + return true; + } + + return client.owner?.id === user.id; + } + + public removeUnprivileged(input: string[]): string[] { + return input.reduce((list, current) => { + if (UNPRIVILEGED_STRIP.includes(current)) { + return list; + } + + return [...list, current]; + }, []); + } } diff --git a/src/modules/api/admin/oauth2-admin.controller.ts b/src/modules/api/admin/oauth2-admin.controller.ts index ce9ee06..1cb87a0 100644 --- a/src/modules/api/admin/oauth2-admin.controller.ts +++ b/src/modules/api/admin/oauth2-admin.controller.ts @@ -10,6 +10,7 @@ import { Post, Put, Query, + UnauthorizedException, UploadedFile, UseGuards, UseInterceptors, @@ -36,6 +37,7 @@ import { TokenService } from 'src/modules/utility/services/token.service'; import { PageOptions } from 'src/types/pagination.interfaces'; import { AdminService } from './admin.service'; import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service'; +import { Throttle } from '@nestjs/throttler'; const RELATIONS = ['urls', 'picture', 'owner']; const SET_CLIENT_FIELDS = [ @@ -66,22 +68,47 @@ export class OAuth2AdminController { @Get('scopes') @Scopes('management') - @Privileges('admin', 'admin:oauth2') - async oauth2Scopes() { - return this._oaClient.availableScopes; + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') + async oauth2Scopes(@CurrentUser() user: User) { + const reducedPermissions = !this._service.userHasPrivilege( + user, + 'admin:oauth2', + ); + + return reducedPermissions + ? this._service.removeUnprivileged(this._oaClient.availableScopes) + : this._oaClient.availableScopes; } @Get('grants') @Scopes('management') - @Privileges('admin', 'admin:oauth2') - async oauth2Grants() { - return this._oaClient.availableGrantTypes; + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') + async oauth2Grants(@CurrentUser() user: User) { + const reducedPermissions = !this._service.userHasPrivilege( + user, + 'admin:oauth2', + ); + + return reducedPermissions + ? this._service.removeUnprivileged(this._oaClient.availableGrantTypes) + : this._oaClient.availableGrantTypes; } @Get('clients') @Scopes('management') - @Privileges('admin', 'admin:oauth2') - async oauth2ClientList(@Query() options: { q?: string } & PageOptions) { + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') + async oauth2ClientList( + @Query() options: { q?: string } & PageOptions, + @CurrentUser() user: User, + ) { + if (!this._service.userHasPrivilege(user, 'admin:oauth2')) { + const unprivileged = await this._oaClient.getClientsByOwner( + user, + RELATIONS, + ); + return unprivileged.map((item) => this._service.stripClientInfo(item)); + } + const search = options.q ? decodeURIComponent(options.q) : null; const resultCount = await this._oaClient.searchClientsCount( search, @@ -108,29 +135,49 @@ export class OAuth2AdminController { @Get('clients/:id') @Scopes('management') - @Privileges('admin', 'admin:oauth2') - async oauth2Client(@Param('id') id: string) { + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') + async oauth2Client(@Param('id') id: string, @CurrentUser() user: User) { const client = await this._oaClient.getById(parseInt(id, 10), RELATIONS); if (!client) { throw new NotFoundException('Client not found'); } + + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to view this client', + ); + } + return this._service.stripClientInfo(client); } @Patch('clients/:id') @Scopes('management') - @Privileges('admin', 'admin:oauth2') + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async updateOauth2Client( @Param('id') id: string, @Body() setter: Partial, + @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), []); if (!client) { throw new NotFoundException('Client not found'); } + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to edit this client', + ); + } + const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS); + // Non-admin cannot change the activated or verified status + if (!this._service.userHasPrivilege(user, 'admin:oauth2')) { + delete allowedFieldsOnly.activated; + delete allowedFieldsOnly.verified; + } + if (!Object.keys(allowedFieldsOnly).length) { return this._service.stripClientInfo(client); } @@ -143,13 +190,19 @@ export class OAuth2AdminController { @Post('clients/:id/new-secret') @Scopes('management') - @Privileges('admin', 'admin:oauth2') - async createNewSecret(@Param('id') id: string) { + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') + async createNewSecret(@Param('id') id: string, @CurrentUser() user: User) { const client = await this._oaClient.getById(parseInt(id, 10), []); if (!client) { throw new NotFoundException('Client not found'); } + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to edit this client', + ); + } + client.client_secret = this._token.generateSecret(); await this._oaClient.updateClient(client); @@ -162,13 +215,22 @@ export class OAuth2AdminController { @Delete('clients/:id/authorizations') @Scopes('management') - @Privileges('admin', 'admin:oauth2') - async deleteClientAuthorizations(@Param('id') id: string) { + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') + async deleteClientAuthorizations( + @Param('id') id: string, + @CurrentUser() user: User, + ) { const client = await this._oaClient.getById(parseInt(id, 10), []); if (!client) { throw new NotFoundException('Client not found'); } + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to edit this client', + ); + } + await this._oaClient.wipeClientAuthorizations(client); return this._service.stripClientInfo(client); @@ -176,21 +238,29 @@ export class OAuth2AdminController { @Get('clients/:id/urls') @Scopes('management') - @Privileges('admin', 'admin:oauth2') - async oauth2ClientURLs(@Param('id') id: string) { + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') + async oauth2ClientURLs(@Param('id') id: string, @CurrentUser() user: User) { const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); if (!client) { throw new NotFoundException('Client not found'); } + + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to edit this client', + ); + } + return client.urls; } @Delete('clients/:id/urls/:url') @Scopes('management') - @Privileges('admin', 'admin:oauth2') + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async deleteOAuth2ClientURL( @Param('id') id: string, @Param('url') urlId: string, + @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); const parsedURLId = parseInt(urlId, 10); @@ -199,6 +269,12 @@ export class OAuth2AdminController { throw new NotFoundException('Client not found'); } + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to edit this client', + ); + } + if (!parsedURLId) { throw new BadRequestException('Invalid URL ID'); } @@ -215,11 +291,12 @@ export class OAuth2AdminController { @Put('clients/:id/urls/:url') @Scopes('management') - @Privileges('admin', 'admin:oauth2') + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async updateOAuth2ClientURL( @Param('id') id: string, @Param('url') urlId: string, @Body() setter: { url: string; type: string }, + @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); const plucked = this._form.pluckObject(setter, ['url', 'type']); @@ -229,6 +306,12 @@ export class OAuth2AdminController { throw new NotFoundException('Client not found'); } + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to edit this client', + ); + } + if (!parsedURLId) { throw new BadRequestException('Invalid URL ID'); } @@ -249,16 +332,23 @@ export class OAuth2AdminController { @Post('clients/:id/urls') @Scopes('management') - @Privileges('admin', 'admin:oauth2') + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async createOAuth2ClientURL( @Param('id') id: string, @Body() setter: { url: string; type: string }, + @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); if (!client) { throw new NotFoundException('Client not found'); } + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to edit this client', + ); + } + if (!setter.url || !setter.type || !URL_TYPES.includes(setter.type)) { throw new NotFoundException('Missing or invalid fields'); } @@ -272,9 +362,10 @@ export class OAuth2AdminController { return url; } + @Throttle(3, 60) @Post('clients/:id/picture') @Scopes('management') - @Privileges('admin', 'admin:oauth2') + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') @UseInterceptors(FileInterceptor('file')) async uploadClientPictureFile( @CurrentUser() user: User, @@ -288,6 +379,12 @@ export class OAuth2AdminController { throw new NotFoundException('Client not found'); } + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to edit this client', + ); + } + if (!file) { throw new BadRequestException('Picture upload failed'); } @@ -315,13 +412,22 @@ export class OAuth2AdminController { @Delete('clients/:id/picture') @Scopes('management') - @Privileges('admin', 'admin:oauth2') - async deleteClientPictureFile(@Param('id') id: string) { + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') + async deleteClientPictureFile( + @Param('id') id: string, + @CurrentUser() user: User, + ) { const client = await this._oaClient.getById(parseInt(id, 10), ['picture']); if (!client) { throw new NotFoundException('Client not found'); } + if (!this._service.userCanEditClient(user, client)) { + throw new UnauthorizedException( + 'You do not have permission to edit this client', + ); + } + this._oaClient.deletePicture(client); client.picture = null; await this._oaClient.updateClient(client); @@ -332,12 +438,16 @@ export class OAuth2AdminController { // New client @Post('/clients') @Scopes('management') - @Privileges('admin', 'admin:oauth2') + @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async createNewClient( @Body() setter: Partial, @CurrentUser() user: User, ) { const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS); + const reducedPermissions = !this._service.userHasPrivilege( + user, + 'admin:oauth2', + ); if (!Object.keys(allowedFieldsOnly).length) { throw new BadRequestException('Required fields are missing'); @@ -349,20 +459,21 @@ export class OAuth2AdminController { const splitGrants = allowedFieldsOnly.grants.split(' '); const splitScopes = allowedFieldsOnly.scope.split(' '); + let availableGrantTypes = this._oaClient.availableGrantTypes; + let availableScopes = this._oaClient.availableScopes; - if ( - !splitGrants.every((grant) => - this._oaClient.availableGrantTypes.includes(grant), - ) - ) { + if (reducedPermissions) { + availableGrantTypes = + this._service.removeUnprivileged(availableGrantTypes); + availableScopes = this._service.removeUnprivileged(availableScopes); + allowedFieldsOnly.activated = true; + } + + if (!splitGrants.every((grant) => availableGrantTypes.includes(grant))) { throw new BadRequestException('Bad grant types'); } - if ( - !splitScopes.every((scope) => - this._oaClient.availableScopes.includes(scope), - ) - ) { + if (!splitScopes.every((scope) => availableScopes.includes(scope))) { throw new BadRequestException('Bad scopes'); } diff --git a/src/modules/objects/oauth2-client/oauth2-client.service.ts b/src/modules/objects/oauth2-client/oauth2-client.service.ts index 45e30f9..690f493 100644 --- a/src/modules/objects/oauth2-client/oauth2-client.service.ts +++ b/src/modules/objects/oauth2-client/oauth2-client.service.ts @@ -159,6 +159,9 @@ export class OAuth2ClientService { { title: ILike(`%${search}%`), }, + { + client_id: search, + }, ] : undefined, skip: offset, @@ -177,12 +180,25 @@ export class OAuth2ClientService { { title: ILike(`%${search}%`), }, + { + client_id: search, + }, ] : undefined, relations, }); } + public async getClientsByOwner( + owner: User, + relations?: string[], + ): Promise { + return this.clientRepository.find({ + where: { owner: { id: owner.id } }, + relations, + }); + } + public async getClientURLs( id: string, type?: OAuth2ClientURLType, diff --git a/src/modules/static-front-end/settings/settings.controller.ts b/src/modules/static-front-end/settings/settings.controller.ts index 272b562..cdfb2e2 100644 --- a/src/modules/static-front-end/settings/settings.controller.ts +++ b/src/modules/static-front-end/settings/settings.controller.ts @@ -15,6 +15,7 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { Throttle } from '@nestjs/throttler'; import { Request, Response } from 'express'; import { unlink } from 'fs/promises'; import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service'; @@ -83,6 +84,7 @@ export class SettingsController { res.redirect('/account/general'); } + @Throttle(3, 60) @Post('avatar') @UseInterceptors(FileInterceptor('file')) async uploadAvatarFile(