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!
* @param privileges List of privileges for this route
*/
export const Privileges = (...privileges: string[]) =>
export const Privileges = (...privileges: (string | string[])[]) =>
SetMetadata('privileges', privileges);

View File

@ -9,7 +9,7 @@ export class PrivilegesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const privileges = this.reflector.get<string[]>(
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),
);

View File

@ -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<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,
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<OAuth2Client>,
@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<OAuth2Client>,
@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');
}

View File

@ -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<OAuth2Client[]> {
return this.clientRepository.find({
where: { owner: { id: owner.id } },
relations,
});
}
public async getClientURLs(
id: string,
type?: OAuth2ClientURLType,

View File

@ -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(