separate role for user oauth2 clients

This commit is contained in:
Evert Prants 2022-08-27 19:58:24 +03:00
parent e884ce80fc
commit 7a06c882ac
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
6 changed files with 205 additions and 36 deletions

View File

@ -4,5 +4,5 @@ import { SetMetadata } from '@nestjs/common';
* Restrict this route to only these privileges. AND logic! * Restrict this route to only these privileges. AND logic!
* @param privileges List of privileges for this route * @param privileges List of privileges for this route
*/ */
export const Privileges = (...privileges: string[]) => export const Privileges = (...privileges: (string | string[])[]) =>
SetMetadata('privileges', privileges); SetMetadata('privileges', privileges);

View File

@ -9,7 +9,7 @@ export class PrivilegesGuard implements CanActivate {
constructor(private reflector: Reflector) {} constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const privileges = this.reflector.get<string[]>( const privileges = this.reflector.get<(string | string[])[]>(
'privileges', 'privileges',
context.getHandler(), context.getHandler(),
); );
@ -18,6 +18,20 @@ export class PrivilegesGuard implements CanActivate {
} }
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const user = request.user; 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) => return privileges.every((item) =>
user.privileges.find(({ name }) => name === item), user.privileges.find(({ name }) => name === item),
); );

View File

@ -1,7 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Client } from 'connect-redis';
import { OAuth2Client } from 'src/modules/objects/oauth2-client/oauth2-client.entity'; 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'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
const UNPRIVILEGED_STRIP = ['openid', 'id_token', 'management', 'implicit'];
@Injectable() @Injectable()
export class AdminService { export class AdminService {
constructor(private _form: FormUtilityService) {} constructor(private _form: FormUtilityService) {}
@ -14,4 +18,26 @@ export class AdminService {
: null, : null,
} as Partial<OAuth2Client>; } as Partial<OAuth2Client>;
} }
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];
}, []);
}
} }

View File

@ -10,6 +10,7 @@ import {
Post, Post,
Put, Put,
Query, Query,
UnauthorizedException,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@ -36,6 +37,7 @@ import { TokenService } from 'src/modules/utility/services/token.service';
import { PageOptions } from 'src/types/pagination.interfaces'; import { PageOptions } from 'src/types/pagination.interfaces';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service'; import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service';
import { Throttle } from '@nestjs/throttler';
const RELATIONS = ['urls', 'picture', 'owner']; const RELATIONS = ['urls', 'picture', 'owner'];
const SET_CLIENT_FIELDS = [ const SET_CLIENT_FIELDS = [
@ -66,22 +68,47 @@ export class OAuth2AdminController {
@Get('scopes') @Get('scopes')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async oauth2Scopes() { async oauth2Scopes(@CurrentUser() user: User) {
return this._oaClient.availableScopes; const reducedPermissions = !this._service.userHasPrivilege(
user,
'admin:oauth2',
);
return reducedPermissions
? this._service.removeUnprivileged(this._oaClient.availableScopes)
: this._oaClient.availableScopes;
} }
@Get('grants') @Get('grants')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async oauth2Grants() { async oauth2Grants(@CurrentUser() user: User) {
return this._oaClient.availableGrantTypes; const reducedPermissions = !this._service.userHasPrivilege(
user,
'admin:oauth2',
);
return reducedPermissions
? this._service.removeUnprivileged(this._oaClient.availableGrantTypes)
: this._oaClient.availableGrantTypes;
} }
@Get('clients') @Get('clients')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async oauth2ClientList(@Query() options: { q?: string } & PageOptions) { 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 search = options.q ? decodeURIComponent(options.q) : null;
const resultCount = await this._oaClient.searchClientsCount( const resultCount = await this._oaClient.searchClientsCount(
search, search,
@ -108,29 +135,49 @@ export class OAuth2AdminController {
@Get('clients/:id') @Get('clients/:id')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async oauth2Client(@Param('id') id: string) { async oauth2Client(@Param('id') id: string, @CurrentUser() user: User) {
const client = await this._oaClient.getById(parseInt(id, 10), RELATIONS); const client = await this._oaClient.getById(parseInt(id, 10), RELATIONS);
if (!client) { if (!client) {
throw new NotFoundException('Client not found'); 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); return this._service.stripClientInfo(client);
} }
@Patch('clients/:id') @Patch('clients/:id')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async updateOauth2Client( async updateOauth2Client(
@Param('id') id: string, @Param('id') id: string,
@Body() setter: Partial<OAuth2Client>, @Body() setter: Partial<OAuth2Client>,
@CurrentUser() user: User,
) { ) {
const client = await this._oaClient.getById(parseInt(id, 10), []); const client = await this._oaClient.getById(parseInt(id, 10), []);
if (!client) { if (!client) {
throw new NotFoundException('Client not found'); 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); 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) { if (!Object.keys(allowedFieldsOnly).length) {
return this._service.stripClientInfo(client); return this._service.stripClientInfo(client);
} }
@ -143,13 +190,19 @@ export class OAuth2AdminController {
@Post('clients/:id/new-secret') @Post('clients/:id/new-secret')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async createNewSecret(@Param('id') id: string) { async createNewSecret(@Param('id') id: string, @CurrentUser() user: User) {
const client = await this._oaClient.getById(parseInt(id, 10), []); const client = await this._oaClient.getById(parseInt(id, 10), []);
if (!client) { if (!client) {
throw new NotFoundException('Client not found'); 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(); client.client_secret = this._token.generateSecret();
await this._oaClient.updateClient(client); await this._oaClient.updateClient(client);
@ -162,13 +215,22 @@ export class OAuth2AdminController {
@Delete('clients/:id/authorizations') @Delete('clients/:id/authorizations')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async deleteClientAuthorizations(@Param('id') id: string) { async deleteClientAuthorizations(
@Param('id') id: string,
@CurrentUser() user: User,
) {
const client = await this._oaClient.getById(parseInt(id, 10), []); const client = await this._oaClient.getById(parseInt(id, 10), []);
if (!client) { if (!client) {
throw new NotFoundException('Client not found'); 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); await this._oaClient.wipeClientAuthorizations(client);
return this._service.stripClientInfo(client); return this._service.stripClientInfo(client);
@ -176,21 +238,29 @@ export class OAuth2AdminController {
@Get('clients/:id/urls') @Get('clients/:id/urls')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async oauth2ClientURLs(@Param('id') id: string) { async oauth2ClientURLs(@Param('id') id: string, @CurrentUser() user: User) {
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
if (!client) { if (!client) {
throw new NotFoundException('Client not found'); 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; return client.urls;
} }
@Delete('clients/:id/urls/:url') @Delete('clients/:id/urls/:url')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async deleteOAuth2ClientURL( async deleteOAuth2ClientURL(
@Param('id') id: string, @Param('id') id: string,
@Param('url') urlId: string, @Param('url') urlId: string,
@CurrentUser() user: User,
) { ) {
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
const parsedURLId = parseInt(urlId, 10); const parsedURLId = parseInt(urlId, 10);
@ -199,6 +269,12 @@ export class OAuth2AdminController {
throw new NotFoundException('Client not found'); 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) { if (!parsedURLId) {
throw new BadRequestException('Invalid URL ID'); throw new BadRequestException('Invalid URL ID');
} }
@ -215,11 +291,12 @@ export class OAuth2AdminController {
@Put('clients/:id/urls/:url') @Put('clients/:id/urls/:url')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async updateOAuth2ClientURL( async updateOAuth2ClientURL(
@Param('id') id: string, @Param('id') id: string,
@Param('url') urlId: string, @Param('url') urlId: string,
@Body() setter: { url: string; type: string }, @Body() setter: { url: string; type: string },
@CurrentUser() user: User,
) { ) {
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
const plucked = this._form.pluckObject(setter, ['url', 'type']); const plucked = this._form.pluckObject(setter, ['url', 'type']);
@ -229,6 +306,12 @@ export class OAuth2AdminController {
throw new NotFoundException('Client not found'); 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) { if (!parsedURLId) {
throw new BadRequestException('Invalid URL ID'); throw new BadRequestException('Invalid URL ID');
} }
@ -249,16 +332,23 @@ export class OAuth2AdminController {
@Post('clients/:id/urls') @Post('clients/:id/urls')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async createOAuth2ClientURL( async createOAuth2ClientURL(
@Param('id') id: string, @Param('id') id: string,
@Body() setter: { url: string; type: string }, @Body() setter: { url: string; type: string },
@CurrentUser() user: User,
) { ) {
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
if (!client) { if (!client) {
throw new NotFoundException('Client not found'); 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)) { if (!setter.url || !setter.type || !URL_TYPES.includes(setter.type)) {
throw new NotFoundException('Missing or invalid fields'); throw new NotFoundException('Missing or invalid fields');
} }
@ -272,9 +362,10 @@ export class OAuth2AdminController {
return url; return url;
} }
@Throttle(3, 60)
@Post('clients/:id/picture') @Post('clients/:id/picture')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
async uploadClientPictureFile( async uploadClientPictureFile(
@CurrentUser() user: User, @CurrentUser() user: User,
@ -288,6 +379,12 @@ export class OAuth2AdminController {
throw new NotFoundException('Client not found'); 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) { if (!file) {
throw new BadRequestException('Picture upload failed'); throw new BadRequestException('Picture upload failed');
} }
@ -315,13 +412,22 @@ export class OAuth2AdminController {
@Delete('clients/:id/picture') @Delete('clients/:id/picture')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async deleteClientPictureFile(@Param('id') id: string) { async deleteClientPictureFile(
@Param('id') id: string,
@CurrentUser() user: User,
) {
const client = await this._oaClient.getById(parseInt(id, 10), ['picture']); const client = await this._oaClient.getById(parseInt(id, 10), ['picture']);
if (!client) { if (!client) {
throw new NotFoundException('Client not found'); 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); this._oaClient.deletePicture(client);
client.picture = null; client.picture = null;
await this._oaClient.updateClient(client); await this._oaClient.updateClient(client);
@ -332,12 +438,16 @@ export class OAuth2AdminController {
// New client // New client
@Post('/clients') @Post('/clients')
@Scopes('management') @Scopes('management')
@Privileges('admin', 'admin:oauth2') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async createNewClient( async createNewClient(
@Body() setter: Partial<OAuth2Client>, @Body() setter: Partial<OAuth2Client>,
@CurrentUser() user: User, @CurrentUser() user: User,
) { ) {
const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS); const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS);
const reducedPermissions = !this._service.userHasPrivilege(
user,
'admin:oauth2',
);
if (!Object.keys(allowedFieldsOnly).length) { if (!Object.keys(allowedFieldsOnly).length) {
throw new BadRequestException('Required fields are missing'); throw new BadRequestException('Required fields are missing');
@ -349,20 +459,21 @@ export class OAuth2AdminController {
const splitGrants = allowedFieldsOnly.grants.split(' '); const splitGrants = allowedFieldsOnly.grants.split(' ');
const splitScopes = allowedFieldsOnly.scope.split(' '); const splitScopes = allowedFieldsOnly.scope.split(' ');
let availableGrantTypes = this._oaClient.availableGrantTypes;
let availableScopes = this._oaClient.availableScopes;
if ( if (reducedPermissions) {
!splitGrants.every((grant) => availableGrantTypes =
this._oaClient.availableGrantTypes.includes(grant), 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'); throw new BadRequestException('Bad grant types');
} }
if ( if (!splitScopes.every((scope) => availableScopes.includes(scope))) {
!splitScopes.every((scope) =>
this._oaClient.availableScopes.includes(scope),
)
) {
throw new BadRequestException('Bad scopes'); throw new BadRequestException('Bad scopes');
} }

View File

@ -159,6 +159,9 @@ export class OAuth2ClientService {
{ {
title: ILike(`%${search}%`), title: ILike(`%${search}%`),
}, },
{
client_id: search,
},
] ]
: undefined, : undefined,
skip: offset, skip: offset,
@ -177,12 +180,25 @@ export class OAuth2ClientService {
{ {
title: ILike(`%${search}%`), title: ILike(`%${search}%`),
}, },
{
client_id: search,
},
] ]
: undefined, : undefined,
relations, relations,
}); });
} }
public async getClientsByOwner(
owner: User,
relations?: string[],
): Promise<OAuth2Client[]> {
return this.clientRepository.find({
where: { owner: { id: owner.id } },
relations,
});
}
public async getClientURLs( public async getClientURLs(
id: string, id: string,
type?: OAuth2ClientURLType, type?: OAuth2ClientURLType,

View File

@ -15,6 +15,7 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { Throttle } from '@nestjs/throttler';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { unlink } from 'fs/promises'; import { unlink } from 'fs/promises';
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service'; import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
@ -83,6 +84,7 @@ export class SettingsController {
res.redirect('/account/general'); res.redirect('/account/general');
} }
@Throttle(3, 60)
@Post('avatar') @Post('avatar')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
async uploadAvatarFile( async uploadAvatarFile(