icynet-auth-server/src/modules/api/admin/oauth2-admin.controller.ts

570 lines
17 KiB
TypeScript

import {
BadRequestException,
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Patch,
Post,
Put,
Query,
UnauthorizedException,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { unlink } from 'fs/promises';
import { Privileges } from 'src/decorators/privileges.decorator';
import { Scopes } from 'src/decorators/scopes.decorator';
import { CurrentUser } from 'src/decorators/user.decorator';
import { OAuth2Guard } from 'src/guards/oauth2.guard';
import { PrivilegesGuard } from 'src/guards/privileges.guard';
import { ScopesGuard } from 'src/guards/scopes.guard';
import { OAuth2Client } from 'src/modules/objects/oauth2-client/oauth2-client.entity';
import {
OAuth2ClientURL,
OAuth2ClientURLType,
} from 'src/modules/objects/oauth2-client/oauth2-client-url.entity';
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
import { UploadService } from 'src/modules/objects/upload/upload.service';
import { User } from 'src/modules/objects/user/user.entity';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { PaginationService } from 'src/modules/utility/services/paginate.service';
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 = [
'title',
'description',
'scope',
'grants',
'activated',
'verified',
'urls',
];
const URL_TYPES = ['redirect_uri', 'terms', 'privacy', 'website'];
const REQUIRED_CLIENT_FIELDS = ['title', 'grants', 'activated'];
@ApiBearerAuth()
@ApiTags('admin')
@Controller('/api/admin/oauth2')
@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard)
export class OAuth2AdminController {
constructor(
private _oaClient: OAuth2ClientService,
private _oaToken: OAuth2TokenService,
private _service: AdminService,
private _paginate: PaginationService,
private _form: FormUtilityService,
private _token: TokenService,
private _upload: UploadService,
) {}
@Get('scopes')
@Scopes('management')
@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'], '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'], '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 {
pagination: null,
list: unprivileged.map((item) => this._oaClient.stripClientInfo(item)),
};
}
const search = options.q ? decodeURIComponent(options.q) : null;
const resultCount = await this._oaClient.searchClientsCount(
search,
RELATIONS,
);
const pagination = this._paginate.paginate(options, resultCount);
const [list] = await this._oaClient.searchClients(
pagination.pageSize,
pagination.offset,
search,
RELATIONS,
);
return {
pagination,
list: this._form.stripObjectArray(
list.map((item) => this._oaClient.stripClientInfo(item)),
['password'],
),
};
}
// New client
@Post('clients')
@Scopes('management')
@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');
}
if (REQUIRED_CLIENT_FIELDS.some((field) => setter[field] === undefined)) {
throw new BadRequestException('Required fields are missing');
}
const splitGrants = (allowedFieldsOnly.grants || '')
.trim()
.split(' ')
.filter((item) => item);
const splitScopes = (allowedFieldsOnly.scope || '')
.trim()
.split(' ')
.filter((item) => item);
let availableGrantTypes = this._oaClient.availableGrantTypes;
let availableScopes = this._oaClient.availableScopes;
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) => availableScopes.includes(scope))) {
throw new BadRequestException('Bad scopes');
}
const urls = setter.urls?.slice();
delete allowedFieldsOnly.urls;
const client = new OAuth2Client();
Object.assign(client, allowedFieldsOnly);
client.client_id = this._token.createUUID();
client.client_secret = this._token.generateSecret();
client.owner = user;
await this._oaClient.updateClient(client);
if (urls?.length) {
await this._oaClient.upsertURLs(client, urls);
}
return this._oaClient.stripClientInfo(client);
}
@Get('clients/:id')
@Scopes('management')
@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._oaClient.stripClientInfo(client);
}
@Patch('clients/:id')
@Scopes('management')
@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), []);
const reducedPermissions = !this._service.userHasPrivilege(
user,
'admin:oauth2',
);
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);
const urls = allowedFieldsOnly.urls?.slice();
delete allowedFieldsOnly.urls;
// Non-admin cannot change the activated or verified status
if (reducedPermissions) {
delete allowedFieldsOnly.activated;
delete allowedFieldsOnly.verified;
}
if (!Object.keys(allowedFieldsOnly).length) {
return this._oaClient.stripClientInfo(client);
}
const splitGrants = (allowedFieldsOnly.grants || '')
.trim()
.split(' ')
.filter((item) => item);
const splitScopes = (allowedFieldsOnly.scope || '')
.trim()
.split(' ')
.filter((item) => item);
let availableGrantTypes = this._oaClient.availableGrantTypes;
let availableScopes = this._oaClient.availableScopes;
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) => availableScopes.includes(scope))) {
throw new BadRequestException('Bad scopes');
}
Object.assign(client, allowedFieldsOnly);
await this._oaClient.updateClient(client);
if (urls?.length) {
await this._oaClient.upsertURLs(client, urls);
}
return this._oaClient.stripClientInfo(client);
}
@Delete('clients/:id')
@Scopes('management')
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
async deleteOauth2Client(@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',
);
}
if (client.activated) {
throw new BadRequestException('Please deactivate the client first.');
}
await this._oaClient.deleteClient(client);
return { success: true };
}
@Post('clients/:id/new-secret')
@Scopes('management')
@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);
// security
await this._oaToken.wipeClientTokens(client);
await this._oaClient.wipeClientAuthorizations(client);
return this._oaClient.stripClientInfo(client);
}
@Delete('clients/:id/authorizations')
@Scopes('management')
@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._oaClient.stripClientInfo(client);
}
@Get('clients/:id/urls')
@Scopes('management')
@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'], '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);
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 (!parsedURLId) {
throw new BadRequestException('Invalid URL ID');
}
const url = await this._oaClient.getClientURLById(parsedURLId);
if (!url) {
throw new BadRequestException('Invalid URL');
}
client.urls = client.urls.filter((url) => url.id !== parsedURLId);
await this._oaClient.deleteClientURL(url);
return client;
}
@Put('clients/:id/urls/:url')
@Scopes('management')
@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']);
const parsedURLId = parseInt(urlId, 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',
);
}
if (!parsedURLId) {
throw new BadRequestException('Invalid URL ID');
}
if (!setter.url || !setter.type || !URL_TYPES.includes(setter.type)) {
throw new NotFoundException('Missing or invalid fields');
}
const url = await this._oaClient.getClientURLById(parsedURLId);
if (!url) {
throw new BadRequestException('Invalid URL');
}
Object.assign(url, plucked);
await this._oaClient.updateClientURL(url);
return url;
}
@Post('clients/:id/urls')
@Scopes('management')
@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');
}
const url = new OAuth2ClientURL();
url.client = client;
url.type = setter.type as OAuth2ClientURLType;
url.url = setter.url;
await this._oaClient.updateClientURL(url);
return url;
}
@Throttle(3, 60)
@Post('clients/:id/picture')
@Scopes('management')
@Privileges(['admin', 'admin:oauth2'], 'self:oauth2')
@UseInterceptors(FileInterceptor('file'))
async uploadClientPictureFile(
@CurrentUser() user: User,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
const client = await this._oaClient.getById(parseInt(id, 10), ['picture']);
try {
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 (!file) {
throw new BadRequestException('Picture upload failed');
}
const matches = await this._upload.checkImageAspect(file);
if (!matches) {
throw new BadRequestException(
'Picture should be with a 1:1 aspect ratio.',
);
}
const upload = await this._upload.registerUploadedFile(file, user);
await this._oaClient.updatePicture(client, upload);
return {
file: upload.file,
};
} catch (e) {
if (!file.buffer) {
await unlink(file.path);
}
throw e;
}
}
@Delete('clients/:id/picture')
@Scopes('management')
@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);
return this._oaClient.stripClientInfo(client);
}
}