comprehensive oauth2 admin api
This commit is contained in:
parent
13eefb166a
commit
e884ce80fc
@ -82,7 +82,7 @@
|
|||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|
||||||
.scopes__scope {
|
&__scope {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -18,11 +18,8 @@ export class PrivilegesGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
return (
|
return privileges.every((item) =>
|
||||||
user.privileges.includes('*') ||
|
|
||||||
privileges.every((item) =>
|
|
||||||
user.privileges.find(({ name }) => name === item),
|
user.privileges.find(({ name }) => name === item),
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,59 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
|
import * as multer from 'multer';
|
||||||
|
import * as mime from 'mime-types';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { ConfigurationService } from '../../config/config.service';
|
||||||
import { OAuth2Module } from 'src/modules/oauth2/oauth2.module';
|
import { OAuth2Module } from 'src/modules/oauth2/oauth2.module';
|
||||||
import { ObjectsModule } from 'src/modules/objects/objects.module';
|
import { ObjectsModule } from 'src/modules/objects/objects.module';
|
||||||
|
import { OAuth2AdminController } from './oauth2-admin.controller';
|
||||||
import { PrivilegeAdminController } from './privilege-admin.controller';
|
import { PrivilegeAdminController } from './privilege-admin.controller';
|
||||||
import { UserAdminController } from './user-admin.controller';
|
import { UserAdminController } from './user-admin.controller';
|
||||||
|
import { ConfigurationModule } from 'src/modules/config/config.module';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [UserAdminController, PrivilegeAdminController],
|
controllers: [
|
||||||
imports: [ObjectsModule, OAuth2Module],
|
UserAdminController,
|
||||||
|
PrivilegeAdminController,
|
||||||
|
OAuth2AdminController,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
ObjectsModule,
|
||||||
|
OAuth2Module,
|
||||||
|
MulterModule.registerAsync({
|
||||||
|
imports: [ConfigurationModule],
|
||||||
|
useFactory: async (config: ConfigurationService) => {
|
||||||
|
return {
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, join(__dirname, '..', '..', '..', '..', 'uploads'));
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const hashTruncate = req.user.uuid.split('-')[0];
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const ext = mime.extension(file.mimetype);
|
||||||
|
cb(null, `app-${hashTruncate}-${timestamp}.${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: {
|
||||||
|
fileSize: 1.049e7, // 10 MiB
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (
|
||||||
|
!file.mimetype.startsWith('image/') ||
|
||||||
|
file.mimetype.includes('svg')
|
||||||
|
) {
|
||||||
|
return cb(new Error('Invalid file type.'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: [ConfigurationService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [AdminService],
|
||||||
})
|
})
|
||||||
export class AdminApiModule {}
|
export class AdminApiModule {}
|
||||||
|
17
src/modules/api/admin/admin.service.ts
Normal file
17
src/modules/api/admin/admin.service.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OAuth2Client } from 'src/modules/objects/oauth2-client/oauth2-client.entity';
|
||||||
|
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminService {
|
||||||
|
constructor(private _form: FormUtilityService) {}
|
||||||
|
|
||||||
|
public stripClientInfo(client: OAuth2Client): Partial<OAuth2Client> {
|
||||||
|
return {
|
||||||
|
...client,
|
||||||
|
owner: client.owner
|
||||||
|
? this._form.pluckObject(client.owner, ['id', 'uuid', 'username'])
|
||||||
|
: null,
|
||||||
|
} as Partial<OAuth2Client>;
|
||||||
|
}
|
||||||
|
}
|
378
src/modules/api/admin/oauth2-admin.controller.ts
Normal file
378
src/modules/api/admin/oauth2-admin.controller.ts
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const RELATIONS = ['urls', 'picture', 'owner'];
|
||||||
|
const SET_CLIENT_FIELDS = [
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'scope',
|
||||||
|
'grants',
|
||||||
|
'activated',
|
||||||
|
'verified',
|
||||||
|
];
|
||||||
|
|
||||||
|
const URL_TYPES = ['redirect_uri', 'terms', 'privacy', 'website'];
|
||||||
|
|
||||||
|
const REQUIRED_CLIENT_FIELDS = ['title', 'scope', 'grants', 'activated'];
|
||||||
|
|
||||||
|
@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')
|
||||||
|
async oauth2Scopes() {
|
||||||
|
return this._oaClient.availableScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('grants')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:oauth2')
|
||||||
|
async oauth2Grants() {
|
||||||
|
return this._oaClient.availableGrantTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clients')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:oauth2')
|
||||||
|
async oauth2ClientList(@Query() options: { q?: string } & PageOptions) {
|
||||||
|
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._service.stripClientInfo(item)),
|
||||||
|
['password'],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clients/:id')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:oauth2')
|
||||||
|
async oauth2Client(@Param('id') id: string) {
|
||||||
|
const client = await this._oaClient.getById(parseInt(id, 10), RELATIONS);
|
||||||
|
if (!client) {
|
||||||
|
throw new NotFoundException('Client not found');
|
||||||
|
}
|
||||||
|
return this._service.stripClientInfo(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('clients/:id')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:oauth2')
|
||||||
|
async updateOauth2Client(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() setter: Partial<OAuth2Client>,
|
||||||
|
) {
|
||||||
|
const client = await this._oaClient.getById(parseInt(id, 10), []);
|
||||||
|
if (!client) {
|
||||||
|
throw new NotFoundException('Client not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS);
|
||||||
|
|
||||||
|
if (!Object.keys(allowedFieldsOnly).length) {
|
||||||
|
return this._service.stripClientInfo(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(client, allowedFieldsOnly);
|
||||||
|
await this._oaClient.updateClient(client);
|
||||||
|
|
||||||
|
return this._service.stripClientInfo(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('clients/:id/new-secret')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:oauth2')
|
||||||
|
async createNewSecret(@Param('id') id: string) {
|
||||||
|
const client = await this._oaClient.getById(parseInt(id, 10), []);
|
||||||
|
if (!client) {
|
||||||
|
throw new NotFoundException('Client not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
client.client_secret = this._token.generateSecret();
|
||||||
|
await this._oaClient.updateClient(client);
|
||||||
|
|
||||||
|
// security
|
||||||
|
await this._oaToken.wipeClientTokens(client);
|
||||||
|
await this._oaClient.wipeClientAuthorizations(client);
|
||||||
|
|
||||||
|
return this._service.stripClientInfo(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('clients/:id/authorizations')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:oauth2')
|
||||||
|
async deleteClientAuthorizations(@Param('id') id: string) {
|
||||||
|
const client = await this._oaClient.getById(parseInt(id, 10), []);
|
||||||
|
if (!client) {
|
||||||
|
throw new NotFoundException('Client not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._oaClient.wipeClientAuthorizations(client);
|
||||||
|
|
||||||
|
return this._service.stripClientInfo(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clients/:id/urls')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:oauth2')
|
||||||
|
async oauth2ClientURLs(@Param('id') id: string) {
|
||||||
|
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
|
||||||
|
if (!client) {
|
||||||
|
throw new NotFoundException('Client not found');
|
||||||
|
}
|
||||||
|
return client.urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('clients/:id/urls/:url')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:oauth2')
|
||||||
|
async deleteOAuth2ClientURL(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('url') urlId: string,
|
||||||
|
) {
|
||||||
|
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
|
||||||
|
const parsedURLId = parseInt(urlId, 10);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new NotFoundException('Client not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
async updateOAuth2ClientURL(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('url') urlId: string,
|
||||||
|
@Body() setter: { url: string; type: string },
|
||||||
|
) {
|
||||||
|
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 (!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')
|
||||||
|
async createOAuth2ClientURL(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() setter: { url: string; type: string },
|
||||||
|
) {
|
||||||
|
const client = await this._oaClient.getById(parseInt(id, 10), ['urls']);
|
||||||
|
if (!client) {
|
||||||
|
throw new NotFoundException('Client not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('clients/:id/picture')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin: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 (!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')
|
||||||
|
async deleteClientPictureFile(@Param('id') id: string) {
|
||||||
|
const client = await this._oaClient.getById(parseInt(id, 10), ['picture']);
|
||||||
|
if (!client) {
|
||||||
|
throw new NotFoundException('Client not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._oaClient.deletePicture(client);
|
||||||
|
client.picture = null;
|
||||||
|
await this._oaClient.updateClient(client);
|
||||||
|
|
||||||
|
return this._service.stripClientInfo(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New client
|
||||||
|
@Post('/clients')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:oauth2')
|
||||||
|
async createNewClient(
|
||||||
|
@Body() setter: Partial<OAuth2Client>,
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
) {
|
||||||
|
const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS);
|
||||||
|
|
||||||
|
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.split(' ');
|
||||||
|
const splitScopes = allowedFieldsOnly.scope.split(' ');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!splitGrants.every((grant) =>
|
||||||
|
this._oaClient.availableGrantTypes.includes(grant),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Bad grant types');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!splitScopes.every((scope) =>
|
||||||
|
this._oaClient.availableScopes.includes(scope),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Bad scopes');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return this._service.stripClientInfo(client);
|
||||||
|
}
|
||||||
|
}
|
@ -12,17 +12,11 @@ import { OAuth2Guard } from 'src/guards/oauth2.guard';
|
|||||||
import { PrivilegesGuard } from 'src/guards/privileges.guard';
|
import { PrivilegesGuard } from 'src/guards/privileges.guard';
|
||||||
import { ScopesGuard } from 'src/guards/scopes.guard';
|
import { ScopesGuard } from 'src/guards/scopes.guard';
|
||||||
import { PrivilegeService } from 'src/modules/objects/privilege/privilege.service';
|
import { PrivilegeService } from 'src/modules/objects/privilege/privilege.service';
|
||||||
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
|
||||||
import { PaginationService } from 'src/modules/utility/services/paginate.service';
|
|
||||||
|
|
||||||
@Controller('/api/admin/privileges')
|
@Controller('/api/admin/privileges')
|
||||||
@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard)
|
@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard)
|
||||||
export class PrivilegeAdminController {
|
export class PrivilegeAdminController {
|
||||||
constructor(
|
constructor(private _privilege: PrivilegeService) {}
|
||||||
private _privilege: PrivilegeService,
|
|
||||||
private _paginate: PaginationService,
|
|
||||||
private _form: FormUtilityService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('')
|
@Get('')
|
||||||
@Scopes('management')
|
@Scopes('management')
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -11,6 +16,7 @@ import { Scopes } from 'src/decorators/scopes.decorator';
|
|||||||
import { OAuth2Guard } from 'src/guards/oauth2.guard';
|
import { OAuth2Guard } from 'src/guards/oauth2.guard';
|
||||||
import { PrivilegesGuard } from 'src/guards/privileges.guard';
|
import { PrivilegesGuard } from 'src/guards/privileges.guard';
|
||||||
import { ScopesGuard } from 'src/guards/scopes.guard';
|
import { ScopesGuard } from 'src/guards/scopes.guard';
|
||||||
|
import { PrivilegeService } from 'src/modules/objects/privilege/privilege.service';
|
||||||
import { UserService } from 'src/modules/objects/user/user.service';
|
import { UserService } from 'src/modules/objects/user/user.service';
|
||||||
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
||||||
import { PaginationService } from 'src/modules/utility/services/paginate.service';
|
import { PaginationService } from 'src/modules/utility/services/paginate.service';
|
||||||
@ -23,10 +29,16 @@ const RELATIONS = ['picture', 'privileges'];
|
|||||||
export class UserAdminController {
|
export class UserAdminController {
|
||||||
constructor(
|
constructor(
|
||||||
private _user: UserService,
|
private _user: UserService,
|
||||||
|
private _privilege: PrivilegeService,
|
||||||
private _paginate: PaginationService,
|
private _paginate: PaginationService,
|
||||||
private _form: FormUtilityService,
|
private _form: FormUtilityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of all users or search for a specific user
|
||||||
|
* @param options Search and pagination options
|
||||||
|
* @returns Paginated user list
|
||||||
|
*/
|
||||||
@Get('')
|
@Get('')
|
||||||
@Scopes('management')
|
@Scopes('management')
|
||||||
@Privileges('admin', 'admin:user')
|
@Privileges('admin', 'admin:user')
|
||||||
@ -49,6 +61,11 @@ export class UserAdminController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single user by ID
|
||||||
|
* @param id User ID
|
||||||
|
* @returns User
|
||||||
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Scopes('management')
|
@Scopes('management')
|
||||||
@Privileges('admin', 'admin:user')
|
@Privileges('admin', 'admin:user')
|
||||||
@ -60,6 +77,34 @@ export class UserAdminController {
|
|||||||
return this._form.stripObject(user, ['password']);
|
return this._form.stripObject(user, ['password']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a user avatar from the server
|
||||||
|
* @param id User ID
|
||||||
|
* @returns Success
|
||||||
|
*/
|
||||||
|
@Delete(':id/avatar')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:user')
|
||||||
|
async deleteUserAvatar(@Param('id') id: string) {
|
||||||
|
const user = await this._user.getById(parseInt(id, 10), ['picture']);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.picture) {
|
||||||
|
await this._user.deleteAvatar(user);
|
||||||
|
user.picture = null;
|
||||||
|
await this._user.updateUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpaginated list of all privileges for user
|
||||||
|
* @param id User ID
|
||||||
|
* @returns Privilege list
|
||||||
|
*/
|
||||||
@Get(':id/privileges')
|
@Get(':id/privileges')
|
||||||
@Scopes('management')
|
@Scopes('management')
|
||||||
@Privileges('admin', 'admin:user')
|
@Privileges('admin', 'admin:user')
|
||||||
@ -70,4 +115,92 @@ export class UserAdminController {
|
|||||||
}
|
}
|
||||||
return user.privileges;
|
return user.privileges;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace user's privileges with the new list
|
||||||
|
* @param id User ID
|
||||||
|
* @param body With `privileges`, list of privilege IDs the user should have
|
||||||
|
* @returns New privileges array
|
||||||
|
*/
|
||||||
|
@Put(':id/privileges')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:user')
|
||||||
|
async setUserPrivileges(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { privileges: number[] },
|
||||||
|
) {
|
||||||
|
const user = await this._user.getById(parseInt(id, 10), ['privileges']);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.privileges) {
|
||||||
|
throw new BadRequestException('Privileges are required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.privileges.length) {
|
||||||
|
const privileges = await this._privilege.getByIDs(body.privileges);
|
||||||
|
if (!privileges?.length) {
|
||||||
|
throw new BadRequestException('Privileges not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
user.privileges = privileges;
|
||||||
|
} else {
|
||||||
|
user.privileges.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._user.updateUser(user);
|
||||||
|
|
||||||
|
return user.privileges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend activation email to a user
|
||||||
|
* @param id User ID
|
||||||
|
* @returns Success or error
|
||||||
|
*/
|
||||||
|
@Post(':id/activate')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:user')
|
||||||
|
async activateUserEmail(@Param('id') id: string) {
|
||||||
|
const user = await this._user.getById(parseInt(id, 10));
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let error: Error;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._user.sendActivationEmail(user);
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: !error, error: error?.name, message: error?.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send password reset email to a user
|
||||||
|
* @param id User ID
|
||||||
|
* @returns Success or error
|
||||||
|
*/
|
||||||
|
@Post(':id/password')
|
||||||
|
@Scopes('management')
|
||||||
|
@Privileges('admin', 'admin:user')
|
||||||
|
async resetPasswordEmail(@Param('id') id: string) {
|
||||||
|
const user = await this._user.getById(parseInt(id, 10));
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let error: Error;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._user.sendPasswordEmail(user);
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: !error, error: error?.name, message: error?.message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { DatabaseModule } from '../database/database.module';
|
import { DatabaseModule } from '../database/database.module';
|
||||||
|
import { UploadModule } from '../upload/upload.module';
|
||||||
import { clientProviders } from './oauth2-client.providers';
|
import { clientProviders } from './oauth2-client.providers';
|
||||||
import { OAuth2ClientService } from './oauth2-client.service';
|
import { OAuth2ClientService } from './oauth2-client.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule],
|
imports: [DatabaseModule, UploadModule],
|
||||||
providers: [...clientProviders, OAuth2ClientService],
|
providers: [...clientProviders, OAuth2ClientService],
|
||||||
exports: [OAuth2ClientService],
|
exports: [OAuth2ClientService],
|
||||||
})
|
})
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Repository } from 'typeorm';
|
import { ILike, Repository } from 'typeorm';
|
||||||
|
import { Upload } from '../upload/upload.entity';
|
||||||
|
import { UploadService } from '../upload/upload.service';
|
||||||
import { User } from '../user/user.entity';
|
import { User } from '../user/user.entity';
|
||||||
import { OAuth2ClientAuthorization } from './oauth2-client-authorization.entity';
|
import { OAuth2ClientAuthorization } from './oauth2-client-authorization.entity';
|
||||||
import {
|
import {
|
||||||
@ -10,6 +12,15 @@ import { OAuth2Client } from './oauth2-client.entity';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuth2ClientService {
|
export class OAuth2ClientService {
|
||||||
|
public availableGrantTypes = [
|
||||||
|
'authorization_code',
|
||||||
|
'refresh_token',
|
||||||
|
'id_token',
|
||||||
|
'implicit',
|
||||||
|
];
|
||||||
|
|
||||||
|
public availableScopes = ['image', 'email', 'privileges', 'management'];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('CLIENT_REPOSITORY')
|
@Inject('CLIENT_REPOSITORY')
|
||||||
private clientRepository: Repository<OAuth2Client>,
|
private clientRepository: Repository<OAuth2Client>,
|
||||||
@ -17,6 +28,7 @@ export class OAuth2ClientService {
|
|||||||
private clientUrlRepository: Repository<OAuth2ClientURL>,
|
private clientUrlRepository: Repository<OAuth2ClientURL>,
|
||||||
@Inject('CLIENT_AUTHORIZATION_REPOSITORY')
|
@Inject('CLIENT_AUTHORIZATION_REPOSITORY')
|
||||||
private clientAuthRepository: Repository<OAuth2ClientAuthorization>,
|
private clientAuthRepository: Repository<OAuth2ClientAuthorization>,
|
||||||
|
private _upload: UploadService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async hasAuthorized(
|
public async hasAuthorized(
|
||||||
@ -94,7 +106,6 @@ export class OAuth2ClientService {
|
|||||||
public async revokeAuthorization(
|
public async revokeAuthorization(
|
||||||
auth: OAuth2ClientAuthorization,
|
auth: OAuth2ClientAuthorization,
|
||||||
): Promise<OAuth2ClientAuthorization> {
|
): Promise<OAuth2ClientAuthorization> {
|
||||||
console.log(auth);
|
|
||||||
return this.clientAuthRepository.remove(auth);
|
return this.clientAuthRepository.remove(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,24 +122,67 @@ export class OAuth2ClientService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getById(id: string | number): Promise<OAuth2Client> {
|
public async getById(
|
||||||
|
id: string | number,
|
||||||
|
relations = ['urls', 'picture'],
|
||||||
|
): Promise<OAuth2Client> {
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let client: OAuth2Client;
|
let client: OAuth2Client;
|
||||||
|
|
||||||
if (typeof id === 'string') {
|
if (typeof id === 'string') {
|
||||||
client = await this.clientRepository.findOne({
|
client = await this.clientRepository.findOne({
|
||||||
where: { client_id: id },
|
where: { client_id: id },
|
||||||
relations: ['urls', 'picture'],
|
relations,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
client = await this.clientRepository.findOne({
|
client = await this.clientRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['urls', 'picture'],
|
relations,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async searchClients(
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
search?: string,
|
||||||
|
relations?: string[],
|
||||||
|
): Promise<[OAuth2Client[], number]> {
|
||||||
|
return this.clientRepository.findAndCount({
|
||||||
|
where: search
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: ILike(`%${search}%`),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
relations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchClientsCount(
|
||||||
|
search?: string,
|
||||||
|
relations?: string[],
|
||||||
|
): Promise<number> {
|
||||||
|
return this.clientRepository.count({
|
||||||
|
where: search
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: ILike(`%${search}%`),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
relations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getClientURLs(
|
public async getClientURLs(
|
||||||
id: string,
|
id: string,
|
||||||
type?: OAuth2ClientURLType,
|
type?: OAuth2ClientURLType,
|
||||||
@ -142,6 +196,15 @@ export class OAuth2ClientService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getClientURLById(id: number): Promise<OAuth2ClientURL> {
|
||||||
|
return this.clientUrlRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
relations: ['client'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async checkRedirectURI(id: string, url: string): Promise<boolean> {
|
public async checkRedirectURI(id: string, url: string): Promise<boolean> {
|
||||||
return !!(await this.clientUrlRepository.findOne({
|
return !!(await this.clientUrlRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
@ -152,4 +215,43 @@ export class OAuth2ClientService {
|
|||||||
relations: ['client'],
|
relations: ['client'],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateClient(client: OAuth2Client): Promise<OAuth2Client> {
|
||||||
|
await this.clientRepository.save(client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateClientURL(url: OAuth2ClientURL): Promise<OAuth2ClientURL> {
|
||||||
|
await this.clientUrlRepository.save(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteClientURL(url: OAuth2ClientURL): Promise<void> {
|
||||||
|
await this.clientUrlRepository.remove(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updatePicture(
|
||||||
|
client: OAuth2Client,
|
||||||
|
upload: Upload,
|
||||||
|
): Promise<OAuth2Client> {
|
||||||
|
if (client.picture) {
|
||||||
|
await this._upload.delete(client.picture);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.picture = upload;
|
||||||
|
await this.updateClient(client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async wipeClientAuthorizations(client: OAuth2Client): Promise<void> {
|
||||||
|
await this.clientAuthRepository.delete({
|
||||||
|
client: { id: client.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePicture(client: OAuth2Client): Promise<void> {
|
||||||
|
if (client.picture) {
|
||||||
|
await this._upload.delete(client.picture);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -64,6 +64,12 @@ export class OAuth2TokenService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async wipeClientTokens(client: OAuth2Client): Promise<void> {
|
||||||
|
await this.tokenRepository.delete({
|
||||||
|
client: { id: client.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async remove(token: OAuth2Token): Promise<void> {
|
public async remove(token: OAuth2Token): Promise<void> {
|
||||||
await this.tokenRepository.remove(token);
|
await this.tokenRepository.remove(token);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
import { Privilege } from './privilege.entity';
|
import { Privilege } from './privilege.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -27,4 +27,8 @@ export class PrivilegeService {
|
|||||||
public async getByID(id: number): Promise<Privilege> {
|
public async getByID(id: number): Promise<Privilege> {
|
||||||
return this.privilegeRepository.findOne({ where: { id } });
|
return this.privilegeRepository.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getByIDs(id: number[]): Promise<Privilege[]> {
|
||||||
|
return this.privilegeRepository.find({ where: { id: In(id) } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,7 @@ export class UploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async checkImageAspect(file: Express.Multer.File): Promise<boolean> {
|
public async checkImageAspect(file: Express.Multer.File): Promise<boolean> {
|
||||||
const opened = await readFile(file.path);
|
const opened = file.buffer || (await readFile(file.path));
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const result = imageSize(opened);
|
const result = imageSize(opened);
|
||||||
|
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ILike, Repository } from 'typeorm';
|
import { ILike, Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
import { UserTokenType } from '../user-token/user-token.entity';
|
import { UserTokenType } from '../user-token/user-token.entity';
|
||||||
import { User } from './user.entity';
|
import { User } from './user.entity';
|
||||||
import { TokenService } from 'src/modules/utility/services/token.service';
|
import { TokenService } from 'src/modules/utility/services/token.service';
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
import { EmailService } from '../email/email.service';
|
import { EmailService } from '../email/email.service';
|
||||||
import { RegistrationEmail } from './email/registration.email';
|
import { RegistrationEmail } from './email/registration.email';
|
||||||
import { ForgotPasswordEmail } from './email/forgot-password.email';
|
import { ForgotPasswordEmail } from './email/forgot-password.email';
|
||||||
@ -28,7 +29,6 @@ export class UserService {
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.userRepository.findOne({ where: { id }, relations });
|
return this.userRepository.findOne({ where: { id }, relations });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user