From b21a835e18f9e1fcf03623acd7e6a04637c7b98d Mon Sep 17 00:00:00 2001 From: nictheboy Date: Thu, 1 Aug 2024 21:24:38 +0800 Subject: [PATCH 01/12] feat(auth): implement a decorator to substitude authService.audit() --- src/answer/answer.controller.ts | 59 ++++++------- src/answer/answer.service.ts | 12 +++ src/auth/auth.service.ts | 3 + src/auth/guard.decorator.ts | 147 ++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 src/auth/guard.decorator.ts diff --git a/src/answer/answer.controller.ts b/src/answer/answer.controller.ts index fb582c09..cd3c4446 100644 --- a/src/answer/answer.controller.ts +++ b/src/answer/answer.controller.ts @@ -16,6 +16,13 @@ import { import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; import { AuthService, AuthorizedAction } from '../auth/auth.service'; +import { + AuthToken, + CurrentUserOwnResource, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { PageDto } from '../common/DTO/page.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; @@ -37,12 +44,17 @@ export class AnswerController { private readonly questionsService: QuestionsService, ) {} + @ResourceOwnerIdGetter('answer') + async getAnswerOwner(answerId: number): Promise { + return this.answerService.getCreatedByIdAcrossQuestions(answerId); + } + @Get('/') async getQuestionAnswers( - @Param('question_id') questionId: number, + @Param('question_id', ParseIntPipe) questionId: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { @@ -72,19 +84,14 @@ export class AnswerController { } @Post('/') + @Guard(AuthorizedAction.create, 'answer') + @CurrentUserOwnResource() async answerQuestion( @Param('question_id', ParseIntPipe) questionId: number, @Body('content') content: string, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - userId, - 'answer', - undefined, - ); const answerId = await this.answerService.createAnswer( questionId, userId, @@ -103,7 +110,7 @@ export class AnswerController { async getAnswerDetail( @Param('question_id', ParseIntPipe) questionId: number, @Param('answer_id', ParseIntPipe) answerId: number, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { @@ -138,20 +145,14 @@ export class AnswerController { } @Put('/:answer_id') + @Guard(AuthorizedAction.modify, 'answer') async updateAnswer( @Param('question_id', ParseIntPipe) questionId: number, - @Param('answer_id', ParseIntPipe) answerId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, @Body() { content }: UpdateAnswerRequestDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.modify, - await this.answerService.getCreatedById(questionId, answerId), - 'answer', - questionId, - ); await this.answerService.updateAnswer( questionId, answerId, @@ -165,19 +166,13 @@ export class AnswerController { } @Delete('/:answer_id') + @Guard(AuthorizedAction.delete, 'answer') async deleteAnswer( @Param('question_id', ParseIntPipe) questionId: number, - @Param('answer_id', ParseIntPipe) answerId: number, - @Headers('Authorization') auth: string | undefined, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.delete, - await this.answerService.getCreatedById(questionId, answerId), - 'answer', - answerId, - ); await this.answerService.deleteAnswer(questionId, answerId, userId); } @@ -186,7 +181,7 @@ export class AnswerController { @Param('question_id', ParseIntPipe) questionId: number, @Param('answer_id', ParseIntPipe) answerId: number, @Body() { attitude_type: attitudeType }: AttitudeTypeDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { const userId = this.authService.verify(auth).userId; this.authService.audit( @@ -215,7 +210,7 @@ export class AnswerController { async favoriteAnswer( @Param('question_id', ParseIntPipe) questionId: number, @Param('answer_id', ParseIntPipe) answerId: number, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { const userId = this.authService.verify(auth).userId; this.authService.audit( @@ -236,7 +231,7 @@ export class AnswerController { async unfavoriteAnswer( @Param('question_id', ParseIntPipe) questionId: number, @Param('answer_id', ParseIntPipe) answerId: number, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { const userId = this.authService.verify(auth).userId; this.authService.audit( diff --git a/src/answer/answer.service.ts b/src/answer/answer.service.ts index 270c6e73..ccc0cff6 100644 --- a/src/answer/answer.service.ts +++ b/src/answer/answer.service.ts @@ -566,6 +566,18 @@ export class AnswerService { return answer.createdById; } + async getCreatedByIdAcrossQuestions(answerId: number): Promise { + const answer = await this.prismaService.answer.findUnique({ + where: { + id: answerId, + }, + }); + if (!answer) { + throw new AnswerNotFoundError(answerId); + } + return answer.createdById; + } + async countQuestionAnswers(questionId: number): Promise { if ((await this.questionsService.isQuestionExists(questionId)) == false) throw new QuestionNotFoundError(questionId); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index e4c4f5f3..1382b511 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -113,7 +113,10 @@ export class TokenPayload { @Injectable() export class AuthService { + public static instance: AuthService; + constructor(private readonly jwtService: JwtService) { + AuthService.instance = this; const tokenPayloadSchemaRaw = readFileSync( path.resolve(__dirname, '../../src/auth/token-payload.schema.json'), 'utf8', diff --git a/src/auth/guard.decorator.ts b/src/auth/guard.decorator.ts new file mode 100644 index 00000000..8bdf4273 --- /dev/null +++ b/src/auth/guard.decorator.ts @@ -0,0 +1,147 @@ +import { AuthenticationRequiredError } from './auth.error'; +import { AuthService, AuthorizedAction } from './auth.service'; + +const RESOURCE_ID_METADATA_KEY = Symbol('resourceIdMetadata'); +const AUTH_TOKEN_METADATA_KEY = Symbol('authTokenMetadata'); +const RESOURCE_OWNER_ID_GETTER_METADATA_KEY = Symbol( + 'resourceOwnerIdGetterMetadata', +); +const CURRENT_USER_OWN_RESOURCE_METADATA_KEY = Symbol( + 'currentUserOwnResourceMetadata', +); + +export function ResourceId() { + return function ( + target: Object, + propertyKey: string | symbol, + parameterIndex: number, + ) { + Reflect.defineMetadata( + RESOURCE_ID_METADATA_KEY, + parameterIndex, + target, + propertyKey, + ); + }; +} + +export function AuthToken() { + return function ( + target: Object, + propertyKey: string | symbol, + parameterIndex: number, + ) { + Reflect.defineMetadata( + AUTH_TOKEN_METADATA_KEY, + parameterIndex, + target, + propertyKey, + ); + }; +} + +// apply it only to (resourceId: number) => Promise +export function ResourceOwnerIdGetter(resourceType: string) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + Reflect.defineMetadata( + RESOURCE_OWNER_ID_GETTER_METADATA_KEY, + resourceType, + target, + propertyKey, + ); + }; +} + +export function CurrentUserOwnResource() { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + Reflect.defineMetadata( + CURRENT_USER_OWN_RESOURCE_METADATA_KEY, + true, + target, + propertyKey, + ); + }; +} + +export function Guard(action: AuthorizedAction, resourceType: string) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + const authTokenParamIdx: number | undefined = Reflect.getOwnMetadata( + AUTH_TOKEN_METADATA_KEY, + target, + propertyKey, + ); + const authToken = + authTokenParamIdx != undefined ? args[authTokenParamIdx] : undefined; + if (authToken == undefined) { + throw new AuthenticationRequiredError(); + } + + const resourceIdParamIdx: number | undefined = Reflect.getOwnMetadata( + RESOURCE_ID_METADATA_KEY, + target, + propertyKey, + ); + const resourceId = + resourceIdParamIdx != undefined ? args[resourceIdParamIdx] : undefined; + + let resourceOwnerId: number | undefined = undefined; + const currentUserOwnResource: true | undefined = Reflect.getMetadata( + CURRENT_USER_OWN_RESOURCE_METADATA_KEY, + target, + propertyKey, + ); + if (currentUserOwnResource != undefined) { + resourceOwnerId = AuthService.instance.verify(authToken).userId; + } else { + const methods = Object.getOwnPropertyNames(target).filter( + (prop) => typeof target[prop] === 'function', + ); + let ownerIdGetterName: string | undefined = undefined; + for (const method of methods) { + const metadata = Reflect.getMetadata( + RESOURCE_OWNER_ID_GETTER_METADATA_KEY, + target, + method, + ); + if (metadata === resourceType) { + ownerIdGetterName = method; + } + } + const resourceOwnerIdGetter = + ownerIdGetterName != undefined + ? target[ownerIdGetterName] + : undefined; + resourceOwnerId = + resourceId != undefined && resourceOwnerIdGetter != undefined + ? await resourceOwnerIdGetter.call(this, resourceId) + : undefined; + } + + AuthService.instance.audit( + authToken, + action, + resourceOwnerId, + resourceType, + resourceId, + ); + return originalMethod.apply(this, args); + }; + + return descriptor; + }; +} From 60883b0712597ad2402a7f5f92d87c348246d670 Mon Sep 17 00:00:00 2001 From: nictheboy Date: Thu, 1 Aug 2024 22:14:29 +0800 Subject: [PATCH 02/12] refactor(auth): move auth definitions to a separate file --- src/answer/answer.controller.ts | 3 +- src/attachments/attachments.controller.ts | 17 ++-- src/auth/auth.error.ts | 2 +- src/auth/auth.service.ts | 80 +----------------- src/auth/auth.spec.ts | 4 +- src/auth/definitions.ts | 84 +++++++++++++++++++ src/auth/guard.decorator.ts | 3 +- src/auth/session.service.ts | 3 +- src/comments/comment.controller.ts | 3 +- .../materialbundles.controller.ts | 3 +- src/materials/materials.controller.ts | 3 +- src/questions/questions.controller.ts | 3 +- src/topics/topics.controller.ts | 3 +- src/users/users-permission.service.ts | 2 +- src/users/users.controller.ts | 3 +- src/users/users.service.ts | 7 +- 16 files changed, 119 insertions(+), 104 deletions(-) create mode 100644 src/auth/definitions.ts diff --git a/src/answer/answer.controller.ts b/src/answer/answer.controller.ts index cd3c4446..a6f6acda 100644 --- a/src/answer/answer.controller.ts +++ b/src/answer/answer.controller.ts @@ -15,7 +15,8 @@ import { } from '@nestjs/common'; import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; -import { AuthService, AuthorizedAction } from '../auth/auth.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthorizedAction } from '../auth/definitions'; import { AuthToken, CurrentUserOwnResource, diff --git a/src/attachments/attachments.controller.ts b/src/attachments/attachments.controller.ts index 2f775852..cbc80a5b 100644 --- a/src/attachments/attachments.controller.ts +++ b/src/attachments/attachments.controller.ts @@ -11,23 +11,24 @@ import { Body, Controller, Get, + Headers, Param, + ParseIntPipe, Post, - Headers, - UsePipes, - ValidationPipe, + UploadedFile, UseFilters, UseInterceptors, - UploadedFile, - ParseIntPipe, + UsePipes, + ValidationPipe, } from '@nestjs/common'; -import { AttachmentsService } from './attachments.service'; -import { attachmentTypeDto } from './DTO/attachments.dto'; import { FileInterceptor } from '@nestjs/platform-express'; +import { AuthService } from '../auth/auth.service'; +import { AuthorizedAction } from '../auth/definitions'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; -import { AuthorizedAction, AuthService } from '../auth/auth.service'; +import { attachmentTypeDto } from './DTO/attachments.dto'; import { getAttachmentResponseDto } from './DTO/get-attachment.dto'; import { uploadAttachmentDto } from './DTO/upload-attachment.dto'; +import { AttachmentsService } from './attachments.service'; @UsePipes(new ValidationPipe()) @UseFilters(new BaseErrorExceptionFilter()) @Controller('attachments') diff --git a/src/auth/auth.error.ts b/src/auth/auth.error.ts index 02afa046..0493f49b 100644 --- a/src/auth/auth.error.ts +++ b/src/auth/auth.error.ts @@ -8,7 +8,7 @@ */ import { BaseError } from '../common/error/base-error'; -import { AuthorizedAction, authorizedActionToString } from './auth.service'; +import { AuthorizedAction, authorizedActionToString } from './definitions'; export class AuthenticationRequiredError extends BaseError { constructor() { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 1382b511..28a29e00 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -26,90 +26,14 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import Ajv from 'ajv'; import { readFileSync } from 'fs'; +import path from 'node:path'; import { AuthenticationRequiredError, InvalidTokenError, PermissionDeniedError, TokenExpiredError, } from './auth.error'; -import path from 'node:path'; - -export enum AuthorizedAction { - create = 1, - delete = 2, - modify = 3, - query = 4, - - other = 5, - // When the action is not one of the four actions above, - // we use "other", and store the action info in resourceType. - // For example, resource type "auth/session:refresh" - // means the action is to refresh a session. -} - -export function authorizedActionToString(action: AuthorizedAction): string { - switch (action) { - case AuthorizedAction.create: - return 'create'; - case AuthorizedAction.delete: - return 'delete'; - case AuthorizedAction.modify: - return 'modify'; - case AuthorizedAction.query: - return 'query'; - case AuthorizedAction.other: - return 'other'; - } -} - -// This class is used as a filter. -// -// If all the conditions are undefined, it matches everything. -// This is DANGEROUS as you can imagine, and you should avoid -// such a powerful authorization. -// -// Once a condition is added, the audited resource should have the same -// attribute if it is authorized. -// -// The data field is reserved for future use. -// -// Examples: -// { ownedByUser: undefined, types: undefined, resourceId: undefined } -// matches every resource, including the resources that are not owned by any user. -// { ownedByUser: 123, types: undefined, resourceId: undefined } -// matches all the resources owned by user whose user id is 123. -// { ownedByUser: 123, types: ["users/profile"], resourceId: undefined } -// matches the profile of user whose id is 123. -// { ownedByUser: undefined, types: ["blog"], resourceId: [42, 95, 928] } -// matches blogs whose IDs are 42, 95 and 928. -// { ownedByUser: undefined, types: [], resourceId: undefined } -// matches nothing and is meaningless. -// -export class AuthorizedResource { - ownedByUser?: number; // owner's user id - types?: string[]; // resource type - resourceIds?: number[]; - data?: any; // additional data -} - -// The permission to perform all the actions listed in authorizedActions -// on all the resources that match the authorizedResource property. -export class Permission { - authorizedActions: AuthorizedAction[]; - authorizedResource: AuthorizedResource; -} - -// The user, whose id is userId, is granted the permissions. -export class Authorization { - userId: number; // authorization identity - permissions: Permission[]; -} - -export class TokenPayload { - authorization: Authorization; - signedAt: number; // timestamp in milliseconds - validUntil: number; // timestamp in milliseconds -} +import { Authorization, AuthorizedAction, TokenPayload } from './definitions'; @Injectable() export class AuthService { diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index 093cae92..70058588 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -14,12 +14,12 @@ import { NotRefreshTokenError, PermissionDeniedError, } from './auth.error'; +import { AuthService } from './auth.service'; import { - AuthService, Authorization, AuthorizedAction, authorizedActionToString, -} from './auth.service'; +} from './definitions'; import { SessionService } from './session.service'; describe('authorizedActionToString()', () => { diff --git a/src/auth/definitions.ts b/src/auth/definitions.ts new file mode 100644 index 00000000..d0374c0b --- /dev/null +++ b/src/auth/definitions.ts @@ -0,0 +1,84 @@ +/* + +IMPORTANT NOTICE: + +If you have modified this file, please run the following linux command: + +./node_modules/.bin/ts-json-schema-generator \ + --path 'src/auth/definitions.ts' \ + --type 'TokenPayload' \ + > src/auth/token-payload.schema.json + +to update the schema file, which is used in validating the token payload. + +*/ + +export enum AuthorizedAction { + create = 1, + delete = 2, + modify = 3, + query = 4, + + other = 5, +} + +export function authorizedActionToString(action: AuthorizedAction): string { + switch (action) { + case AuthorizedAction.create: + return 'create'; + case AuthorizedAction.delete: + return 'delete'; + case AuthorizedAction.modify: + return 'modify'; + case AuthorizedAction.query: + return 'query'; + case AuthorizedAction.other: + return 'other'; + } +} +// This class is used as a filter. +// +// If all the conditions are undefined, it matches everything. +// This is DANGEROUS as you can imagine, and you should avoid +// such a powerful authorization. +// +// Once a condition is added, the audited resource should have the same +// attribute if it is authorized. +// +// The data field is reserved for future use. +// +// Examples: +// { ownedByUser: undefined, types: undefined, resourceId: undefined } +// matches every resource, including the resources that are not owned by any user. +// { ownedByUser: 123, types: undefined, resourceId: undefined } +// matches all the resources owned by user whose user id is 123. +// { ownedByUser: 123, types: ["users/profile"], resourceId: undefined } +// matches the profile of user whose id is 123. +// { ownedByUser: undefined, types: ["blog"], resourceId: [42, 95, 928] } +// matches blogs whose IDs are 42, 95 and 928. +// { ownedByUser: undefined, types: [], resourceId: undefined } +// matches nothing and is meaningless. +// +export class AuthorizedResource { + ownedByUser?: number; // owner's user id + types?: string[]; // resource type + resourceIds?: number[]; + data?: any; // additional data +} +// The permission to perform all the actions listed in authorizedActions +// on all the resources that match the authorizedResource property. +export class Permission { + authorizedActions: AuthorizedAction[]; + authorizedResource: AuthorizedResource; +} +// The user, whose id is userId, is granted the permissions. +export class Authorization { + userId: number; // authorization identity + permissions: Permission[]; +} + +export class TokenPayload { + authorization: Authorization; + signedAt: number; // timestamp in milliseconds + validUntil: number; // timestamp in milliseconds +} diff --git a/src/auth/guard.decorator.ts b/src/auth/guard.decorator.ts index 8bdf4273..1148e5cf 100644 --- a/src/auth/guard.decorator.ts +++ b/src/auth/guard.decorator.ts @@ -1,5 +1,6 @@ import { AuthenticationRequiredError } from './auth.error'; -import { AuthService, AuthorizedAction } from './auth.service'; +import { AuthService } from './auth.service'; +import { AuthorizedAction } from './definitions'; const RESOURCE_ID_METADATA_KEY = Symbol('resourceIdMetadata'); const AUTH_TOKEN_METADATA_KEY = Symbol('authTokenMetadata'); diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts index bc4f3ead..ca766e31 100644 --- a/src/auth/session.service.ts +++ b/src/auth/session.service.ts @@ -14,7 +14,8 @@ import { SessionExpiredError, SessionRevokedError, } from './auth.error'; -import { AuthService, Authorization, AuthorizedAction } from './auth.service'; +import { AuthService } from './auth.service'; +import { Authorization, AuthorizedAction } from './definitions'; @Injectable() export class SessionService { diff --git a/src/comments/comment.controller.ts b/src/comments/comment.controller.ts index 21ffa446..8c150052 100644 --- a/src/comments/comment.controller.ts +++ b/src/comments/comment.controller.ts @@ -15,7 +15,8 @@ import { } from '@nestjs/common'; import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; -import { AuthService, AuthorizedAction } from '../auth/auth.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthorizedAction } from '../auth/definitions'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { PageDto } from '../common/DTO/page.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; diff --git a/src/materialbundles/materialbundles.controller.ts b/src/materialbundles/materialbundles.controller.ts index cbb673a6..f407496e 100644 --- a/src/materialbundles/materialbundles.controller.ts +++ b/src/materialbundles/materialbundles.controller.ts @@ -23,7 +23,8 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { AuthorizedAction, AuthService } from '../auth/auth.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthorizedAction } from '../auth/definitions'; import { createMaterialBundleRequestDto, createMaterialBundleResponseDto, diff --git a/src/materials/materials.controller.ts b/src/materials/materials.controller.ts index f88faf3b..b5a993a3 100644 --- a/src/materials/materials.controller.ts +++ b/src/materials/materials.controller.ts @@ -24,7 +24,8 @@ import { ValidationPipe, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { AuthorizedAction, AuthService } from '../auth/auth.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthorizedAction } from '../auth/definitions'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; import { GetMaterialResponseDto } from './DTO/get-material.dto'; import { MaterialTypeDto } from './DTO/material.dto'; diff --git a/src/questions/questions.controller.ts b/src/questions/questions.controller.ts index e83d7c93..e5e9b0d4 100644 --- a/src/questions/questions.controller.ts +++ b/src/questions/questions.controller.ts @@ -25,7 +25,8 @@ import { } from '@nestjs/common'; import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; -import { AuthService, AuthorizedAction } from '../auth/auth.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthorizedAction } from '../auth/definitions'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { PageDto, PageWithKeywordDto } from '../common/DTO/page.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; diff --git a/src/topics/topics.controller.ts b/src/topics/topics.controller.ts index 76611b06..bda11c7d 100644 --- a/src/topics/topics.controller.ts +++ b/src/topics/topics.controller.ts @@ -20,7 +20,8 @@ import { UseFilters, UseInterceptors, } from '@nestjs/common'; -import { AuthService, AuthorizedAction } from '../auth/auth.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthorizedAction } from '../auth/definitions'; import { PageWithKeywordDto } from '../common/DTO/page.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; import { TokenValidateInterceptor } from '../common/interceptor/token-validate.interceptor'; diff --git a/src/users/users-permission.service.ts b/src/users/users-permission.service.ts index 9dbfaaf4..a99f3307 100644 --- a/src/users/users-permission.service.ts +++ b/src/users/users-permission.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Authorization, AuthorizedAction } from '../auth/auth.service'; +import { Authorization, AuthorizedAction } from '../auth/definitions'; @Injectable() export class UsersPermissionService { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 4eef3995..c33c1929 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -29,7 +29,8 @@ import { import { Response } from 'express'; import { AnswerService } from '../answer/answer.service'; import { AuthenticationRequiredError } from '../auth/auth.error'; -import { AuthService, AuthorizedAction } from '../auth/auth.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthorizedAction } from '../auth/definitions'; import { SessionService } from '../auth/session.service'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { PageDto } from '../common/DTO/page.dto'; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 49536393..ff565109 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -20,11 +20,8 @@ import { isEmail } from 'class-validator'; import assert from 'node:assert'; import { AnswerService } from '../answer/answer.service'; import { PermissionDeniedError, TokenExpiredError } from '../auth/auth.error'; -import { - AuthService, - Authorization, - AuthorizedAction, -} from '../auth/auth.service'; +import { AuthService } from '../auth/auth.service'; +import { Authorization, AuthorizedAction } from '../auth/definitions'; import { SessionService } from '../auth/session.service'; import { AvatarNotFoundError } from '../avatars/avatars.error'; import { AvatarsService } from '../avatars/avatars.service'; From 2c8de27ea20715666ef08b9a0ccb0633887a39b7 Mon Sep 17 00:00:00 2001 From: nictheboy Date: Fri, 2 Aug 2024 23:02:41 +0800 Subject: [PATCH 03/12] refactor(auth): use @Guard instead of authService.audit() --- jest.config.json | 4 +- src/answer/answer.controller.ts | 83 +++---- src/app.module.ts | 21 +- src/attachments/attachments.controller.ts | 18 +- src/auth/auth.error.ts | 6 +- src/auth/auth.service.ts | 15 -- src/auth/auth.spec.ts | 68 ++---- src/auth/definitions.ts | 31 +-- src/auth/guard.decorator.ts | 23 +- src/auth/session.service.ts | 8 +- src/auth/token-payload.schema.json | 9 +- src/auth/user-id.decorator.ts | 39 ++++ src/avatars/avatars.controller.ts | 15 +- src/comments/comment.controller.ts | 93 +++----- .../interceptor/ensure-guard.interceptor.ts | 44 ++++ .../interceptor/token-validate.interceptor.ts | 16 +- src/groups/groups.controller.ts | 14 +- src/main.ts | 1 + .../materialbundles.controller.ts | 73 +++--- .../materialbundles.service.ts | 10 + src/materials/materials.controller.ts | 31 +-- src/questions/questions.controller.ts | 217 ++++++------------ src/questions/questions.error.ts | 2 + src/questions/questions.service.ts | 2 + src/topics/topics.controller.ts | 40 +--- src/users/users-permission.service.ts | 125 +++++----- src/users/users.controller.ts | 149 +++++------- src/users/users.service.ts | 6 +- test/answer.e2e-spec.ts | 183 +++++++++------ test/attachments.e2e-spec.ts | 5 + test/avatars.e2e-spec.ts | 87 ++++++- test/comment.e2e-spec.ts | 15 ++ test/groups.e2e-spec.ts | 29 +-- test/materialbundle.e2e-spec.ts | 48 +++- test/materials.e2e-spec.ts | 22 ++ test/question.e2e-spec.ts | 156 +++++++++---- test/topic.e2e-spec.ts | 52 +++-- test/user.follow.e2e-spec.ts | 51 ++-- test/user.profile.e2e-spec.ts | 77 ++++--- 39 files changed, 1047 insertions(+), 841 deletions(-) create mode 100644 src/auth/user-id.decorator.ts create mode 100644 src/common/interceptor/ensure-guard.interceptor.ts diff --git a/jest.config.json b/jest.config.json index dec528b3..920478f4 100644 --- a/jest.config.json +++ b/jest.config.json @@ -11,8 +11,8 @@ "!src/*/**/*.dto.ts", "!src/*/**/*.es-doc.ts", "!src/*/**/*.enum.ts", - "!src/*/**/*.deprecated.entity.ts", - "!src/common/config/configuration.ts" + "!src/common/config/configuration.ts", + "!src/auth/definitions.ts" ], "testTimeout": 60000 } diff --git a/src/answer/answer.controller.ts b/src/answer/answer.controller.ts index a6f6acda..a13879ce 100644 --- a/src/answer/answer.controller.ts +++ b/src/answer/answer.controller.ts @@ -10,13 +10,10 @@ import { Post, Put, Query, - UseFilters, - UseInterceptors, } from '@nestjs/common'; import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; import { AuthService } from '../auth/auth.service'; -import { AuthorizedAction } from '../auth/definitions'; import { AuthToken, CurrentUserOwnResource, @@ -24,10 +21,9 @@ import { ResourceId, ResourceOwnerIdGetter, } from '../auth/guard.decorator'; +import { UserId } from '../auth/user-id.decorator'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { PageDto } from '../common/DTO/page.dto'; -import { BaseErrorExceptionFilter } from '../common/error/error-filter'; -import { TokenValidateInterceptor } from '../common/interceptor/token-validate.interceptor'; import { QuestionsService } from '../questions/questions.service'; import { CreateAnswerResponseDto } from './DTO/create-answer.dto'; import { GetAnswerDetailResponseDto } from './DTO/get-answer-detail.dto'; @@ -36,8 +32,6 @@ import { UpdateAnswerRequestDto } from './DTO/update-answer.dto'; import { AnswerService } from './answer.service'; @Controller('/questions/:question_id/answers') -@UseFilters(BaseErrorExceptionFilter) -@UseInterceptors(TokenValidateInterceptor) export class AnswerController { constructor( private readonly authService: AuthService, @@ -50,22 +44,22 @@ export class AnswerController { return this.answerService.getCreatedByIdAcrossQuestions(answerId); } + @ResourceOwnerIdGetter('question') + async getQuestionOwner(questionId: number): Promise { + return this.questionsService.getQuestionCreatedById(questionId); + } + @Get('/') + @Guard('enumerate-answers', 'question') async getQuestionAnswers( - @Param('question_id', ParseIntPipe) questionId: number, + @Param('question_id', ParseIntPipe) @ResourceId() questionId: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId: number | undefined; - try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - userId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const [answers, page] = await this.answerService.getQuestionAnswers( questionId, pageStart, @@ -85,14 +79,14 @@ export class AnswerController { } @Post('/') - @Guard(AuthorizedAction.create, 'answer') + @Guard('create', 'answer') @CurrentUserOwnResource() async answerQuestion( @Param('question_id', ParseIntPipe) questionId: number, @Body('content') content: string, @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; const answerId = await this.answerService.createAnswer( questionId, userId, @@ -108,20 +102,15 @@ export class AnswerController { } @Get('/:answer_id') + @Guard('query', 'answer') async getAnswerDetail( @Param('question_id', ParseIntPipe) questionId: number, - @Param('answer_id', ParseIntPipe) answerId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId; - try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - userId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const answerDto = await this.answerService.getAnswerDto( questionId, answerId, @@ -146,14 +135,14 @@ export class AnswerController { } @Put('/:answer_id') - @Guard(AuthorizedAction.modify, 'answer') + @Guard('modify', 'answer') async updateAnswer( @Param('question_id', ParseIntPipe) questionId: number, @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, @Body() { content }: UpdateAnswerRequestDto, @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; await this.answerService.updateAnswer( questionId, answerId, @@ -167,31 +156,25 @@ export class AnswerController { } @Delete('/:answer_id') - @Guard(AuthorizedAction.delete, 'answer') + @Guard('delete', 'answer') async deleteAnswer( @Param('question_id', ParseIntPipe) questionId: number, @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; await this.answerService.deleteAnswer(questionId, answerId, userId); } @Post('/:answer_id/attitudes') + @Guard('attitude', 'answer') async updateAttitudeToAnswer( @Param('question_id', ParseIntPipe) questionId: number, - @Param('answer_id', ParseIntPipe) answerId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, @Body() { attitude_type: attitudeType }: AttitudeTypeDto, @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.other, - await this.answerService.getCreatedById(questionId, answerId), - 'answer/attitude', - answerId, - ); const attitudes = await this.answerService.setAttitudeToAnswer( questionId, answerId, @@ -208,19 +191,13 @@ export class AnswerController { } @Put('/:answer_id/favorite') + @Guard('favorite', 'answer') async favoriteAnswer( @Param('question_id', ParseIntPipe) questionId: number, - @Param('answer_id', ParseIntPipe) answerId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.other, - await this.answerService.getCreatedById(questionId, answerId), - 'answer/favourite', - answerId, - ); await this.answerService.favoriteAnswer(questionId, answerId, userId); return { code: 200, @@ -229,19 +206,13 @@ export class AnswerController { } @Delete('/:answer_id/favorite') + @Guard('unfavorite', 'answer') async unfavoriteAnswer( @Param('question_id', ParseIntPipe) questionId: number, - @Param('answer_id', ParseIntPipe) answerId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.other, - await this.answerService.getCreatedById(questionId, answerId), - 'answer/favourite', - answerId, - ); await this.answerService.unfavoriteAnswer(questionId, answerId, userId); } } diff --git a/src/app.module.ts b/src/app.module.ts index f4515000..ce4ab947 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,17 +1,20 @@ import { Module, ValidationPipe } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { APP_PIPE } from '@nestjs/core'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ServeStaticModule } from '@nestjs/serve-static'; import { AnswerModule } from './answer/answer.module'; +import { AttachmentsModule } from './attachments/attachments.module'; import { AvatarsModule } from './avatars/avatars.module'; import { CommentsModule } from './comments/comment.module'; import configuration from './common/config/configuration'; +import { BaseErrorExceptionFilter } from './common/error/error-filter'; +import { EnsureGuardInterceptor } from './common/interceptor/ensure-guard.interceptor'; +import { TokenValidateInterceptor } from './common/interceptor/token-validate.interceptor'; import { GroupsModule } from './groups/groups.module'; +import { MaterialbundlesModule } from './materialbundles/materialbundles.module'; import { MaterialsModule } from './materials/materials.module'; import { QuestionsModule } from './questions/questions.module'; import { UsersModule } from './users/users.module'; -import { AttachmentsModule } from './attachments/attachments.module'; -import { MaterialbundlesModule } from './materialbundles/materialbundles.module'; @Module({ imports: [ ConfigModule.forRoot({ load: [configuration] }), @@ -38,6 +41,18 @@ import { MaterialbundlesModule } from './materialbundles/materialbundles.module' disableErrorMessages: false, }), }, + { + provide: APP_FILTER, + useClass: BaseErrorExceptionFilter, + }, + { + provide: APP_INTERCEPTOR, + useClass: TokenValidateInterceptor, + }, + { + provide: APP_INTERCEPTOR, + useClass: EnsureGuardInterceptor, + }, ], }) export class AppModule {} diff --git a/src/attachments/attachments.controller.ts b/src/attachments/attachments.controller.ts index cbc80a5b..9efafb89 100644 --- a/src/attachments/attachments.controller.ts +++ b/src/attachments/attachments.controller.ts @@ -23,14 +23,13 @@ import { } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { AuthService } from '../auth/auth.service'; -import { AuthorizedAction } from '../auth/definitions'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; import { attachmentTypeDto } from './DTO/attachments.dto'; import { getAttachmentResponseDto } from './DTO/get-attachment.dto'; import { uploadAttachmentDto } from './DTO/upload-attachment.dto'; import { AttachmentsService } from './attachments.service'; -@UsePipes(new ValidationPipe()) -@UseFilters(new BaseErrorExceptionFilter()) +import { AuthToken, Guard } from '../auth/guard.decorator'; + @Controller('attachments') export class AttachmentsController { constructor( @@ -40,19 +39,12 @@ export class AttachmentsController { @Post() @UseInterceptors(FileInterceptor('file')) + @Guard('create', 'attachment') async uploadAttachment( @Body() { type }: attachmentTypeDto, @UploadedFile() file: Express.Multer.File, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { - const uploaderId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - uploaderId, - 'attachment', - undefined, - ); const attachmentId = await this.attachmentsService.uploadAttachment( type, file, @@ -67,8 +59,10 @@ export class AttachmentsController { } @Get('/:attachmentId') + @Guard('query', 'attachment') async getAttachmentDetail( @Param('attachmentId', ParseIntPipe) id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { const attachment = await this.attachmentsService.getAttachment(id); return { diff --git a/src/auth/auth.error.ts b/src/auth/auth.error.ts index 0493f49b..5e84d8d7 100644 --- a/src/auth/auth.error.ts +++ b/src/auth/auth.error.ts @@ -8,7 +8,7 @@ */ import { BaseError } from '../common/error/base-error'; -import { AuthorizedAction, authorizedActionToString } from './definitions'; +import { AuthorizedAction } from './definitions'; export class AuthenticationRequiredError extends BaseError { constructor() { @@ -37,9 +37,7 @@ export class PermissionDeniedError extends BaseError { ) { super( 'PermissionDeniedError', - `The attempt to perform action '${authorizedActionToString( - action, - )}' on resource (resourceOwnerId: ${resourceOwnerId}, resourceType: ${resourceType}, resourceId: ${resourceId}) is not permitted by the given token.`, + `The attempt to perform action '${action}' on resource (resourceOwnerId: ${resourceOwnerId}, resourceType: ${resourceType}, resourceId: ${resourceId}) is not permitted by the given token.`, 403, ); } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 28a29e00..4f33ce2a 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -7,21 +7,6 @@ * */ -/* - -IMPORTANT NOTICE: - -If you have modified this file, please run the following linux command: - -./node_modules/.bin/ts-json-schema-generator \ - --path 'src/auth/auth.service.ts' \ - --type 'TokenPayload' \ - > src/auth/token-payload.schema.json - -to update the schema file, which is used in validating the token payload. - -*/ - import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import Ajv from 'ajv'; diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index 70058588..39082ae0 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -15,31 +15,9 @@ import { PermissionDeniedError, } from './auth.error'; import { AuthService } from './auth.service'; -import { - Authorization, - AuthorizedAction, - authorizedActionToString, -} from './definitions'; +import { Authorization } from './definitions'; import { SessionService } from './session.service'; -describe('authorizedActionToString()', () => { - it('should return "create" when AuthorizedAction.create is passed', () => { - expect(authorizedActionToString(AuthorizedAction.create)).toEqual('create'); - }); - it('should return "delete" when AuthorizedAction.delete is passed', () => { - expect(authorizedActionToString(AuthorizedAction.delete)).toEqual('delete'); - }); - it('should return "modify" when AuthorizedAction.modify is passed', () => { - expect(authorizedActionToString(AuthorizedAction.modify)).toEqual('modify'); - }); - it('should return "query" when AuthorizedAction.query is passed', () => { - expect(authorizedActionToString(AuthorizedAction.query)).toEqual('query'); - }); - it('should return "other" when AuthorizedAction.other is passed', () => { - expect(authorizedActionToString(AuthorizedAction.other)).toEqual('other'); - }); -}); - describe('AuthService', () => { let app: TestingModule; let authService: AuthService; @@ -95,7 +73,7 @@ describe('AuthService', () => { userId: 1, permissions: [ { - authorizedActions: [AuthorizedAction.create], + authorizedActions: ['create'], authorizedResource: { resourceType: 'user', resourceId: 1, @@ -117,7 +95,7 @@ describe('AuthService', () => { userId: 1, permissions: [ { - authorizedActions: [AuthorizedAction.create], + authorizedActions: ['create'], authorizedResource: { resourceType: 'user', resourceId: 1, @@ -140,13 +118,7 @@ describe('AuthService', () => { permissions: [], }); expect(() => { - authService.audit( - token, - AuthorizedAction.other, - '1' as any as number, - 'type', - 1, - ); + authService.audit(token, 'other', '1' as any as number, 'type', 1); }).toThrow('resourceOwnerId must be a number.'); }); it('should throw Error("resourceId must be a number.")', () => { @@ -155,22 +127,16 @@ describe('AuthService', () => { permissions: [], }); expect(() => { - authService.audit( - token, - AuthorizedAction.other, - 1, - 'type', - '1' as any as number, - ); + authService.audit(token, 'other', 1, 'type', '1' as any as number); }).toThrow('resourceId must be a number.'); }); it('should throw AuthenticationRequiredError()', () => { - expect(() => - authService.audit('', AuthorizedAction.other, 1, 'type', 1), - ).toThrow(new AuthenticationRequiredError()); - expect(() => - authService.audit(undefined, AuthorizedAction.other, 1, 'type', 1), - ).toThrow(new AuthenticationRequiredError()); + expect(() => authService.audit('', 'other', 1, 'type', 1)).toThrow( + new AuthenticationRequiredError(), + ); + expect(() => authService.audit(undefined, 'other', 1, 'type', 1)).toThrow( + new AuthenticationRequiredError(), + ); expect(() => authService.decode('')).toThrow( new AuthenticationRequiredError(), ); @@ -183,7 +149,7 @@ describe('AuthService', () => { userId: 0, permissions: [ { - authorizedActions: [AuthorizedAction.query], + authorizedActions: ['query'], authorizedResource: { ownedByUser: 1, types: undefined, @@ -192,14 +158,14 @@ describe('AuthService', () => { }, ], }); - authService.audit(`bearer ${token}`, AuthorizedAction.query, 1, 'type', 1); + authService.audit(`bearer ${token}`, 'query', 1, 'type', 1); }); it('should throw PermissionDeniedError()', () => { const token = authService.sign({ userId: 0, permissions: [ { - authorizedActions: [AuthorizedAction.delete], + authorizedActions: ['delete'], authorizedResource: { ownedByUser: undefined, types: undefined, @@ -208,9 +174,9 @@ describe('AuthService', () => { }, ], }); - expect(() => - authService.audit(token, AuthorizedAction.delete, 1, 'type', 5), - ).toThrow(new PermissionDeniedError(AuthorizedAction.delete, 1, 'type', 5)); + expect(() => authService.audit(token, 'delete', 1, 'type', 5)).toThrow( + new PermissionDeniedError('delete', 1, 'type', 5), + ); }); it('should verify and decode successfully', () => { const authorization: Authorization = { diff --git a/src/auth/definitions.ts b/src/auth/definitions.ts index d0374c0b..a53e67a5 100644 --- a/src/auth/definitions.ts +++ b/src/auth/definitions.ts @@ -1,3 +1,11 @@ +/* + * Description: This file defines the basic structures used in authorization. + * + * Author(s): + * Nictheboy Li + * + */ + /* IMPORTANT NOTICE: @@ -13,29 +21,8 @@ to update the schema file, which is used in validating the token payload. */ -export enum AuthorizedAction { - create = 1, - delete = 2, - modify = 3, - query = 4, +export type AuthorizedAction = string; - other = 5, -} - -export function authorizedActionToString(action: AuthorizedAction): string { - switch (action) { - case AuthorizedAction.create: - return 'create'; - case AuthorizedAction.delete: - return 'delete'; - case AuthorizedAction.modify: - return 'modify'; - case AuthorizedAction.query: - return 'query'; - case AuthorizedAction.other: - return 'other'; - } -} // This class is used as a filter. // // If all the conditions are undefined, it matches everything. diff --git a/src/auth/guard.decorator.ts b/src/auth/guard.decorator.ts index 1148e5cf..798978cb 100644 --- a/src/auth/guard.decorator.ts +++ b/src/auth/guard.decorator.ts @@ -1,3 +1,17 @@ +/* + * Description: This file implements the guard decorator that is used to protect resources. + * + * You need to use @ResourceId(), @AuthToken(), @ResourceOwnerIdGetter() and @CurrentUserOwnResource() + * to provide the necessary information for the guard decorator. + * + * You can lean how to use these things by reading controller's code. + * + * Author(s): + * Nictheboy Li + * + */ + +import { SetMetadata } from '@nestjs/common'; import { AuthenticationRequiredError } from './auth.error'; import { AuthService } from './auth.service'; import { AuthorizedAction } from './definitions'; @@ -10,6 +24,9 @@ const RESOURCE_OWNER_ID_GETTER_METADATA_KEY = Symbol( const CURRENT_USER_OWN_RESOURCE_METADATA_KEY = Symbol( 'currentUserOwnResourceMetadata', ); +export const HAS_GUARD_DECORATOR_METADATA_KEY = Symbol( + 'hasGuardDecoratorMetadata', +); export function ResourceId() { return function ( @@ -142,7 +159,11 @@ export function Guard(action: AuthorizedAction, resourceType: string) { ); return originalMethod.apply(this, args); }; - + SetMetadata(HAS_GUARD_DECORATOR_METADATA_KEY, true)( + target, + propertyKey, + descriptor, + ); return descriptor; }; } diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts index ca766e31..20cddfc2 100644 --- a/src/auth/session.service.ts +++ b/src/auth/session.service.ts @@ -15,7 +15,7 @@ import { SessionRevokedError, } from './auth.error'; import { AuthService } from './auth.service'; -import { Authorization, AuthorizedAction } from './definitions'; +import { Authorization } from './definitions'; @Injectable() export class SessionService { @@ -41,7 +41,7 @@ export class SessionService { types: ['auth/session:refresh', 'auth/session:revoke'], resourceIds: [sessionId], }, - authorizedActions: [AuthorizedAction.other], + authorizedActions: ['other'], }, ], }; @@ -92,7 +92,7 @@ export class SessionService { const sessionId = auth.permissions[0].authorizedResource.resourceIds[0]; this.authService.audit( oldRefreshToken, - AuthorizedAction.other, + 'other', undefined, 'auth/session:refresh', sessionId, @@ -172,7 +172,7 @@ export class SessionService { const sessionId = auth.permissions[0].authorizedResource.resourceIds[0]; this.authService.audit( refreshToken, - AuthorizedAction.other, + 'other', undefined, 'auth/session:revoke', sessionId, diff --git a/src/auth/token-payload.schema.json b/src/auth/token-payload.schema.json index d25ebdcf..40efb68a 100644 --- a/src/auth/token-payload.schema.json +++ b/src/auth/token-payload.schema.json @@ -22,14 +22,7 @@ "type": "object" }, "AuthorizedAction": { - "enum": [ - 1, - 2, - 3, - 4, - 5 - ], - "type": "number" + "type": "string" }, "AuthorizedResource": { "additionalProperties": false, diff --git a/src/auth/user-id.decorator.ts b/src/auth/user-id.decorator.ts new file mode 100644 index 00000000..1c709eff --- /dev/null +++ b/src/auth/user-id.decorator.ts @@ -0,0 +1,39 @@ +/* + * Description: This file implements a decorator that can be used to get the user id. + * It can be used just like the @Ip() decorator. + * + * It has two forms: + * 1. @UserId() userId: number | undefined + * 2. @UserId(true) userId: number + * Only the second one will throw an error if the user is not logged in. + * + * Author(s): + * Nictheboy Li + * + */ + +import { ExecutionContext, createParamDecorator } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthenticationRequiredError } from './auth.error'; + +export const UserId = createParamDecorator( + (required: boolean = false, ctx: ExecutionContext): number | undefined => { + const request = ctx.switchToHttp().getRequest(); + const auth = request.headers.authorization; + let userId: number | undefined = undefined; + if (required) { + userId = AuthService.instance.verify(auth).userId; + /* istanbul ignore if */ + if (userId == undefined) { + throw new AuthenticationRequiredError(); + } + } else { + try { + userId = AuthService.instance.verify(auth).userId; + } catch { + // The user is not logged in. + } + } + return userId; + }, +); diff --git a/src/avatars/avatars.controller.ts b/src/avatars/avatars.controller.ts index 7911dcde..9e83eb33 100644 --- a/src/avatars/avatars.controller.ts +++ b/src/avatars/avatars.controller.ts @@ -27,20 +27,19 @@ import { InvalidAvatarTypeError, } from './avatars.error'; import { AvatarsService } from './avatars.service'; +import { AuthToken, Guard, ResourceId } from '../auth/guard.decorator'; @Controller('/avatars') -@UsePipes(ValidationPipe) -@UseFilters(BaseErrorExceptionFilter) -@UseInterceptors(TokenValidateInterceptor) export class AvatarsController { constructor(private readonly avatarsService: AvatarsService) {} @Post() @UseInterceptors(FileInterceptor('avatar')) + @Guard('create', 'avatar') async createAvatar( @UploadedFile() file: Express.Multer.File, + @Headers('Authorization') @AuthToken() auth: string, ): Promise { - //const userId = this.authService.verify(auth).userId; const avatar = await this.avatarsService.save(file.path, file.filename); return { code: 201, @@ -52,9 +51,11 @@ export class AvatarsController { } @Get('/default') + @Guard('query-default', 'avatar') async getDefaultAvatar( @Headers('If-None-Match') ifNoneMatch: string, @Res({ passthrough: true }) res: Response, + @Headers('Authorization') @AuthToken() auth: string, ) { const defaultAvatarId = await this.avatarsService.getDefaultAvatarId(); const avatarPath = await this.avatarsService.getAvatarPath(defaultAvatarId); @@ -83,10 +84,12 @@ export class AvatarsController { } @Get('/:id') + @Guard('query', 'avatar') async getAvatar( @Headers('If-None-Match') ifNoneMatch: string, - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Res({ passthrough: true }) res: Response, + @Headers('Authorization') @AuthToken() auth: string, ) { const avatarPath = await this.avatarsService.getAvatarPath(id); if (!fs.existsSync(avatarPath)) { @@ -114,8 +117,10 @@ export class AvatarsController { } @Get() + @Guard('enumerate', 'avatar') async getAvailableAvatarIds( @Query('type') type: AvatarType = AvatarType.predefined, + @Headers('Authorization') @AuthToken() auth: string, ) { if (type == AvatarType.predefined) { const avatarIds = await this.avatarsService.getPreDefinedAvatarIds(); diff --git a/src/comments/comment.controller.ts b/src/comments/comment.controller.ts index 8c150052..94bc062c 100644 --- a/src/comments/comment.controller.ts +++ b/src/comments/comment.controller.ts @@ -16,7 +16,14 @@ import { import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; import { AuthService } from '../auth/auth.service'; -import { AuthorizedAction } from '../auth/definitions'; +import { + AuthToken, + CurrentUserOwnResource, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; +import { UserId } from '../auth/user-id.decorator'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { PageDto } from '../common/DTO/page.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; @@ -28,31 +35,29 @@ import { UpdateCommentDto } from './DTO/update-comment.dto'; import { CommentsService } from './comment.service'; import { parseCommentable } from './commentable.enum'; @Controller('/comments') -@UseFilters(BaseErrorExceptionFilter) -@UseInterceptors(TokenValidateInterceptor) export class CommentsController { constructor( private readonly commentsService: CommentsService, private readonly authService: AuthService, ) {} + @ResourceOwnerIdGetter('comment') + async getCommentOwner(commentId: number): Promise { + return await this.commentsService.getCommentCreatedById(commentId); + } + @Get('/:commentableType/:commentableId') + @Guard('enumerate', 'comment') async getComments( @Param('commentableType') commentableType: string, @Param('commentableId', ParseIntPipe) commentableId: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId: number | undefined; - try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - userId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const [comments, page] = await this.commentsService.getComments( parseCommentable(commentableType), commentableId, @@ -76,19 +81,13 @@ export class CommentsController { //! before the dynamic route `/:commentableType/:commentableId` //! so that it is not overridden. @Post('/:commentId/attitudes') + @Guard('attitude', 'comment') async updateAttitudeToComment( - @Param('commentId', ParseIntPipe) commentId: number, + @Param('commentId', ParseIntPipe) @ResourceId() commentId: number, @Body() { attitude_type: attitudeType }: AttitudeTypeDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.other, - await this.commentsService.getCommentCreatedById(commentId), - 'comment/attitude', - commentId, - ); const attitudes = await this.commentsService.setAttitudeToComment( commentId, userId, @@ -104,21 +103,16 @@ export class CommentsController { } @Post('/:commentableType/:commentableId') + @Guard('create', 'comment') + @CurrentUserOwnResource() async createComment( @Param('commentableType') commentableType: string, @Param('commentableId', ParseIntPipe) commentableId: number, @Body('content') content: string, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - userId, - 'comment', - undefined, - ); const commentId = await this.commentsService.createComment( parseCommentable(commentableType), commentableId, @@ -135,35 +129,24 @@ export class CommentsController { } @Delete('/:commentId') + @Guard('delete', 'comment') async deleteComment( - @Param('commentId', ParseIntPipe) commentId: number, - @Headers('Authorization') auth: string | undefined, + @Param('commentId', ParseIntPipe) @ResourceId() commentId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.delete, - await this.commentsService.getCommentCreatedById(commentId), - 'comment', - commentId, - ); await this.commentsService.deleteComment(commentId, userId); } @Get('/:commentId') + @Guard('query', 'comment') async getCommentDetail( - @Param('commentId', ParseIntPipe) commentId: number, - @Headers('Authorization') auth: string | undefined, + @Param('commentId', ParseIntPipe) @ResourceId() commentId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId: number | undefined; - try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - userId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const comment = await this.commentsService.getCommentDto( commentId, userId, @@ -180,18 +163,12 @@ export class CommentsController { } @Patch('/:commentId') + @Guard('modify', 'comment') async updateComment( - @Param('commentId', ParseIntPipe) commentId: number, + @Param('commentId', ParseIntPipe) @ResourceId() commentId: number, @Body() { content }: UpdateCommentDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { - this.authService.audit( - auth, - AuthorizedAction.modify, - await this.commentsService.getCommentCreatedById(commentId), - 'comment', - commentId, - ); await this.commentsService.updateComment(commentId, content); return { code: 200, diff --git a/src/common/interceptor/ensure-guard.interceptor.ts b/src/common/interceptor/ensure-guard.interceptor.ts new file mode 100644 index 00000000..ebc9f5d7 --- /dev/null +++ b/src/common/interceptor/ensure-guard.interceptor.ts @@ -0,0 +1,44 @@ +/* + * Description: An interceptor that will ensure the presence of a guard or NoAuth decorator. + * This interceptor is used globally to avoid forgetting to add a guard. + * + * Author(s): + * Nictheboy Li + * + */ + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { ModuleRef, Reflector } from '@nestjs/core'; +import { Observable } from 'rxjs'; +import { AuthService } from '../../auth/auth.service'; +import { NoAuth } from './token-validate.interceptor'; +import { HAS_GUARD_DECORATOR_METADATA_KEY } from '../../auth/guard.decorator'; + +// See: https://docs.nestjs.com/interceptors +// See: https://stackoverflow.com/questions/63618612/nestjs-use-service-inside-interceptor-not-global-interceptor + +@Injectable() +export class EnsureGuardInterceptor implements NestInterceptor { + constructor(private readonly reflector: Reflector) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const noAuth = this.reflector.get(NoAuth, context.getHandler()); + const hasGuard = + this.reflector.get( + HAS_GUARD_DECORATOR_METADATA_KEY, + context.getHandler(), + ) ?? false; + /* istanbul ignore if */ + if (!hasGuard && !noAuth) { + throw new Error( + 'EnsureGuardInterceptor: Neither Guard nor NoAuth decorator found', + ); + } + return next.handle(); + } +} diff --git a/src/common/interceptor/token-validate.interceptor.ts b/src/common/interceptor/token-validate.interceptor.ts index 7cc7e561..83778195 100644 --- a/src/common/interceptor/token-validate.interceptor.ts +++ b/src/common/interceptor/token-validate.interceptor.ts @@ -32,26 +32,20 @@ import { AuthService } from '../../auth/auth.service'; // See: https://docs.nestjs.com/interceptors // See: https://stackoverflow.com/questions/63618612/nestjs-use-service-inside-interceptor-not-global-interceptor -export const NoTokenValidate = Reflector.createDecorator(); +export const NoAuth = Reflector.createDecorator(); @Injectable() export class TokenValidateInterceptor implements NestInterceptor { - constructor( - private readonly authService: AuthService, - private reflector: Reflector, - ) {} + constructor(private reflector: Reflector) {} intercept(context: ExecutionContext, next: CallHandler): Observable { - const noTokenValidate = this.reflector.get( - NoTokenValidate, - context.getHandler(), - ); - if (noTokenValidate) { + const noAuth = this.reflector.get(NoAuth, context.getHandler()); + if (noAuth) { return next.handle(); } const token = context.switchToHttp().getRequest().headers['authorization']; if (token != undefined) { - this.authService.verify(token); + AuthService.instance.verify(token); } return next.handle(); } diff --git a/src/groups/groups.controller.ts b/src/groups/groups.controller.ts index c7d41d1f..51efb106 100644 --- a/src/groups/groups.controller.ts +++ b/src/groups/groups.controller.ts @@ -26,7 +26,10 @@ import { AuthService } from '../auth/auth.service'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { GroupPageDto } from '../common/DTO/page.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; -import { TokenValidateInterceptor } from '../common/interceptor/token-validate.interceptor'; +import { + NoAuth, + TokenValidateInterceptor, +} from '../common/interceptor/token-validate.interceptor'; import { CreateGroupDto } from './DTO/create-group.dto'; import { GetGroupsResponseDto } from './DTO/get-groups.dto'; import { GetGroupMembersResponseDto } from './DTO/get-members.dto'; @@ -47,6 +50,7 @@ export class GroupsController { ) {} @Post('/') + @NoAuth() async createGroup( @Body() { name, intro, avatarId }: CreateGroupDto, @Headers('Authorization') auth: string | undefined, @@ -71,6 +75,7 @@ export class GroupsController { } @Get('/') + @NoAuth() async getGroups( @Query() { q: key, page_start, page_size, type }: GroupPageDto, @Headers('Authorization') auth: string | undefined, @@ -103,6 +108,7 @@ export class GroupsController { } @Get('/:id') + @NoAuth() async getGroupDetail( @Param('id', ParseIntPipe) id: number, @Headers('Authorization') auth: string | undefined, @@ -129,6 +135,7 @@ export class GroupsController { } @Put('/:id') + @NoAuth() async updateGroup( @Param('id', ParseIntPipe) id: number, @Headers('Authorization') auth: string | undefined, @@ -149,6 +156,7 @@ export class GroupsController { } @Delete('/:id') + @NoAuth() async deleteGroup( @Param('id', ParseIntPipe) id: number, @Headers('Authorization') auth: string | undefined, @@ -158,6 +166,7 @@ export class GroupsController { } @Get('/:id/members') + @NoAuth() async getGroupMembers( @Param('id', ParseIntPipe) id: number, @Query() { page_start, page_size }: GroupPageDto, @@ -190,6 +199,7 @@ export class GroupsController { } @Post('/:id/members') + @NoAuth() async joinGroup( @Param('id', ParseIntPipe) groupId: number, @Body() { intro }: JoinGroupDto, @@ -209,6 +219,7 @@ export class GroupsController { } @Delete('/:id/members') + @NoAuth() async quitGroup( @Param('id', ParseIntPipe) groupId: number, @Headers('Authorization') auth: string | undefined, @@ -223,6 +234,7 @@ export class GroupsController { } @Get('/:id/questions') + @NoAuth() async getGroupQuestions( @Param('id', ParseIntPipe) id: number, @Query() { page_start, page_size }: GroupPageDto, diff --git a/src/main.ts b/src/main.ts index 864ac40f..e972cfb1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { BaseErrorExceptionFilter } from './common/error/error-filter'; export const IS_DEV = process.env.NODE_ENV !== 'production'; diff --git a/src/materialbundles/materialbundles.controller.ts b/src/materialbundles/materialbundles.controller.ts index f407496e..5fbbc5bc 100644 --- a/src/materialbundles/materialbundles.controller.ts +++ b/src/materialbundles/materialbundles.controller.ts @@ -24,12 +24,19 @@ import { ValidationPipe, } from '@nestjs/common'; import { AuthService } from '../auth/auth.service'; -import { AuthorizedAction } from '../auth/definitions'; import { createMaterialBundleRequestDto, createMaterialBundleResponseDto, } from './DTO/create-materialbundle.dto'; //import { getMaterialBundleListDto } from './DTO/get-materialbundle.dto'; +import { + AuthToken, + CurrentUserOwnResource, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; +import { UserId } from '../auth/user-id.decorator'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; import { @@ -41,33 +48,35 @@ import { updateMaterialBundleDto } from './DTO/update-materialbundle.dto'; import { MaterialbundlesService } from './materialbundles.service'; @Controller('/material-bundles') -@UsePipes(new ValidationPipe()) -@UseFilters(new BaseErrorExceptionFilter()) export class MaterialbundlesController { constructor( private readonly materialbundlesService: MaterialbundlesService, private readonly authService: AuthService, ) {} + @ResourceOwnerIdGetter('material-bundle') + async getMaterialBundleOwner( + materialBundleId: number, + ): Promise { + return this.materialbundlesService.getMaterialBundleCreatorId( + materialBundleId, + ); + } + @Post() + @Guard('create', 'material-bundle') + @CurrentUserOwnResource() async createMaterialBundle( - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, @Body() { title, content, materials }: createMaterialBundleRequestDto, + @UserId(true) userId: number, ): Promise { - const creatorId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - creatorId, - 'materialbundle', - undefined, - ); const bundleId = await this.materialbundlesService.createBundle( title, content, materials, - creatorId, + userId, ); return { code: 201, @@ -77,7 +86,9 @@ export class MaterialbundlesController { }, }; } + @Get() + @Guard('enumerate', 'material-bundle') async getMaterialBundleList( @Query() { @@ -86,16 +97,11 @@ export class MaterialbundlesController { page_size: pageSize, sort, }: getMaterialBundleListDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, + @UserId() viewerId: number | undefined, ): Promise { - let viewerId: number | undefined; - try { - viewerId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const [bundles, page] = await this.materialbundlesService.getBundles( q, pageStart, @@ -115,18 +121,14 @@ export class MaterialbundlesController { }; } @Get('/:materialBundleId') + @Guard('query', 'material-bundle') async getMaterialBundleDetail( - @Param('materialBundleId', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('materialBundleId', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, + @UserId() viewerId: number | undefined, ): Promise { - let viewerId: number | undefined; - try { - viewerId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const materialBundle = await this.materialbundlesService.getBundleDetail( id, viewerId, @@ -142,13 +144,13 @@ export class MaterialbundlesController { }; } @Patch('/:materialBundleId') + @Guard('modify', 'material-bundle') async updateMaterialBundle( - @Param('materialBundleId', ParseIntPipe) id: number, + @Param('materialBundleId', ParseIntPipe) @ResourceId() id: number, @Body() { title, content, materials }: updateMaterialBundleDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - //console.log(title, content, materials); - const userId = this.authService.verify(auth).userId; await this.materialbundlesService.updateMaterialBundle( id, userId, @@ -162,11 +164,12 @@ export class MaterialbundlesController { }; } @Delete('/:materialBundleId') + @Guard('delete', 'material-bundle') async deleteMaterialBundle( - @Param('materialBundleId', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('materialBundleId', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ) { - const userId = this.authService.verify(auth).userId; await this.materialbundlesService.deleteMaterialBundle(id, userId); } } diff --git a/src/materialbundles/materialbundles.service.ts b/src/materialbundles/materialbundles.service.ts index 8288689d..9fcfc687 100644 --- a/src/materialbundles/materialbundles.service.ts +++ b/src/materialbundles/materialbundles.service.ts @@ -328,4 +328,14 @@ export class MaterialbundlesService { }); return; } + + async getMaterialBundleCreatorId(bundleId: number): Promise { + const bundle = await this.prismaService.materialBundle.findUnique({ + where: { + id: bundleId, + }, + }); + if (!bundle) throw new BundleNotFoundError(bundleId); + return bundle.creatorId; + } } diff --git a/src/materials/materials.controller.ts b/src/materials/materials.controller.ts index b5a993a3..aa4b95fe 100644 --- a/src/materials/materials.controller.ts +++ b/src/materials/materials.controller.ts @@ -25,16 +25,15 @@ import { } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { AuthService } from '../auth/auth.service'; -import { AuthorizedAction } from '../auth/definitions'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; import { GetMaterialResponseDto } from './DTO/get-material.dto'; import { MaterialTypeDto } from './DTO/material.dto'; import { UploadMaterialResponseDto } from './DTO/upload-material.dto'; import { MaterialsService } from './materials.service'; +import { AuthToken, Guard, ResourceId } from '../auth/guard.decorator'; +import { UserId } from '../auth/user-id.decorator'; @Controller('/materials') -@UsePipes(new ValidationPipe()) -@UseFilters(new BaseErrorExceptionFilter()) export class MaterialsController { constructor( private readonly materialsService: MaterialsService, @@ -43,19 +42,13 @@ export class MaterialsController { @Post() @UseInterceptors(FileInterceptor('file')) + @Guard('create', 'material') async uploadMaterial( @Body() { type: materialType }: MaterialTypeDto, @UploadedFile() file: Express.Multer.File, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) uploaderId: number, ): Promise { - const uploaderId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - uploaderId, - 'material', - undefined, - ); const materialId = await this.materialsService.uploadMaterial( materialType, file, @@ -71,18 +64,14 @@ export class MaterialsController { } @Get('/:materialId') + @Guard('query', 'material') async getMaterialDetail( - @Param('materialId', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('materialId', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, + @UserId() viewerId: number | undefined, ): Promise { - let viewerId: number | undefined; - try { - viewerId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const material = await this.materialsService.getMaterial( id, viewerId, @@ -99,7 +88,7 @@ export class MaterialsController { } @Delete('/:materialId') // to do async deleteMaterial() //@Param('materialId') id: number, - //@Headers('Authorization') auth: string | undefined, + //@Headers('Authorization') @AuthToken() auth: string | undefined, : Promise { /* istanbul ignore next */ throw new Error('deleteMaterial method is not implemented yet.'); diff --git a/src/questions/questions.controller.ts b/src/questions/questions.controller.ts index e5e9b0d4..d7e1a31a 100644 --- a/src/questions/questions.controller.ts +++ b/src/questions/questions.controller.ts @@ -5,6 +5,8 @@ * * Author(s): * Nictheboy Li + * Andy Lee + * HuanCheng65 * */ @@ -26,7 +28,7 @@ import { import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; import { AuthService } from '../auth/auth.service'; -import { AuthorizedAction } from '../auth/definitions'; +import { UserId } from '../auth/user-id.decorator'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { PageDto, PageWithKeywordDto } from '../common/DTO/page.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; @@ -57,31 +59,36 @@ import { SearchQuestionResponseDto } from './DTO/search-question.dto'; import { SetBountyDto } from './DTO/set-bounty.dto'; import { UpdateQuestionRequestDto } from './DTO/update-question.dto'; import { QuestionsService } from './questions.service'; +import { + AuthToken, + CurrentUserOwnResource, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; @Controller('/questions') -@UseFilters(BaseErrorExceptionFilter) -@UseInterceptors(TokenValidateInterceptor) export class QuestionsController { constructor( readonly questionsService: QuestionsService, readonly authService: AuthService, ) {} + @ResourceOwnerIdGetter('question') + async getQuestionOwner(id: number): Promise { + return this.questionsService.getQuestionCreatedById(id); + } + @Get('/') + @Guard('enumerate', 'question') async searchQuestion( @Query() { q, page_start: pageStart, page_size: pageSize }: PageWithKeywordDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() searcherId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - // try get viewer id - let searcherId: number | undefined; - try { - searcherId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const [questions, pageRespond] = await this.questionsService.searchQuestions( q ?? '', @@ -102,19 +109,14 @@ export class QuestionsController { } @Post('/') + @Guard('create', 'question') + @CurrentUserOwnResource() async addQuestion( @Body() { title, content, type, topics, groupId, bounty }: AddQuestionRequestDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - userId, - 'questions', - undefined, - ); const questionId = await this.questionsService.addQuestion( userId, title, @@ -134,18 +136,14 @@ export class QuestionsController { } @Get('/:id') + @Guard('query', 'question') async getQuestion( - @Param('id', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId: number | undefined; - try { - userId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const questionDto = await this.questionsService.getQuestionDto( id, userId, @@ -162,18 +160,12 @@ export class QuestionsController { } @Put('/:id') + @Guard('modify', 'question') async updateQuestion( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Body() { title, content, type, topics }: UpdateQuestionRequestDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { - this.authService.audit( - auth, - AuthorizedAction.modify, - await this.questionsService.getQuestionCreatedById(id), - 'questions', - id, - ); await this.questionsService.updateQuestion( id, title, @@ -189,35 +181,25 @@ export class QuestionsController { } @Delete('/:id') + @Guard('delete', 'question') async deleteQuestion( - @Param('id', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { - this.authService.audit( - auth, - AuthorizedAction.delete, - await this.questionsService.getQuestionCreatedById(id), - 'questions', - id, - ); await this.questionsService.deleteQuestion(id); } @Get('/:id/followers') + @Guard('enumerate-followers', 'question') async getQuestionFollowers( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId: number | undefined; - try { - userId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const [followers, pageRespond] = await this.questionsService.getQuestionFollowers( id, @@ -238,18 +220,12 @@ export class QuestionsController { } @Post('/:id/followers') + @Guard('follow', 'question') async followQuestion( - @Param('id', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - userId, - 'questions/following', - id, - ); await this.questionsService.followQuestion(userId, id); return { code: 201, @@ -261,18 +237,12 @@ export class QuestionsController { } @Delete('/:id/followers') + @Guard('unfollow', 'question') async unfollowQuestion( - @Param('id', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.delete, - userId, - 'questions/following', - id, - ); await this.questionsService.unfollowQuestion(userId, id); return { code: 200, @@ -284,19 +254,13 @@ export class QuestionsController { } @Post('/:id/attitudes') + @Guard('attitude', 'question') async updateAttitudeToQuestion( - @Param('id', ParseIntPipe) questionId: number, + @Param('id', ParseIntPipe) @ResourceId() questionId: number, @Body() { attitude_type: attitudeType }: AttitudeTypeDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.other, - await this.questionsService.getQuestionCreatedById(questionId), - 'questions/attitude', - questionId, - ); const attitudes = await this.questionsService.setAttitudeToQuestion( questionId, userId, @@ -312,8 +276,9 @@ export class QuestionsController { } @Get('/:id/invitations') + @Guard('enumerate-invitations', 'question') async getQuestionInvitations( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, @Query( @@ -325,16 +290,11 @@ export class QuestionsController { }), ) sort: SortPattern = { createdAt: 'desc' }, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId: number | undefined; - try { - userId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const [invitations, page] = await this.questionsService.getQuestionInvitations( id, @@ -356,19 +316,12 @@ export class QuestionsController { } @Post('/:id/invitations') + @Guard('invite', 'question') async inviteUserAnswerQuestion( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Body() { user_id: invitedUserId }: InviteUsersAnswerRequestDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - userId, - 'questions/invitation', - undefined, - ); const inviteId = await this.questionsService.inviteUsersToAnswerQuestion( id, invitedUserId, @@ -383,18 +336,12 @@ export class QuestionsController { } @Delete('/:id/invitations/:invitation_id') + @Guard('uninvite', 'question') async cancelInvition( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Param('invitation_id', ParseIntPipe) invitationId: number, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { - this.authService.audit( - auth, - AuthorizedAction.delete, - await this.questionsService.getInvitedById(id, invitationId), - 'questions/invitation', - invitationId, - ); await this.questionsService.cancelInvitation(id, invitationId); return { code: 204, @@ -406,20 +353,16 @@ export class QuestionsController { //! before the dynamic route `/:id/invitations/:invitation_id` //! so that it is not overridden. @Get('/:id/invitations/recommendations') + @Guard('query-invitation-recommendations', 'question') async getRecommendations( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Query('page_size') pageSize: number, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId: number | undefined; - try { - userId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const users = await this.questionsService.getQuestionInvitationRecommendations( id, @@ -438,19 +381,15 @@ export class QuestionsController { } @Get('/:id/invitations/:invitation_id') + @Guard('query-invitation', 'question') async getInvitationDetail( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Param('invitation_id', ParseIntPipe) invitationId: number, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId: number | undefined; - try { - userId = this.authService.verify(auth).userId; - } catch { - // The user is not logged in. - } const invitationDto = await this.questionsService.getQuestionInvitationDto( id, invitationId, @@ -468,18 +407,12 @@ export class QuestionsController { } @Put('/:id/bounty') + @Guard('set-bounty', 'question') async setBounty( - @Param('id', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, @Body() { bounty }: SetBountyDto, ): Promise { - this.authService.audit( - auth, - AuthorizedAction.modify, - await this.questionsService.getQuestionCreatedById(id), - 'questions', - id, - ); await this.questionsService.setBounty(id, bounty); return { code: 200, @@ -488,18 +421,12 @@ export class QuestionsController { } @Put('/:id/acceptance') + @Guard('accept-answer', 'question') async acceptAnswer( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Query('answer_id', ParseIntPipe) answer_id: number, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { - this.authService.audit( - auth, - AuthorizedAction.modify, - await this.questionsService.getQuestionCreatedById(id), - 'questions', - id, - ); await this.questionsService.acceptAnswer(id, answer_id); return { code: 200, diff --git a/src/questions/questions.error.ts b/src/questions/questions.error.ts index b28f041b..6693dec6 100644 --- a/src/questions/questions.error.ts +++ b/src/questions/questions.error.ts @@ -4,6 +4,8 @@ * * Author(s): * Nictheboy Li + * Andy Lee + * HuanCheng65 * */ diff --git a/src/questions/questions.service.ts b/src/questions/questions.service.ts index 0dc940de..4cfceeb4 100644 --- a/src/questions/questions.service.ts +++ b/src/questions/questions.service.ts @@ -4,6 +4,8 @@ * * Author(s): * Nictheboy Li + * Andy Lee + * HuanCheng65 * */ diff --git a/src/topics/topics.controller.ts b/src/topics/topics.controller.ts index bda11c7d..c5533a4b 100644 --- a/src/topics/topics.controller.ts +++ b/src/topics/topics.controller.ts @@ -21,7 +21,6 @@ import { UseInterceptors, } from '@nestjs/common'; import { AuthService } from '../auth/auth.service'; -import { AuthorizedAction } from '../auth/definitions'; import { PageWithKeywordDto } from '../common/DTO/page.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; import { TokenValidateInterceptor } from '../common/interceptor/token-validate.interceptor'; @@ -29,10 +28,10 @@ import { AddTopicRequestDto, AddTopicResponseDto } from './DTO/add-topic.dto'; import { GetTopicResponseDto } from './DTO/get-topic.dto'; import { SearchTopicResponseDto } from './DTO/search-topic.dto'; import { TopicsService } from './topics.service'; +import { UserId } from '../auth/user-id.decorator'; +import { AuthToken, Guard, ResourceId } from '../auth/guard.decorator'; @Controller('/topics') -@UseFilters(BaseErrorExceptionFilter) -@UseInterceptors(TokenValidateInterceptor) export class TopicsController { constructor( private readonly topicsService: TopicsService, @@ -40,20 +39,15 @@ export class TopicsController { ) {} @Get('/') + @Guard('enumerate', 'topic') async searchTopics( @Query() { q, page_start: pageStart, page_size: pageSize }: PageWithKeywordDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() searcherId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - // try get viewer id - let searcherId: number | undefined; - try { - searcherId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const [topics, pageRespond] = await this.topicsService.searchTopics( q, pageStart, @@ -73,18 +67,12 @@ export class TopicsController { } @Post('/') + @Guard('create', 'topic') async addTopic( @Body() { name }: AddTopicRequestDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - userId, - 'topics', - undefined, - ); const topic = await this.topicsService.addTopic(name, userId); return { code: 201, @@ -96,18 +84,14 @@ export class TopicsController { } @Get('/:id') + @Guard('query', 'topic') async getTopic( - @Param('id', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string, ): Promise { - let userId: number | undefined; - try { - userId = this.authService.verify(auth).userId; - } catch { - /* eslint-disable-line no-empty */ - } const topic = await this.topicsService.getTopicDtoById( id, userId, diff --git a/src/users/users-permission.service.ts b/src/users/users-permission.service.ts index a99f3307..f073e459 100644 --- a/src/users/users-permission.service.ts +++ b/src/users/users-permission.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Authorization, AuthorizedAction } from '../auth/definitions'; +import { Authorization } from '../auth/definitions'; @Injectable() export class UsersPermissionService { @@ -10,134 +10,143 @@ export class UsersPermissionService { userId: userId, permissions: [ { - authorizedActions: [AuthorizedAction.query], + authorizedActions: [ + 'query', + 'follow', + 'unfollow', + 'enumerate-followers', + 'enumerate-answers', + 'enumerate-questions', + 'enumerate-followed-users', + 'enumerate-followed-questions', + ], authorizedResource: { - ownedByUser: userId, - types: undefined, + ownedByUser: undefined, + types: ['user'], resourceIds: undefined, }, }, { - authorizedActions: [AuthorizedAction.modify], + authorizedActions: ['modify-profile'], authorizedResource: { ownedByUser: userId, - types: ['users/profile'], + types: ['user'], resourceIds: undefined, }, }, { - authorizedActions: [AuthorizedAction.create, AuthorizedAction.delete], + authorizedActions: ['query', 'enumerate'], + authorizedResource: { + ownedByUser: undefined, + types: ['question', 'answer', 'comment'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'delete', 'modify'], authorizedResource: { ownedByUser: userId, - types: ['users/following'], + types: ['question', 'answer', 'comment'], resourceIds: undefined, }, }, { - // An user can control the questions he/she created. authorizedActions: [ - AuthorizedAction.create, - AuthorizedAction.delete, - AuthorizedAction.modify, - AuthorizedAction.query, - AuthorizedAction.other, + 'query', + 'query-invitation-recommendations', + 'query-invitation', + 'enumerate', + 'enumerate-answers', + 'enumerate-followers', + 'enumerate-invitations', + 'follow', + 'unfollow', + 'invite', + 'uninvite', ], authorizedResource: { - ownedByUser: userId, - types: ['questions'], + ownedByUser: undefined, + types: ['question'], resourceIds: undefined, }, }, { - authorizedActions: [AuthorizedAction.create, AuthorizedAction.delete], + authorizedActions: ['accept-answer', 'set-bounty'], authorizedResource: { ownedByUser: userId, - types: ['questions/following'], + types: ['question'], resourceIds: undefined, }, }, { - authorizedActions: [AuthorizedAction.create, AuthorizedAction.delete], + authorizedActions: ['query', 'favorite', 'unfavorite'], authorizedResource: { - ownedByUser: userId, - types: ['questions/invitation'], + ownedByUser: undefined, + types: ['answer'], resourceIds: undefined, }, }, { - // Everyone can create a topic. - authorizedActions: [AuthorizedAction.create], + authorizedActions: ['attitude'], authorizedResource: { ownedByUser: undefined, - types: ['topics'], + types: ['comment', 'question', 'answer'], resourceIds: undefined, }, }, { - // An user can control the answer he/she created. - authorizedActions: [ - AuthorizedAction.create, - AuthorizedAction.delete, - AuthorizedAction.modify, - AuthorizedAction.query, - AuthorizedAction.other, - ], + authorizedActions: ['create', 'query'], authorizedResource: { - ownedByUser: userId, - types: ['answer'], + ownedByUser: undefined, + types: ['attachment', 'material'], resourceIds: undefined, }, }, { - // An user can set attitude to any answer - authorizedActions: [AuthorizedAction.other], + authorizedActions: ['query', 'enumerate'], authorizedResource: { ownedByUser: undefined, - types: ['answer/attitude'], + types: ['material-bundle'], resourceIds: undefined, }, }, { - // An user can favourite any answer - authorizedActions: [AuthorizedAction.other], + authorizedActions: ['create', 'modify', 'delete'], authorizedResource: { ownedByUser: undefined, - types: ['answer/favourite'], + types: ['material-bundle'], resourceIds: undefined, }, }, { - // An user can create and delete comment. - authorizedActions: [ - AuthorizedAction.create, - AuthorizedAction.delete, - AuthorizedAction.modify, - ], + authorizedActions: ['create', 'query', 'enumerate'], authorizedResource: { - ownedByUser: userId, - types: ['comment'], + ownedByUser: undefined, + types: ['topic'], resourceIds: undefined, }, }, { - // An user can set attitude to any comment and question - authorizedActions: [AuthorizedAction.other], + authorizedActions: ['create', 'query', 'query-default', 'enumerate'], authorizedResource: { ownedByUser: undefined, - types: [ - 'comment/attitude', - 'questions/attitude', - 'answer/attitude', - ], + types: ['avatar'], resourceIds: undefined, }, }, { - // An user can upload material,attachment or materialbundle - authorizedActions: [AuthorizedAction.create], + authorizedActions: [], authorizedResource: { ownedByUser: undefined, - types: ['material', 'attachment', 'materialbundle'], + types: ['group'], + resourceIds: undefined, + }, + }, + { + authorizedActions: [], + authorizedResource: { + ownedByUser: userId, + types: ['group'], resourceIds: undefined, }, }, diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index c33c1929..c92666a7 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -30,13 +30,19 @@ import { Response } from 'express'; import { AnswerService } from '../answer/answer.service'; import { AuthenticationRequiredError } from '../auth/auth.error'; import { AuthService } from '../auth/auth.service'; -import { AuthorizedAction } from '../auth/definitions'; +import { + AuthToken, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; import { SessionService } from '../auth/session.service'; +import { UserId } from '../auth/user-id.decorator'; import { BaseResponseDto } from '../common/DTO/base-response.dto'; import { PageDto } from '../common/DTO/page.dto'; import { BaseErrorExceptionFilter } from '../common/error/error-filter'; import { - NoTokenValidate, + NoAuth, TokenValidateInterceptor, } from '../common/interceptor/token-validate.interceptor'; import { QuestionsService } from '../questions/questions.service'; @@ -69,8 +75,6 @@ import { import { UsersService } from './users.service'; @Controller('/users') -@UseFilters(BaseErrorExceptionFilter) -@UseInterceptors(TokenValidateInterceptor) export class UsersController { constructor( private readonly usersService: UsersService, @@ -82,8 +86,13 @@ export class UsersController { private readonly questionsService: QuestionsService, ) {} + @ResourceOwnerIdGetter('user') + async getUserOwner(userId: number): Promise { + return userId; + } + @Post('/verify/email') - @NoTokenValidate() + @NoAuth() async sendRegisterEmailCode( @Body() { email }: SendEmailVerifyCodeRequestDto, @Ip() ip: string, @@ -97,7 +106,7 @@ export class UsersController { } @Post('/') - @NoTokenValidate() + @NoAuth() async register( @Body() { username, nickname, password, email, emailCode }: RegisterRequestDto, @@ -144,7 +153,7 @@ export class UsersController { } @Post('/auth/login') - @NoTokenValidate() + @NoAuth() async login( @Body() { username, password }: LoginRequestDto, @Ip() ip: string, @@ -181,7 +190,7 @@ export class UsersController { } @Post('/auth/refresh-token') - @NoTokenValidate() + @NoAuth() async refreshToken( @Headers('cookie') cookieHeader: string, @Res() res: Response, @@ -230,7 +239,7 @@ export class UsersController { } @Post('/auth/logout') - @NoTokenValidate() + @NoAuth() async logout( @Headers('cookie') cookieHeader: string, ): Promise { @@ -253,7 +262,7 @@ export class UsersController { } @Post('/recover/password/request') - @NoTokenValidate() + @NoAuth() async sendResetPasswordEmail( @Body() { email }: ResetPasswordRequestRequestDto, @Ip() ip: string, @@ -267,7 +276,7 @@ export class UsersController { } @Post('/recover/password/verify') - @NoTokenValidate() + @NoAuth() async verifyAndResetPassword( @Body() { token, new_password }: ResetPasswordVerifyRequestDto, @Ip() ip: string, @@ -286,18 +295,14 @@ export class UsersController { } @Get('/:id') + @Guard('query', 'user') async getUser( - @Param('id', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string | undefined, ): Promise { - let viewerId: number | undefined; - try { - viewerId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const user = await this.usersService.getUserDtoById( id, viewerId, @@ -314,18 +319,12 @@ export class UsersController { } @Put('/:id') + @Guard('modify-profile', 'user') async updateUser( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Body() { nickname, intro, avatarId }: UpdateUserRequestDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, ): Promise { - this.authService.audit( - auth, - AuthorizedAction.modify, - id, - 'users/profile', - undefined, - ); await this.usersService.updateUserProfile(id, nickname, intro, avatarId); return { code: 200, @@ -334,18 +333,12 @@ export class UsersController { } @Post('/:id/followers') + @Guard('follow', 'user') async followUser( - @Param('id', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.create, - userId, - 'users/following', - undefined, - ); await this.usersService.addFollowRelationship(userId, id); return { code: 201, @@ -357,18 +350,12 @@ export class UsersController { } @Delete('/:id/followers') + @Guard('unfollow', 'user') async unfollowUser( - @Param('id', ParseIntPipe) id: number, - @Headers('Authorization') auth: string | undefined, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, ): Promise { - const userId = this.authService.verify(auth).userId; - this.authService.audit( - auth, - AuthorizedAction.delete, - userId, - 'users/following', - undefined, - ); await this.usersService.deleteFollowRelationship(userId, id); return { code: 200, @@ -380,22 +367,17 @@ export class UsersController { } @Get('/:id/followers') + @Guard('enumerate-followers', 'user') async getFollowers( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string | undefined, ): Promise { if (pageSize == undefined || pageSize == 0) pageSize = 20; - // try get viewer id - let viewerId: number | undefined; - try { - viewerId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const [followers, page] = await this.usersService.getFollowers( id, pageStart, @@ -415,22 +397,17 @@ export class UsersController { } @Get('/:id/follow/users') + @Guard('enumerate-followed-users', 'user') async getFollowees( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseIntPipe) @ResourceId() id: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string | undefined, ): Promise { if (pageSize == undefined || pageSize == 0) pageSize = 20; - // try get viewer id - let viewerId: number | undefined; - try { - viewerId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const [followees, page] = await this.usersService.getFollowees( id, pageStart, @@ -450,22 +427,17 @@ export class UsersController { } @Get('/:id/questions') + @Guard('enumerate-questions', 'user') async getUserAskedQuestions( - @Param('id', ParseIntPipe) userId: number, + @Param('id', ParseIntPipe) @ResourceId() userId: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string | undefined, ): Promise { if (pageSize == undefined || pageSize == 0) pageSize = 20; - // try get viewer id - let viewerId: number | undefined; - try { - viewerId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const [questions, page] = await this.questionsService.getUserAskedQuestions( userId, pageStart, @@ -485,23 +457,17 @@ export class UsersController { } @Get('/:id/answers') - @UseInterceptors(ClassSerializerInterceptor) + @Guard('enumerate-answers', 'user') async getUserAnsweredAnswers( - @Param('id', ParseIntPipe) userId: number, + @Param('id', ParseIntPipe) @ResourceId() userId: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string | undefined, + @UserId() viewerId: number | undefined, ): Promise { if (pageSize == undefined || pageSize == 0) pageSize = 20; - // try get viewer id - let viewerId: number | undefined; - try { - viewerId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const [answers, page] = await this.answerService.getUserAnsweredAnswersAcrossQuestions( userId, @@ -522,22 +488,17 @@ export class UsersController { } @Get('/:id/follow/questions') + @Guard('enumerate-followed-questions', 'user') async getFollowedQuestions( - @Param('id', ParseIntPipe) userId: number, + @Param('id', ParseIntPipe) @ResourceId() userId: number, @Query() { page_start: pageStart, page_size: pageSize }: PageDto, - @Headers('Authorization') auth: string | undefined, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, @Ip() ip: string, @Headers('User-Agent') userAgent: string | undefined, ): Promise { if (pageSize == undefined || pageSize == 0) pageSize = 20; - // try get viewer id - let viewerId: number | undefined; - try { - viewerId = this.authService.verify(auth).userId; - } catch { - // the user is not logged in - } const [questions, page] = await this.questionsService.getFollowedQuestions( userId, pageStart, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index ff565109..cf19dd27 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -21,7 +21,7 @@ import assert from 'node:assert'; import { AnswerService } from '../answer/answer.service'; import { PermissionDeniedError, TokenExpiredError } from '../auth/auth.error'; import { AuthService } from '../auth/auth.service'; -import { Authorization, AuthorizedAction } from '../auth/definitions'; +import { Authorization } from '../auth/definitions'; import { SessionService } from '../auth/session.service'; import { AvatarNotFoundError } from '../avatars/avatars.error'; import { AvatarsService } from '../avatars/avatars.service'; @@ -482,7 +482,7 @@ export class UsersService { userId: user.id, permissions: [ { - authorizedActions: [AuthorizedAction.modify], + authorizedActions: ['modify'], authorizedResource: { ownedByUser: user.id, types: ['users/password:reset'], @@ -523,7 +523,7 @@ export class UsersService { try { this.authService.audit( token, - AuthorizedAction.modify, + 'modify', userId, 'users/password:reset', undefined, diff --git a/test/answer.e2e-spec.ts b/test/answer.e2e-spec.ts index 9fc75d4c..02d61ea6 100644 --- a/test/answer.e2e-spec.ts +++ b/test/answer.e2e-spec.ts @@ -247,9 +247,9 @@ describe('Answers Module', () => { expect(respond.body.code).toBe(400); }); it('should return updated statistic info when getting user who not log in', async () => { - const respond = await request(app.getHttpServer()).get( - `/users/${auxUserId}`, - ); + const respond = await request(app.getHttpServer()) + .get(`/users/${auxUserId}`) + .set('authorization', 'Bearer ' + TestToken); expect(respond.body.data.user.answer_count).toBe(6); }); it('should return updated statistic info when getting user', async () => { @@ -305,34 +305,34 @@ describe('Answers Module', () => { expect(response.body.data.answer.view_count).toBeDefined(); expect(response.body.data.answer.is_group).toBe(false); }); - it('should get a answer even without token', async () => { - // const TestQuestionId = questionId[0]; - const TestAnswerId = answerIds[0]; - const TestQuestionId = AnswerQuestionMap[TestAnswerId]; - const response = await request(app.getHttpServer()) - .get(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) - .send(); - expect(response.body.message).toBe('Answer fetched successfully.'); - expect(response.status).toBe(200); - expect(response.body.code).toBe(200); - expect(response.body.data.question.id).toBe(TestQuestionId); - expect(response.body.data.question.title).toBeDefined(); - expect(response.body.data.question.content).toBeDefined(); - expect(response.body.data.question.author.id).toBe(TestUserId); - expect(response.body.data.answer.id).toBe(TestAnswerId); - expect(response.body.data.answer.question_id).toBe(TestQuestionId); - expect(response.body.data.answer.content).toContain( - '你说得对,但是原神是一款由米哈游自主研发的开放世界游戏,', - ); - expect(response.body.data.answer.author.id).toBe(auxUserId); - expect(response.body.data.answer.created_at).toBeDefined(); - expect(response.body.data.answer.updated_at).toBeDefined(); - //expect(response.body.data.answer.agree_type).toBe(0); - expect(response.body.data.answer.is_favorite).toBe(false); - //expect(response.body.data.answer.agree_count).toBe(0); - expect(response.body.data.answer.favorite_count).toBe(0); - expect(response.body.data.answer.view_count).toBeDefined(); - }); + // it('should get a answer even without token', async () => { + // // const TestQuestionId = questionId[0]; + // const TestAnswerId = answerIds[0]; + // const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + // const response = await request(app.getHttpServer()) + // .get(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) + // .send(); + // expect(response.body.message).toBe('Answer fetched successfully.'); + // expect(response.status).toBe(200); + // expect(response.body.code).toBe(200); + // expect(response.body.data.question.id).toBe(TestQuestionId); + // expect(response.body.data.question.title).toBeDefined(); + // expect(response.body.data.question.content).toBeDefined(); + // expect(response.body.data.question.author.id).toBe(TestUserId); + // expect(response.body.data.answer.id).toBe(TestAnswerId); + // expect(response.body.data.answer.question_id).toBe(TestQuestionId); + // expect(response.body.data.answer.content).toContain( + // '你说得对,但是原神是一款由米哈游自主研发的开放世界游戏,', + // ); + // expect(response.body.data.answer.author.id).toBe(auxUserId); + // expect(response.body.data.answer.created_at).toBeDefined(); + // expect(response.body.data.answer.updated_at).toBeDefined(); + // //expect(response.body.data.answer.agree_type).toBe(0); + // expect(response.body.data.answer.is_favorite).toBe(false); + // //expect(response.body.data.answer.agree_count).toBe(0); + // expect(response.body.data.answer.favorite_count).toBe(0); + // expect(response.body.data.answer.view_count).toBeDefined(); + // }); it('should return AnswerNotFoundError', async () => { const TestAnswerId = answerIds[0]; @@ -346,6 +346,17 @@ describe('Answers Module', () => { expect(response.status).toBe(404); expect(response.body.code).toBe(404); }); + + it('should return AuthenticationRequiredError', async () => { + const TestAnswerId = answerIds[0]; + const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + const response = await request(app.getHttpServer()) + .get(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) + // .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(response.body.code).toBe(401); + }); }); describe('Get Answers By Question ID', () => { @@ -468,41 +479,50 @@ describe('Answers Module', () => { expect(response.body.data.answers[1].id).toBe(specialAnswerIds[3]); }); - it('should successfully get answers by question ID without token', async () => { - const pageSize = 2; - const response = await request(app.getHttpServer()) - .get(`/questions/${specialQuestionId}/answers`) - .query({ - page_start: specialAnswerIds[2], - page_size: pageSize, - }) - .send(); - - expect(response.body.message).toBe('Answers fetched successfully.'); - - expect(response.status).toBe(200); - expect(response.body.code).toBe(200); - expect(response.body.data.page.page_start).toBe(specialAnswerIds[2]); - expect(response.body.data.page.page_size).toBe(2); - expect(response.body.data.page.has_prev).toBe(true); - expect(response.body.data.page.prev_start).toBe(specialAnswerIds[0]); - expect(response.body.data.page.has_more).toBe(true); - expect(response.body.data.page.next_start).toBe(specialAnswerIds[4]); - expect(response.body.data.answers.length).toBe(2); - expect(response.body.data.answers[0].question_id).toBe(specialQuestionId); - expect(response.body.data.answers[1].question_id).toBe(specialQuestionId); - expect(response.body.data.answers[0].id).toBe(specialAnswerIds[2]); - expect(response.body.data.answers[1].id).toBe(specialAnswerIds[3]); - }); - - it('should return an empty list for a non-existent question ID', async () => { + // it('should successfully get answers by question ID without token', async () => { + // const pageSize = 2; + // const response = await request(app.getHttpServer()) + // .get(`/questions/${specialQuestionId}/answers`) + // .query({ + // page_start: specialAnswerIds[2], + // page_size: pageSize, + // }) + // .send(); + + // expect(response.body.message).toBe('Answers fetched successfully.'); + + // expect(response.status).toBe(200); + // expect(response.body.code).toBe(200); + // expect(response.body.data.page.page_start).toBe(specialAnswerIds[2]); + // expect(response.body.data.page.page_size).toBe(2); + // expect(response.body.data.page.has_prev).toBe(true); + // expect(response.body.data.page.prev_start).toBe(specialAnswerIds[0]); + // expect(response.body.data.page.has_more).toBe(true); + // expect(response.body.data.page.next_start).toBe(specialAnswerIds[4]); + // expect(response.body.data.answers.length).toBe(2); + // expect(response.body.data.answers[0].question_id).toBe(specialQuestionId); + // expect(response.body.data.answers[1].question_id).toBe(specialQuestionId); + // expect(response.body.data.answers[0].id).toBe(specialAnswerIds[2]); + // expect(response.body.data.answers[1].id).toBe(specialAnswerIds[3]); + // }); + + it('should return QuestionNotFoundError for a non-existent question ID', async () => { const nonExistentQuestionId = 99999; const response = await request(app.getHttpServer()) .get(`/questions/${nonExistentQuestionId}/answers`) .set('Authorization', `Bearer ${TestToken}`); - expect(response.body.message).toBe('Answers fetched successfully.'); - expect(response.status).toBe(200); - expect(response.body.code).toBe(200); + expect(response.body.message).toMatch(/QuestionNotFoundError: /); + expect(response.status).toBe(404); + expect(response.body.code).toBe(404); + }); + + it('should return AuthenticationRequiredError', async () => { + const response = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers`) + // .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(response.body.code).toBe(401); }); }); @@ -511,6 +531,7 @@ describe('Answers Module', () => { const noneExistUserId = -1; const respond = await request(app.getHttpServer()) .get(`/users/${noneExistUserId}/answers`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toMatch(/UserIdNotFoundError: /); expect(respond.status).toBe(404); @@ -546,6 +567,7 @@ describe('Answers Module', () => { page_start: auxUserAskedAnswerIds[0], page_size: 2, }) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(response.body.message).toBe('Query asked questions successfully.'); expect(response.status).toBe(200); @@ -567,6 +589,7 @@ describe('Answers Module', () => { page_start: auxUserAskedAnswerIds[2], page_size: 2, }) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(response.body.message).toBe('Query asked questions successfully.'); expect(response.status).toBe(200); @@ -581,6 +604,13 @@ describe('Answers Module', () => { expect(response.body.data.answers[0].id).toBe(auxUserAskedAnswerIds[2]); expect(response.body.data.answers[1].id).toBe(auxUserAskedAnswerIds[3]); }); + it('should return AuthenticationRequiredError', async () => { + const response = await request(app.getHttpServer()) + .get(`/users/${auxUserId}/answers`) + .send(); + expect(response.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(response.body.code).toBe(401); + }); }); describe('Update Answer', () => { @@ -820,19 +850,27 @@ describe('Answers Module', () => { expect(respond.body.data.attitudes.difference).toBe(0); expect(respond.body.data.attitudes.user_attitude).toBe('NEGATIVE'); }); - it('should get answer dto with attitude statics', async () => { + // it('should get answer dto with attitude statics', async () => { + // const respond = await request(app.getHttpServer()) + // .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) + // .send(); + // expect(respond.body.message).toBe('Answer fetched successfully.'); + // expect(respond.body.code).toBe(200); + // expect(respond.statusCode).toBe(200); + // expect(respond.body.data.answer.attitudes.positive_count).toBe(1); + // expect(respond.body.data.answer.attitudes.negative_count).toBe(1); + // expect(respond.body.data.answer.attitudes.difference).toBe(0); + // expect(respond.body.data.answer.attitudes.user_attitude).toBe( + // 'UNDEFINED', + // ); + // }); + it('should return AuthenticationRequiredError', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) .send(); - expect(respond.body.message).toBe('Answer fetched successfully.'); - expect(respond.body.code).toBe(200); - expect(respond.statusCode).toBe(200); - expect(respond.body.data.answer.attitudes.positive_count).toBe(1); - expect(respond.body.data.answer.attitudes.negative_count).toBe(1); - expect(respond.body.data.answer.attitudes.difference).toBe(0); - expect(respond.body.data.answer.attitudes.user_attitude).toBe( - 'UNDEFINED', - ); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + expect(respond.statusCode).toBe(401); }); it('should get answer dto with attitude statics', async () => { const respond = await request(app.getHttpServer()) @@ -897,6 +935,7 @@ describe('Answers Module', () => { it('should get answer dto with attitude statics', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toBe('Answer fetched successfully.'); expect(respond.body.code).toBe(200); diff --git a/test/attachments.e2e-spec.ts b/test/attachments.e2e-spec.ts index adbb243d..8ef3f1af 100644 --- a/test/attachments.e2e-spec.ts +++ b/test/attachments.e2e-spec.ts @@ -150,6 +150,7 @@ describe('Attachment Module', () => { it('should get the uploaded image detail', async () => { const respond = await request(app.getHttpServer()) .get(`/attachments/${ImageId}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.body.data.attachment.meta.height).toEqual(200); @@ -159,6 +160,7 @@ describe('Attachment Module', () => { it('should get the uploaded video detail', async () => { const respond = await request(app.getHttpServer()) .get(`/attachments/${VideoId}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.body.data.attachment.meta.height).toEqual(1080); @@ -172,6 +174,7 @@ describe('Attachment Module', () => { it('should get the uploaded audio detail', async () => { const respond = await request(app.getHttpServer()) .get(`/attachments/${AudioId}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.body.data.attachment.meta.size).toEqual(70699); @@ -181,6 +184,7 @@ describe('Attachment Module', () => { it('should get the uploaded file detail', async () => { const respond = await request(app.getHttpServer()) .get(`/attachments/${FileId}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.body.data.attachment.meta.mime).toBe('application/pdf'); @@ -189,6 +193,7 @@ describe('Attachment Module', () => { it('should return AttachmentNotFoundError', async () => { const respond = await request(app.getHttpServer()) .get(`/attachments/${FileId + 20}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(404); expect(respond.body.code).toBe(404); diff --git a/test/avatars.e2e-spec.ts b/test/avatars.e2e-spec.ts index 63257f8b..5b9db5aa 100644 --- a/test/avatars.e2e-spec.ts +++ b/test/avatars.e2e-spec.ts @@ -3,10 +3,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AvatarType } from '@prisma/client'; import request from 'supertest'; import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; jest.mock('../src/email/email.service'); describe('Avatar Module', () => { let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + let TestToken: string; + let TestUserId: number; let AvatarId: number; beforeAll(async () => { @@ -17,23 +25,74 @@ describe('Avatar Module', () => { app = moduleFixture.createNestApplication(); await app.init(); }, 20000); + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + expect(respond.body.data.user.id).toBeDefined(); + TestUserId = respond.body.data.user.id; + }); + }); + describe('upload avatar', () => { it('should upload an avatar', async () => { const respond = await request(app.getHttpServer()) .post('/avatars') + .set('Authorization', `Bearer ${TestToken}`) .attach('avatar', 'src/resources/avatars/default.jpg'); - //.set('Authorization', `Bearer ${TestToken}`); expect(respond.status).toBe(201); expect(respond.body.message).toBe('Upload avatar successfully'); expect(respond.body.data).toHaveProperty('avatarId'); AvatarId = respond.body.data.avatarId; }); + it('should return AuthenticationRequiredError when no token is provided', async () => { + const respond = await request(app.getHttpServer()) + .post('/avatars') + .attach('avatar', 'src/resources/avatars/default.jpg'); + expect(respond.status).toBe(401); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + }); }); describe('get avatar', () => { it('should get the uploaded avatar', async () => { const avatarId = AvatarId; const respond = await request(app.getHttpServer()) .get(`/avatars/${avatarId}`) + .set('Authorization', `Bearer ${TestToken}`) .send() .responseType('blob'); expect(respond.status).toBe(200); @@ -47,15 +106,24 @@ describe('Avatar Module', () => { it('should return AvatarNotFoundError when an avatar is not found', async () => { const respond = await request(app.getHttpServer()) .get('/avatars/1000') + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toMatch(/^AvatarNotFoundError: /); expect(respond.status).toBe(404); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/avatars/${AvatarId}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + }); }); describe('get default avatar', () => { it('should get default avatar', async () => { const respond = await request(app.getHttpServer()) .get('/avatars/default') + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.headers['cache-control']).toContain('max-age'); @@ -65,11 +133,19 @@ describe('Avatar Module', () => { expect(respond.headers['etag']).toBeDefined(); expect(respond.headers['last-modified']).toBeDefined(); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()).get( + '/avatars/default', + ); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + }); }); describe('get pre available avatarIds', () => { it('should get available avatarIds', async () => { const respond = await request(app.getHttpServer()) .get('/avatars/') + .set('Authorization', `Bearer ${TestToken}`) .query({ type: AvatarType.predefined }) .send(); expect(respond.status).toBe(200); @@ -81,11 +157,20 @@ describe('Avatar Module', () => { it('should return InvalidAvatarTypeError', async () => { const respond = await request(app.getHttpServer()) .get('/avatars/') + .set('Authorization', `Bearer ${TestToken}`) .query({ type: 'yuiiiiiii' }) .send(); expect(respond.status).toBe(400); expect(respond.body.message).toContain('Invalid Avatar type'); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get('/avatars/') + .query({ type: AvatarType.predefined }) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + }); }); afterAll(async () => { diff --git a/test/comment.e2e-spec.ts b/test/comment.e2e-spec.ts index 7ab7bf57..b5102113 100644 --- a/test/comment.e2e-spec.ts +++ b/test/comment.e2e-spec.ts @@ -347,6 +347,13 @@ describe('comments Module', () => { expect(respond.status).toBe(404); expect(respond.body.code).toBe(404); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/${CommentIds[0]}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('AttitudeToComment', () => { @@ -578,6 +585,14 @@ describe('comments Module', () => { 'UNDEFINED', ); }); + it('should return AuthenticationRequiredError', async () => { + const commentId = CommentIds[3]; + const respond = await request(app.getHttpServer()) + .patch(`/comments/${commentId}`) + .send({ content: `${TestCommentPrefix} 我超,宵宫!` }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); it('should get comment by id', async () => { const respond = await request(app.getHttpServer()) .get(`/comments/${CommentIds[3]}`) diff --git a/test/groups.e2e-spec.ts b/test/groups.e2e-spec.ts index f06cc859..95c483e2 100644 --- a/test/groups.e2e-spec.ts +++ b/test/groups.e2e-spec.ts @@ -85,20 +85,6 @@ describe('Groups Module', () => { }); describe('preparation', () => { - it('should upload two avatars for creating and updating', async () => { - async function uploadAvatar() { - const respond = await request(app.getHttpServer()) - .post('/avatars') - //.set('Authorization', `Bearer ${TestToken}`) - .attach('avatar', 'src/resources/avatars/default.jpg'); - expect(respond.status).toBe(201); - expect(respond.body.message).toBe('Upload avatar successfully'); - expect(respond.body.data).toHaveProperty('avatarId'); - return respond.body.data.avatarId; - } - PreAvatarId = await uploadAvatar(); - UpdateAvatarId = await uploadAvatar(); - }); it(`should send an email and register a user ${TestUsername}`, async () => { const respond1 = await request(app.getHttpServer()) .post('/users/verify/email') @@ -140,6 +126,21 @@ describe('Groups Module', () => { TestUserDto = respond.body.data.user; }); + it('should upload two avatars for creating and updating', async () => { + async function uploadAvatar() { + const respond = await request(app.getHttpServer()) + .post('/avatars') + .set('Authorization', `Bearer ${TestToken}`) + .attach('avatar', 'src/resources/avatars/default.jpg'); + expect(respond.status).toBe(201); + expect(respond.body.message).toBe('Upload avatar successfully'); + expect(respond.body.data).toHaveProperty('avatarId'); + return respond.body.data.avatarId; + } + PreAvatarId = await uploadAvatar(); + UpdateAvatarId = await uploadAvatar(); + }); + it('should create some groups', async () => { async function createGroup(name: string, intro: string) { const respond = await request(app.getHttpServer()) diff --git a/test/materialbundle.e2e-spec.ts b/test/materialbundle.e2e-spec.ts index c3da9dbb..f6e98abd 100644 --- a/test/materialbundle.e2e-spec.ts +++ b/test/materialbundle.e2e-spec.ts @@ -189,18 +189,30 @@ describe('MaterialBundle Module', () => { expect(respond.body.code).toBe(404); expect(respond.body.message).toMatch(/^MaterialNotFoundError: /); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post('/material-bundles') + .send({ + title: 'a materialbundle', + content: 'content about materialbundle', + materials: [ImageId, VideoId, FileId], + }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('get materialbundles', () => { it('should get all of the materialbundles', async () => { const respond = await request(app.getHttpServer()) .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) .query({ q: '', sort: '', }); + expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.status).toBe(200); expect(respond.body.code).toBe(200); - expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.body.data.materials.length).toEqual(20); expect(respond.body.data.materials[0].id).toEqual(1); expect(respond.body.data.page.page_size).toBe(20); @@ -212,13 +224,14 @@ describe('MaterialBundle Module', () => { it('should get the materialbundles with keyword without size and start', async () => { const respond = await request(app.getHttpServer()) .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) .query({ q: unique.toString(), sort: '', }); + expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.status).toBe(200); expect(respond.body.code).toBe(200); - expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.body.data.materials.length).toEqual(20); respond.body.data.materials .slice(0, 20) @@ -234,14 +247,15 @@ describe('MaterialBundle Module', () => { it('should get the materialbundles with keyword and size without start', async () => { const respond = await request(app.getHttpServer()) .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) .query({ q: unique.toString(), page_size: 10, sort: '', }); + expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.status).toBe(200); expect(respond.body.code).toBe(200); - expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.body.data.materials.length).toEqual(10); respond.body.data.materials .slice(0, 10) @@ -257,15 +271,16 @@ describe('MaterialBundle Module', () => { it('should get the materialbundles with keyword,size and start', async () => { const respond = await request(app.getHttpServer()) .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) .query({ q: unique.toString(), page_size: 10, page_start: bundleIds[4], sort: '', }); + expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.status).toBe(200); expect(respond.body.code).toBe(200); - expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.body.data.materials.length).toEqual(10); respond.body.data.materials .slice(0, 10) @@ -281,14 +296,15 @@ describe('MaterialBundle Module', () => { it('should get the materialbundles with keyword,size and search syntax string as start', async () => { const respond = await request(app.getHttpServer()) .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) .query({ q: `title:${unique.toString()} id:>=${bundleIds[4]}`, page_size: 10, sort: '', }); + expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.status).toBe(200); expect(respond.body.code).toBe(200); - expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.body.data.materials.length).toEqual(10); respond.body.data.materials .slice(0, 10) @@ -304,15 +320,16 @@ describe('MaterialBundle Module', () => { it('should get the materialbundles with keyword,size,start and sort', async () => { const respond = await request(app.getHttpServer()) .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) .query({ q: unique.toString(), page_size: 10, page_start: bundleIds[14], sort: 'newest', }); + expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.status).toBe(200); expect(respond.body.code).toBe(200); - expect(respond.body.message).toBe('get material bundles successfully'); expect(respond.body.data.materials.length).toEqual(10); respond.body.data.materials .slice(0, 10) @@ -328,6 +345,7 @@ describe('MaterialBundle Module', () => { it('should return KeywordTooLongError', async () => { const respond = await request(app.getHttpServer()) .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) .query({ q: 'yui'.repeat(100), page_size: 10, @@ -341,6 +359,7 @@ describe('MaterialBundle Module', () => { it('should get the materialbundle detail', async () => { const respond = await request(app.getHttpServer()) .get(`/material-bundles/${bundleId1}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.body.message).toBe( @@ -361,11 +380,19 @@ describe('MaterialBundle Module', () => { it('should return BundleNotFoundError', async () => { const respond = await request(app.getHttpServer()) .get(`/material-bundles/${bundleId1 + 30}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(404); expect(respond.body.code).toBe(404); expect(respond.body.message).toMatch(/^BundleNotFoundError: /); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles/${bundleId1}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('update materialbundle', () => { it('should update the materialbundle', async () => { @@ -381,6 +408,7 @@ describe('MaterialBundle Module', () => { expect(respond1.body.message).toBe('Materialbundle updated successfully'); const respond2 = await request(app.getHttpServer()) .get(`/material-bundles/${bundleId2}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond2.body.data.materialBundle.title).toBe('new title'); expect(respond2.body.data.materialBundle.content).toBe('new content'); @@ -408,6 +436,13 @@ describe('MaterialBundle Module', () => { expect(respond.body.code).toBe(403); expect(respond.body.message).toMatch(/^UpdateBundleDeniedError: /); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .patch(`/material-bundles/${bundleId1}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('delete materialbundle', () => { it('should return AuthenticationRequiredError', async () => { @@ -443,6 +478,7 @@ describe('MaterialBundle Module', () => { //expect(respond.status).toBe(200); const respond2 = await request(app.getHttpServer()) .get(`/material-bundles/${bundleId2}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond2.body.message).toMatch(/^BundleNotFoundError: /); expect(respond2.body.code).toBe(404); diff --git a/test/materials.e2e-spec.ts b/test/materials.e2e-spec.ts index 15a01ba2..16563d89 100644 --- a/test/materials.e2e-spec.ts +++ b/test/materials.e2e-spec.ts @@ -145,11 +145,21 @@ describe('Material Module', () => { expect(respond.body.code).toBe(422); expect(respond.body.message).toMatch(/MimeTypeNotMatchError: /); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post('/materials') + .field('type', 'image') + .attach('file', 'src/materials/resources/test.jpg'); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + expect(respond.body.message).toMatch(/AuthenticationRequiredError: /); + }); }); describe('get material', () => { it('should get the uploaded image detail', async () => { const respond = await request(app.getHttpServer()) .get(`/materials/${ImageId}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.body.data.material.meta.height).toEqual(200); @@ -162,6 +172,7 @@ describe('Material Module', () => { it('should get the uploaded video detail', async () => { const respond = await request(app.getHttpServer()) .get(`/materials/${VideoId}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.body.data.material.meta.height).toEqual(1080); @@ -178,6 +189,7 @@ describe('Material Module', () => { it('should get the uploaded audio detail', async () => { const respond = await request(app.getHttpServer()) .get(`/materials/${AudioId}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.body.data.material.meta.size).toEqual(70699); @@ -190,6 +202,7 @@ describe('Material Module', () => { it('should get the uploaded file detail', async () => { const respond = await request(app.getHttpServer()) .get(`/materials/${FileId}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(200); expect(respond.body.data.material.meta.mime).toBe('application/pdf'); @@ -201,11 +214,20 @@ describe('Material Module', () => { it('should return MaterialNotFoundError', async () => { const respond = await request(app.getHttpServer()) .get(`/materials/${FileId + 20}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.status).toBe(404); expect(respond.body.code).toBe(404); expect(respond.body.message).toMatch(/MaterialNotFoundError: /); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/materials/${FileId}`) + .send(); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + expect(respond.body.message).toMatch(/AuthenticationRequiredError: /); + }); }); afterAll(async () => { await app.close(); diff --git a/test/question.e2e-spec.ts b/test/question.e2e-spec.ts index 031b294d..ee445149 100644 --- a/test/question.e2e-spec.ts +++ b/test/question.e2e-spec.ts @@ -3,6 +3,8 @@ * * Author(s): * Nictheboy Li + * Andy Lee + * HuanCheng65 * */ @@ -177,9 +179,9 @@ describe('Questions Module', () => { await createQuestion('long question', '啊'.repeat(30000)); }, 60000); it('should return updated statistic info when getting user', async () => { - const respond = await request(app.getHttpServer()).get( - `/users/${TestUserId}`, - ); + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}`) + .set('authorization', 'Bearer ' + TestToken); expect(respond.body.data.user.question_count).toBe(6); }); it('should return updated statistic info when getting user', async () => { @@ -257,44 +259,44 @@ describe('Questions Module', () => { expect(respond.body.data.question.view_count).toBe(0); expect(respond.body.data.question.group).toBe(null); }, 20000); - it('should get a question without token', async () => { - const respond = await request(app.getHttpServer()) - .get(`/questions/${questionIds[0]}`) - .send(); - expect(respond.body.message).toBe('OK'); - expect(respond.body.code).toBe(200); - expect(respond.status).toBe(200); - expect(respond.body.data.question.id).toBe(questionIds[0]); - expect(respond.body.data.question.title).toContain(TestQuestionPrefix); - expect(respond.body.data.question.content).toBe( - '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', - ); - expect(respond.body.data.question.author.id).toBe(TestUserId); - expect(respond.body.data.question.author.username).toBe(TestUsername); - expect(respond.body.data.question.author.nickname).toBe('test_user'); - expect(respond.body.data.question.type).toBe(0); - expect(respond.body.data.question.topics.length).toBe(2); - expect(respond.body.data.question.topics[0].name).toContain( - TestTopicPrefix, - ); - expect(respond.body.data.question.topics[1].name).toContain( - TestTopicPrefix, - ); - expect(respond.body.data.question.created_at).toBeDefined(); - expect(respond.body.data.question.updated_at).toBeDefined(); - expect(respond.body.data.question.attitudes.positive_count).toBe(0); - expect(respond.body.data.question.attitudes.negative_count).toBe(0); - expect(respond.body.data.question.attitudes.difference).toBe(0); - expect(respond.body.data.question.attitudes.user_attitude).toBe( - 'UNDEFINED', - ); - expect(respond.body.data.question.is_follow).toBe(false); - expect(respond.body.data.question.answer_count).toBe(0); - expect(respond.body.data.question.view_count).toBe(1); - expect(respond.body.data.question.follow_count).toBe(0); - expect(respond.body.data.question.comment_count).toBe(0); - expect(respond.body.data.question.group).toBe(null); - }, 20000); + // it('should get a question without token', async () => { + // const respond = await request(app.getHttpServer()) + // .get(`/questions/${questionIds[0]}`) + // .send(); + // expect(respond.body.message).toBe('OK'); + // expect(respond.body.code).toBe(200); + // expect(respond.status).toBe(200); + // expect(respond.body.data.question.id).toBe(questionIds[0]); + // expect(respond.body.data.question.title).toContain(TestQuestionPrefix); + // expect(respond.body.data.question.content).toBe( + // '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + // ); + // expect(respond.body.data.question.author.id).toBe(TestUserId); + // expect(respond.body.data.question.author.username).toBe(TestUsername); + // expect(respond.body.data.question.author.nickname).toBe('test_user'); + // expect(respond.body.data.question.type).toBe(0); + // expect(respond.body.data.question.topics.length).toBe(2); + // expect(respond.body.data.question.topics[0].name).toContain( + // TestTopicPrefix, + // ); + // expect(respond.body.data.question.topics[1].name).toContain( + // TestTopicPrefix, + // ); + // expect(respond.body.data.question.created_at).toBeDefined(); + // expect(respond.body.data.question.updated_at).toBeDefined(); + // expect(respond.body.data.question.attitudes.positive_count).toBe(0); + // expect(respond.body.data.question.attitudes.negative_count).toBe(0); + // expect(respond.body.data.question.attitudes.difference).toBe(0); + // expect(respond.body.data.question.attitudes.user_attitude).toBe( + // 'UNDEFINED', + // ); + // expect(respond.body.data.question.is_follow).toBe(false); + // expect(respond.body.data.question.answer_count).toBe(0); + // expect(respond.body.data.question.view_count).toBe(1); + // expect(respond.body.data.question.follow_count).toBe(0); + // expect(respond.body.data.question.comment_count).toBe(0); + // expect(respond.body.data.question.group).toBe(null); + // }, 20000); it('should return QuestionNotFoundError', async () => { const respond = await request(app.getHttpServer()) .get('/questions/-1') @@ -303,6 +305,13 @@ describe('Questions Module', () => { expect(respond.body.message).toMatch(/^QuestionNotFoundError: /); expect(respond.body.code).toBe(404); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[0]}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('get questions asked by user', () => { @@ -310,6 +319,7 @@ describe('Questions Module', () => { const noneExistUserId = -1; const respond = await request(app.getHttpServer()) .get(`/users/${noneExistUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toMatch(/^UserIdNotFoundError: /); expect(respond.body.code).toBe(404); @@ -318,6 +328,7 @@ describe('Questions Module', () => { it('should get all the questions asked by the user', async () => { const respond = await request(app.getHttpServer()) .get(`/users/${TestUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toBe('Query asked questions successfully.'); expect(respond.body.code).toBe(200); @@ -344,6 +355,7 @@ describe('Questions Module', () => { it('should get all the questions asked by the user', async () => { const respond = await request(app.getHttpServer()) .get(`/users/${TestUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) .query({ page_start: questionIds[1], page_size: 1000, @@ -366,6 +378,7 @@ describe('Questions Module', () => { it('should get paged questions asked by the user', async () => { const respond = await request(app.getHttpServer()) .get(`/users/${TestUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) .query({ page_start: questionIds[0], page_size: 2, @@ -394,6 +407,7 @@ describe('Questions Module', () => { it('should get paged questions asked by the user', async () => { const respond = await request(app.getHttpServer()) .get(`/users/${TestUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) .query({ page_start: questionIds[2], page_size: 2, @@ -412,6 +426,13 @@ describe('Questions Module', () => { expect(respond.body.data.questions[0].id).toBe(questionIds[2]); expect(respond.body.data.questions[1].id).toBe(questionIds[3]); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}/questions`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('search question', () => { @@ -421,6 +442,7 @@ describe('Questions Module', () => { it('should return empty page without parameters', async () => { const respond = await request(app.getHttpServer()) .get('/questions') + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toBe('OK'); expect(respond.body.code).toBe(200); @@ -436,6 +458,7 @@ describe('Questions Module', () => { it('should return empty page without page_size and page_start', async () => { const respond = await request(app.getHttpServer()) .get(`/questions?q=${TestQuestionCode}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toBe('OK'); expect(respond.body.code).toBe(200); @@ -451,6 +474,7 @@ describe('Questions Module', () => { it('should search successfully with page_size, with or without page_start', async () => { const respond = await request(app.getHttpServer()) .get(`/questions?q=${TestQuestionCode}&page_size=1`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toBe('OK'); expect(respond.body.code).toBe(200); @@ -464,6 +488,7 @@ describe('Questions Module', () => { const next = respond.body.data.page.next_start; const respond2 = await request(app.getHttpServer()) .get(`/questions?q=${TestQuestionCode}&page_size=1&page_start=${next}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond2.body.message).toBe('OK'); expect(respond2.body.code).toBe(200); @@ -480,11 +505,19 @@ describe('Questions Module', () => { it('should return QuestionNotFoundError', async () => { const respond = await request(app.getHttpServer()) .get(`/questions?q=${TestQuestionPrefix}&page_size=5&page_start=-1`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toMatch(/^QuestionNotFoundError: /); expect(respond.body.code).toBe(404); expect(respond.status).toBe(404); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions?q=${TestQuestionCode}&page_size=1`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('update question', () => { @@ -504,6 +537,7 @@ describe('Questions Module', () => { expect(respond.status).toBe(200); const respond2 = await request(app.getHttpServer()) .get(`/questions/${questionIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond2.body.message).toBe('OK'); expect(respond2.body.code).toBe(200); @@ -594,6 +628,7 @@ describe('Questions Module', () => { expect(respond.status).toBe(200); const respond2 = await request(app.getHttpServer()) .get(`/questions/${questionIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond2.body.message).toMatch(/^QuestionNotFoundError: /); expect(respond2.body.code).toBe(404); @@ -654,6 +689,7 @@ describe('Questions Module', () => { it('should get followed questions', async () => { const respond = await request(app.getHttpServer()) .get(`/users/${TestUserId}/follow/questions`) + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond.body.message).toBe( 'Query followed questions successfully.', @@ -698,6 +734,7 @@ describe('Questions Module', () => { page_start: questionIds[2], page_size: 1000, }) + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond.body.message).toBe( 'Query followed questions successfully.', @@ -722,6 +759,7 @@ describe('Questions Module', () => { page_start: questionIds[2], page_size: 1, }) + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond.body.message).toBe( 'Query followed questions successfully.', @@ -757,6 +795,7 @@ describe('Questions Module', () => { it('should get follower list', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}/followers`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toBe('OK'); expect(respond.body.code).toBe(200); @@ -770,6 +809,7 @@ describe('Questions Module', () => { const respond2 = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}/followers?page_size=1`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond2.body.message).toBe('OK'); expect(respond2.body.code).toBe(200); @@ -788,6 +828,7 @@ describe('Questions Module', () => { .get( `/questions/${questionIds[1]}/followers?page_size=1&page_start=${TestUserId}`, ) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond3.body).toStrictEqual(respond2.body); @@ -795,6 +836,7 @@ describe('Questions Module', () => { .get( `/questions/${questionIds[1]}/followers?page_size=1&page_start=${auxUserId}`, ) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond4.body.message).toBe('OK'); expect(respond4.body.code).toBe(200); @@ -828,6 +870,13 @@ describe('Questions Module', () => { expect(respond.body.message).toMatch(/^QuestionNotFollowedYetError: /); expect(respond.body.code).toBe(400); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[1]}/followers`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('attitude', () => { @@ -877,6 +926,7 @@ describe('Questions Module', () => { it('should get modified question attitude statistic', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toBe('OK'); expect(respond.body.code).toBe(200); @@ -885,7 +935,7 @@ describe('Questions Module', () => { expect(respond.body.data.question.attitudes.negative_count).toBe(1); expect(respond.body.data.question.attitudes.difference).toBe(0); expect(respond.body.data.question.attitudes.user_attitude).toBe( - 'UNDEFINED', + 'POSITIVE', ); }); it('should get modified question attitude statistic', async () => { @@ -955,6 +1005,7 @@ describe('Questions Module', () => { it('should get modified question attitude statistic', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toBe('OK'); expect(respond.body.code).toBe(200); @@ -1093,6 +1144,7 @@ describe('Questions Module', () => { it('should get invitations', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) .send(); expect(respond.body.message).toBe('OK'); expect(respond.body.code).toBe(200); @@ -1114,6 +1166,7 @@ describe('Questions Module', () => { it('should get invitations', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) .query({ sort: '+created_at', }) @@ -1135,6 +1188,7 @@ describe('Questions Module', () => { it('should get invitations', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) .query({ sort: '+created_at', page_size: 1, @@ -1153,6 +1207,7 @@ describe('Questions Module', () => { const next = respond.body.data.page.next_start; const respond2 = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) .query({ sort: '+created_at', page_start: next, @@ -1175,6 +1230,7 @@ describe('Questions Module', () => { it('should get invitations', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) .query({ sort: '-created_at', }) @@ -1196,6 +1252,7 @@ describe('Questions Module', () => { it('should get invitations', async () => { const respond = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) .query({ sort: '-created_at', page_size: 1, @@ -1214,6 +1271,7 @@ describe('Questions Module', () => { const next = respond.body.data.page.next_start; const respond2 = await request(app.getHttpServer()) .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) .query({ sort: '-created_at', page_start: next, @@ -1330,6 +1388,13 @@ describe('Questions Module', () => { expect(respond.body.code).toBe(400); expect(respond.status).toBe(400); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations/${invitationIds[1]}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('get recommendation function test', () => { @@ -1351,6 +1416,13 @@ describe('Questions Module', () => { expect(respond.status).toBe(404); expect(respond.body.code).toBe(404); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations/recommendations`) + .query({ page_size: 5 }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); }); describe('Bounty test', () => { diff --git a/test/topic.e2e-spec.ts b/test/topic.e2e-spec.ts index 088feeed..545dd282 100644 --- a/test/topic.e2e-spec.ts +++ b/test/topic.e2e-spec.ts @@ -160,6 +160,7 @@ describe('Topic Module', () => { it('should return empty page without parameters', async () => { const respond = await request(app.getHttpServer()) .get('/topics') + .set('authorization', 'Bearer ' + TestToken) .query({ q: '', }) @@ -179,6 +180,7 @@ describe('Topic Module', () => { // Try search: `${TestTopicCode} 高等` const respond = await request(app.getHttpServer()) .get(`/topics`) + .set('authorization', 'Bearer ' + TestToken) .query({ q: `${TestTopicCode} 高等`, }) @@ -216,6 +218,7 @@ describe('Topic Module', () => { const respond3 = await request(app.getHttpServer()) .get(`/topics`) + .set('authorization', 'Bearer ' + TestToken) .query({ q: `${TestTopicCode} 高等`, page_size: 3, @@ -255,6 +258,7 @@ describe('Topic Module', () => { // Try emoji search to see if unicode storage works. const respond5 = await request(app.getHttpServer()) .get(`/topics`) + .set('authorization', 'Bearer ' + TestToken) .query({ q: `${TestTopicCode} 🧑‍🦲`, page_size: 3, @@ -271,26 +275,26 @@ describe('Topic Module', () => { `${TestTopicPrefix} Emojis in the topic name 🧑‍🦲 with some 中文 in it`, ); }, 60000); - it('should return an empty page', () => { - return request(app.getHttpServer()) + it('should return an empty page', async () => { + const respond = await request(app.getHttpServer()) .get('/topics?q=%E6%AF%B3%E6%AF%B3%E6%AF%B3%E6%AF%B3') - .send() - .then((respond) => { - expect(respond.body.message).toBe('OK'); - expect(respond.status).toBe(200); - expect(respond.body.code).toBe(200); - expect(respond.body.data.topics.length).toBe(0); - expect(respond.body.data.page.page_start).toBe(0); - expect(respond.body.data.page.page_size).toBe(0); - expect(respond.body.data.page.has_prev).toBe(false); - expect(respond.body.data.page.prev_start).toBe(0); - expect(respond.body.data.page.has_more).toBe(false); - expect(respond.body.data.page.next_start).toBe(0); - }); + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.topics.length).toBe(0); + expect(respond.body.data.page.page_start).toBe(0); + expect(respond.body.data.page.page_size).toBe(0); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); }); it('should return TopicNotFoundError', async () => { const respond = await request(app.getHttpServer()) .get('/topics?q=something&page_start=-1') + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond.body.message).toMatch(/^TopicNotFoundError: /); expect(respond.status).toBe(404); @@ -298,16 +302,25 @@ describe('Topic Module', () => { it('should return BadRequestException', async () => { const respond = await request(app.getHttpServer()) .get('/topics?q=something&page_start=abc') + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond.body.message).toMatch(/^BadRequestException: /); expect(respond.status).toBe(400); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics?q=something') + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + }); }); describe('get topic', () => { it('should get a topic', async () => { const respond = await request(app.getHttpServer()) .get(`/topics/${TopicIds[0]}`) + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond.body.message).toBe('OK'); expect(respond.status).toBe(200); @@ -318,6 +331,7 @@ describe('Topic Module', () => { it('should return TopicNotFoundError', async () => { const respond = await request(app.getHttpServer()) .get('/topics/-1') + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond.body.message).toMatch(/^TopicNotFoundError: /); expect(respond.status).toBe(404); @@ -325,10 +339,18 @@ describe('Topic Module', () => { it('should return BadRequestException', async () => { const respond = await request(app.getHttpServer()) .get('/topics/abc') + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond.body.message).toMatch(/^BadRequestException: /); expect(respond.status).toBe(400); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics/1') + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + }); }); afterAll(async () => { diff --git a/test/user.follow.e2e-spec.ts b/test/user.follow.e2e-spec.ts index bbfe86ae..f4c6f3eb 100644 --- a/test/user.follow.e2e-spec.ts +++ b/test/user.follow.e2e-spec.ts @@ -181,9 +181,9 @@ describe('Following Submodule of User Module', () => { }); it('should return updated statistic info when getting user', async () => { - const respond = await request(app.getHttpServer()).get( - `/users/${TestUserId}`, - ); + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}`) + .set('authorization', 'Bearer ' + TestToken); expect(respond.body.data.user.follow_count).toBe(tempUserIds.length); expect(respond.body.data.user.fans_count).toBe(tempUserIds.length); expect(respond.body.data.user.is_follow).toBe(false); @@ -279,7 +279,7 @@ describe('Following Submodule of User Module', () => { const respond3 = await request(app.getHttpServer()) .get(`/users/${TestUserId}/followers`) //.set('User-Agent', 'PostmanRuntime/7.26.8') - //.set('authorization', 'Bearer ' + TestToken); + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond3.body.message).toBe('Query followers successfully.'); expect(respond3.status).toBe(200); @@ -299,7 +299,7 @@ describe('Following Submodule of User Module', () => { `/users/${TestUserId}/followers?page_start=${tempUserIds[3]}&page_size=3`, ) //.set('User-Agent', 'PostmanRuntime/7.26.8') - //.set('authorization', 'Bearer ' + TestToken); + .set('authorization', 'Bearer ' + TestToken) .send(); expect(respond4.body.message).toBe('Query followers successfully.'); expect(respond4.status).toBe(200); @@ -372,14 +372,14 @@ describe('Following Submodule of User Module', () => { }); describe('statistics', () => { - it('should return updated statistic info when getting user', async () => { - const respond = await request(app.getHttpServer()).get( - `/users/${TestUserId}`, - ); - expect(respond.body.data.user.follow_count).toBe(tempUserIds.length); - expect(respond.body.data.user.fans_count).toBe(tempUserIds.length + 1); - expect(respond.body.data.user.is_follow).toBe(false); - }); + // it('should return updated statistic info when getting user', async () => { + // const respond = await request(app.getHttpServer()).get( + // `/users/${TestUserId}`, + // ); + // expect(respond.body.data.user.follow_count).toBe(tempUserIds.length); + // expect(respond.body.data.user.fans_count).toBe(tempUserIds.length + 1); + // expect(respond.body.data.user.is_follow).toBe(false); + // }); it('should return updated statistic info when getting user', async () => { const respond = await request(app.getHttpServer()) @@ -399,14 +399,14 @@ describe('Following Submodule of User Module', () => { expect(respond.body.data.user.is_follow).toBe(true); }); - it('should return updated statistic info when getting user', async () => { - const respond = await request(app.getHttpServer()).get( - `/users/${tempUserIds[0]}`, - ); - expect(respond.body.data.user.follow_count).toBe(1); - expect(respond.body.data.user.fans_count).toBe(1); - expect(respond.body.data.user.is_follow).toBe(false); - }); + // it('should return updated statistic info when getting user', async () => { + // const respond = await request(app.getHttpServer()).get( + // `/users/${tempUserIds[0]}`, + // ); + // expect(respond.body.data.user.follow_count).toBe(1); + // expect(respond.body.data.user.fans_count).toBe(1); + // expect(respond.body.data.user.is_follow).toBe(false); + // }); it('should return updated statistic info when getting user', async () => { const respond = await request(app.getHttpServer()) @@ -416,6 +416,15 @@ describe('Following Submodule of User Module', () => { expect(respond.body.data.user.fans_count).toBe(1); expect(respond.body.data.user.is_follow).toBe(true); }); + + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${tempUserIds[0]}/followers`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + }); }); afterAll(async () => { diff --git a/test/user.profile.e2e-spec.ts b/test/user.profile.e2e-spec.ts index 77138ceb..dfc79518 100644 --- a/test/user.profile.e2e-spec.ts +++ b/test/user.profile.e2e-spec.ts @@ -48,19 +48,6 @@ describe('Profile Submodule of User Module', () => { }); describe('preparation', () => { - it('should upload an avatar for updating', async () => { - async function uploadAvatar() { - const respond = await request(app.getHttpServer()) - .post('/avatars') - //.set('Authorization', `Bearer ${TestToken}`) - .attach('avatar', 'src/resources/avatars/default.jpg'); - expect(respond.status).toBe(201); - expect(respond.body.message).toBe('Upload avatar successfully'); - expect(respond.body.data).toHaveProperty('avatarId'); - return respond.body.data.avatarId; - } - UpdateAvatarId = await uploadAvatar(); - }); it(`should send an email and register a user ${TestUsername}`, async () => { const respond1 = await request(app.getHttpServer()) .post('/users/verify/email') @@ -102,6 +89,20 @@ describe('Profile Submodule of User Module', () => { }); }); + it('should upload an avatar for updating', async () => { + async function uploadAvatar() { + const respond = await request(app.getHttpServer()) + .post('/avatars') + .set('Authorization', `Bearer ${TestToken}`) + .attach('avatar', 'src/resources/avatars/default.jpg'); + expect(respond.status).toBe(201); + expect(respond.body.message).toBe('Upload avatar successfully'); + expect(respond.body.data).toHaveProperty('avatarId'); + return respond.body.data.avatarId; + } + UpdateAvatarId = await uploadAvatar(); + }); + describe('update user profile', () => { it('should update user profile', async () => { const respond = await request(app.getHttpServer()) @@ -165,31 +166,41 @@ describe('Profile Submodule of User Module', () => { expect(respond.body.data.user.answer_count).toBe(0); expect(respond.body.data.user.is_follow).toBe(false); }); - it('should get modified user profile even without a token', async () => { - const respond = await request(app.getHttpServer()).get( - `/users/${TestUserId}`, - ); - //.set('User-Agent', 'PostmanRuntime/7.26.8') - //.set('authorization', 'Bearer ' + TestToken); - expect(respond.body.message).toBe('Query user successfully.'); - expect(respond.status).toBe(200); - expect(respond.body.code).toBe(200); - expect(respond.body.data.user.username).toBe(TestUsername); - expect(respond.body.data.user.nickname).toBe('test_user_updated'); - expect(respond.body.data.user.avatarId).toBe(UpdateAvatarId); - expect(respond.body.data.user.intro).toBe('test user updated'); - expect(respond.body.data.user.follow_count).toBe(0); - expect(respond.body.data.user.fans_count).toBe(0); - expect(respond.body.data.user.question_count).toBe(0); - expect(respond.body.data.user.answer_count).toBe(0); - expect(respond.body.data.user.is_follow).toBe(false); - }); + // it('should get modified user profile even without a token', async () => { + // const respond = await request(app.getHttpServer()).get( + // `/users/${TestUserId}`, + // ); + // //.set('User-Agent', 'PostmanRuntime/7.26.8') + // //.set('authorization', 'Bearer ' + TestToken); + // expect(respond.body.message).toBe('Query user successfully.'); + // expect(respond.status).toBe(200); + // expect(respond.body.code).toBe(200); + // expect(respond.body.data.user.username).toBe(TestUsername); + // expect(respond.body.data.user.nickname).toBe('test_user_updated'); + // expect(respond.body.data.user.avatarId).toBe(UpdateAvatarId); + // expect(respond.body.data.user.intro).toBe('test user updated'); + // expect(respond.body.data.user.follow_count).toBe(0); + // expect(respond.body.data.user.fans_count).toBe(0); + // expect(respond.body.data.user.question_count).toBe(0); + // expect(respond.body.data.user.answer_count).toBe(0); + // expect(respond.body.data.user.is_follow).toBe(false); + // }); it('should return UserIdNotFoundError', async () => { - const respond = await request(app.getHttpServer()).get(`/users/-1`); + const respond = await request(app.getHttpServer()) + .get(`/users/-1`) + .set('authorization', 'Bearer ' + TestToken); expect(respond.body.message).toMatch(/^UserIdNotFoundError: /); expect(respond.status).toBe(404); expect(respond.body.code).toBe(404); }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()).get( + `/users/${TestUserId}`, + ); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + }); }); afterAll(async () => { From bbbd517b40f538c3572c6f8b9fed516e7e9a65cf Mon Sep 17 00:00:00 2001 From: nictheboy Date: Tue, 6 Aug 2024 18:42:52 +0800 Subject: [PATCH 04/12] feat(auth): add custom auth logic --- src/auth/auth.service.ts | 18 ++++- src/auth/auth.spec.ts | 112 ++++++++++++++++++++++++----- src/auth/custom-auth-logic.ts | 30 ++++++++ src/auth/definitions.ts | 1 + src/auth/guard.decorator.ts | 2 +- src/auth/session.service.ts | 4 +- src/auth/token-payload.schema.json | 3 + src/users/users.service.ts | 2 +- 8 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 src/auth/custom-auth-logic.ts diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 4f33ce2a..02f8abbf 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -19,10 +19,12 @@ import { TokenExpiredError, } from './auth.error'; import { Authorization, AuthorizedAction, TokenPayload } from './definitions'; +import { CustomAuthLogics } from './custom-auth-logic'; @Injectable() export class AuthService { public static instance: AuthService; + public customAuthLogics: CustomAuthLogics = new CustomAuthLogics(); constructor(private readonly jwtService: JwtService) { AuthService.instance = this; @@ -98,13 +100,13 @@ export class AuthService { // no owner, type or id. Only the AuthorizedResource object whose ownedByUser, types // or resourceIds is undefined or contains a undefined can matches such a resource which has // no owner, type or id. - audit( + async audit( token: string | undefined, action: AuthorizedAction, resourceOwnerId?: number, resourceType?: string, resourceId?: number, - ): void { + ): Promise { const authorization = this.verify(token); // In many situations, the coders may forget to convert the string to number. // So we do it here. @@ -163,6 +165,18 @@ export class AuthService { if (idMatches == false) continue; // Now, id matches. + if (permission.customLogic !== undefined) { + const result = await this.customAuthLogics.invoke( + permission.customLogic, + action, + resourceOwnerId, + resourceType, + resourceId, + ); + if (result !== true) continue; + } + // Now, custom logic matches. + // Action, owner, type and id matches, so the operaton is permitted. return; } diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index 39082ae0..a871b9b0 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -15,8 +15,9 @@ import { PermissionDeniedError, } from './auth.error'; import { AuthService } from './auth.service'; -import { Authorization } from './definitions'; +import { Authorization, AuthorizedAction } from './definitions'; import { SessionService } from './session.service'; +import { CustomAuthLogicHandler } from './custom-auth-logic'; describe('AuthService', () => { let app: TestingModule; @@ -117,26 +118,26 @@ describe('AuthService', () => { userId: 0, permissions: [], }); - expect(() => { - authService.audit(token, 'other', '1' as any as number, 'type', 1); - }).toThrow('resourceOwnerId must be a number.'); + expect(async () => { + await authService.audit(token, 'other', '1' as any as number, 'type', 1); + }).rejects.toThrow('resourceOwnerId must be a number.'); }); it('should throw Error("resourceId must be a number.")', () => { const token = authService.sign({ userId: 0, permissions: [], }); - expect(() => { - authService.audit(token, 'other', 1, 'type', '1' as any as number); - }).toThrow('resourceId must be a number.'); + expect(async () => { + await authService.audit(token, 'other', 1, 'type', '1' as any as number); + }).rejects.toThrow('resourceId must be a number.'); }); it('should throw AuthenticationRequiredError()', () => { - expect(() => authService.audit('', 'other', 1, 'type', 1)).toThrow( - new AuthenticationRequiredError(), - ); - expect(() => authService.audit(undefined, 'other', 1, 'type', 1)).toThrow( - new AuthenticationRequiredError(), - ); + expect( + async () => await authService.audit('', 'other', 1, 'type', 1), + ).rejects.toThrow(new AuthenticationRequiredError()); + expect( + async () => await authService.audit(undefined, 'other', 1, 'type', 1), + ).rejects.toThrow(new AuthenticationRequiredError()); expect(() => authService.decode('')).toThrow( new AuthenticationRequiredError(), ); @@ -144,7 +145,7 @@ describe('AuthService', () => { new AuthenticationRequiredError(), ); }); - it('should pass audit', () => { + it('should pass audit', async () => { const token = authService.sign({ userId: 0, permissions: [ @@ -158,7 +159,7 @@ describe('AuthService', () => { }, ], }); - authService.audit(`bearer ${token}`, 'query', 1, 'type', 1); + await authService.audit(`bearer ${token}`, 'query', 1, 'type', 1); }); it('should throw PermissionDeniedError()', () => { const token = authService.sign({ @@ -174,9 +175,9 @@ describe('AuthService', () => { }, ], }); - expect(() => authService.audit(token, 'delete', 1, 'type', 5)).toThrow( - new PermissionDeniedError('delete', 1, 'type', 5), - ); + expect( + async () => await authService.audit(token, 'delete', 1, 'type', 5), + ).rejects.toThrow(new PermissionDeniedError('delete', 1, 'type', 5)); }); it('should verify and decode successfully', () => { const authorization: Authorization = { @@ -207,4 +208,79 @@ describe('AuthService', () => { new NotRefreshTokenError(), ); }); + it('should throw Error()', () => { + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['some_action'], + authorizedResource: { + types: ['user'], + }, + customLogic: 'some_logic', + }, + ], + }); + expect(async () => { + await authService.audit(token, 'some_action', 1, 'user', 1); + }).rejects.toThrow(new Error("Custom auth logic 'some_logic' not found.")); + }); + it('should register and invoke custom logic successfully', async () => { + let handler_called = false; + const handler: CustomAuthLogicHandler = async ( + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + ) => { + handler_called = true; + return true; + }; + authService.customAuthLogics.register('some_logic', handler); + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['some_action'], + authorizedResource: { + types: ['user'], + }, + customLogic: 'some_logic', + }, + ], + }); + await authService.audit(token, 'some_action', 1, 'user', 1); + expect(handler_called).toBe(true); + }); + it('should register and invoke custom logic successfully', async () => { + let handler_called = false; + const handler: CustomAuthLogicHandler = async ( + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + ) => { + handler_called = true; + return false; + }; + authService.customAuthLogics.register('another_logic', handler); + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['another_action'], + authorizedResource: { + types: ['user'], + }, + customLogic: 'another_logic', + }, + ], + }); + expect(async () => { + await authService.audit(token, 'another_action', 1, 'user', 1); + }).rejects.toThrow( + new PermissionDeniedError('another_action', 1, 'user', 1), + ); + expect(handler_called).toBe(true); + }); }); diff --git a/src/auth/custom-auth-logic.ts b/src/auth/custom-auth-logic.ts new file mode 100644 index 00000000..767b889f --- /dev/null +++ b/src/auth/custom-auth-logic.ts @@ -0,0 +1,30 @@ +import { AuthorizedAction } from './definitions'; + +export type CustomAuthLogicHandler = ( + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, +) => Promise; + +export class CustomAuthLogics { + private logics: Map = new Map(); + + register(name: string, handler: CustomAuthLogicHandler): void { + this.logics.set(name, handler); + } + + invoke( + name: string, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + ): Promise { + const handler = this.logics.get(name); + if (!handler) { + throw new Error(`Custom auth logic '${name}' not found.`); + } + return handler(action, resourceOwnerId, resourceType, resourceId); + } +} diff --git a/src/auth/definitions.ts b/src/auth/definitions.ts index a53e67a5..4a972065 100644 --- a/src/auth/definitions.ts +++ b/src/auth/definitions.ts @@ -57,6 +57,7 @@ export class AuthorizedResource { export class Permission { authorizedActions: AuthorizedAction[]; authorizedResource: AuthorizedResource; + customLogic?: string; } // The user, whose id is userId, is granted the permissions. export class Authorization { diff --git a/src/auth/guard.decorator.ts b/src/auth/guard.decorator.ts index 798978cb..3d79e1b7 100644 --- a/src/auth/guard.decorator.ts +++ b/src/auth/guard.decorator.ts @@ -150,7 +150,7 @@ export function Guard(action: AuthorizedAction, resourceType: string) { : undefined; } - AuthService.instance.audit( + await AuthService.instance.audit( authToken, action, resourceOwnerId, diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts index 20cddfc2..62d62ec5 100644 --- a/src/auth/session.service.ts +++ b/src/auth/session.service.ts @@ -90,7 +90,7 @@ export class SessionService { throw new NotRefreshTokenError(); } const sessionId = auth.permissions[0].authorizedResource.resourceIds[0]; - this.authService.audit( + await this.authService.audit( oldRefreshToken, 'other', undefined, @@ -170,7 +170,7 @@ export class SessionService { throw new NotRefreshTokenError(); } const sessionId = auth.permissions[0].authorizedResource.resourceIds[0]; - this.authService.audit( + await this.authService.audit( refreshToken, 'other', undefined, diff --git a/src/auth/token-payload.schema.json b/src/auth/token-payload.schema.json index 40efb68a..c9ba9151 100644 --- a/src/auth/token-payload.schema.json +++ b/src/auth/token-payload.schema.json @@ -57,6 +57,9 @@ }, "authorizedResource": { "$ref": "#/definitions/AuthorizedResource" + }, + "customLogic": { + "type": "string" } }, "required": [ diff --git a/src/users/users.service.ts b/src/users/users.service.ts index cf19dd27..ed4ffc4d 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -521,7 +521,7 @@ export class UsersService { // If we check, then, if the token is invalid, it won't be logged. const userId = this.authService.decode(token).authorization.userId; try { - this.authService.audit( + await this.authService.audit( token, 'modify', userId, From 8f193790b9515aee28df138a1cd0bc796f55ce56 Mon Sep 17 00:00:00 2001 From: nictheboy Date: Fri, 6 Sep 2024 18:50:56 +0800 Subject: [PATCH 05/12] feat(auth): add customLogicData --- src/auth/auth.service.ts | 1 + src/auth/auth.spec.ts | 36 ++++++++++++++++++++++++++++++ src/auth/custom-auth-logic.ts | 10 ++++++++- src/auth/definitions.ts | 1 + src/auth/token-payload.schema.json | 3 ++- 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 02f8abbf..ae41a1cf 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -172,6 +172,7 @@ export class AuthService { resourceOwnerId, resourceType, resourceId, + permission.customLogicData, ); if (result !== true) continue; } diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index a871b9b0..cbae9e49 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -283,4 +283,40 @@ describe('AuthService', () => { ); expect(handler_called).toBe(true); }); + it('should invoke custom logic and get additional data successfully', async () => { + let handler_called = false; + let data = { some: '' }; + const handler: CustomAuthLogicHandler = async ( + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + customLogicData?: any, + ) => { + handler_called = true; + data = customLogicData; + return false; + }; + authService.customAuthLogics.register('another_logic', handler); + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['another_action'], + authorizedResource: { + types: ['user'], + }, + customLogic: 'another_logic', + customLogicData: { some: 'data' }, + }, + ], + }); + expect(async () => { + await authService.audit(token, 'another_action', 1, 'user', 1); + }).rejects.toThrow( + new PermissionDeniedError('another_action', 1, 'user', 1), + ); + expect(handler_called).toBe(true); + expect(data).toEqual({ some: 'data' }); + }); }); diff --git a/src/auth/custom-auth-logic.ts b/src/auth/custom-auth-logic.ts index 767b889f..d60cbff1 100644 --- a/src/auth/custom-auth-logic.ts +++ b/src/auth/custom-auth-logic.ts @@ -5,6 +5,7 @@ export type CustomAuthLogicHandler = ( resourceOwnerId?: number, resourceType?: string, resourceId?: number, + customLogicData?: any, ) => Promise; export class CustomAuthLogics { @@ -20,11 +21,18 @@ export class CustomAuthLogics { resourceOwnerId?: number, resourceType?: string, resourceId?: number, + customLogicData?: any, ): Promise { const handler = this.logics.get(name); if (!handler) { throw new Error(`Custom auth logic '${name}' not found.`); } - return handler(action, resourceOwnerId, resourceType, resourceId); + return handler( + action, + resourceOwnerId, + resourceType, + resourceId, + customLogicData, + ); } } diff --git a/src/auth/definitions.ts b/src/auth/definitions.ts index 4a972065..4d6f254c 100644 --- a/src/auth/definitions.ts +++ b/src/auth/definitions.ts @@ -58,6 +58,7 @@ export class Permission { authorizedActions: AuthorizedAction[]; authorizedResource: AuthorizedResource; customLogic?: string; + customLogicData?: any; } // The user, whose id is userId, is granted the permissions. export class Authorization { diff --git a/src/auth/token-payload.schema.json b/src/auth/token-payload.schema.json index c9ba9151..51ae55fe 100644 --- a/src/auth/token-payload.schema.json +++ b/src/auth/token-payload.schema.json @@ -60,7 +60,8 @@ }, "customLogic": { "type": "string" - } + }, + "customLogicData": {} }, "required": [ "authorizedActions", From 7d5ec025596d76c1305493b8a9e430793add00a7 Mon Sep 17 00:00:00 2001 From: nictheboy Date: Fri, 6 Sep 2024 19:00:01 +0800 Subject: [PATCH 06/12] feat(auth): add auditWithoutToken() for AuthService --- src/auth/auth.service.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ae41a1cf..94c71e14 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -108,6 +108,23 @@ export class AuthService { resourceId?: number, ): Promise { const authorization = this.verify(token); + await this.auditWithoutToken( + authorization, + action, + resourceOwnerId, + resourceType, + resourceId, + ); + } + + // Do the same thing as audit(), but without a token. + async auditWithoutToken( + authorization: Authorization, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + ): Promise { // In many situations, the coders may forget to convert the string to number. // So we do it here. // Addition: We think this hides problems; so we remove it. From 32a9c20dbd669df7ced1c2ff0ad132431117962a Mon Sep 17 00:00:00 2001 From: nictheboy Date: Fri, 6 Sep 2024 19:23:52 +0800 Subject: [PATCH 07/12] feat(auth): check existence before adding custom logic --- src/auth/auth.spec.ts | 4 ++-- src/auth/custom-auth-logic.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index cbae9e49..126da767 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -297,7 +297,7 @@ describe('AuthService', () => { data = customLogicData; return false; }; - authService.customAuthLogics.register('another_logic', handler); + authService.customAuthLogics.register('yet_another_logic', handler); const token = authService.sign({ userId: 0, permissions: [ @@ -306,7 +306,7 @@ describe('AuthService', () => { authorizedResource: { types: ['user'], }, - customLogic: 'another_logic', + customLogic: 'yet_another_logic', customLogicData: { some: 'data' }, }, ], diff --git a/src/auth/custom-auth-logic.ts b/src/auth/custom-auth-logic.ts index d60cbff1..eb5d76b2 100644 --- a/src/auth/custom-auth-logic.ts +++ b/src/auth/custom-auth-logic.ts @@ -12,6 +12,10 @@ export class CustomAuthLogics { private logics: Map = new Map(); register(name: string, handler: CustomAuthLogicHandler): void { + /* istanbul ignore if */ + if (this.logics.has(name)) { + throw new Error(`Custom auth logic '${name}' already exists.`); + } this.logics.set(name, handler); } From a3f5dcbb74c5c3aadfad6cf7dc41b6ee5ae52e9d Mon Sep 17 00:00:00 2001 From: nictheboy Date: Fri, 6 Sep 2024 19:24:30 +0800 Subject: [PATCH 08/12] feat(auth): allow authorizedActions to match every action --- src/auth/auth.service.ts | 11 ++++--- src/auth/auth.spec.ts | 46 ++++++++++++++++++++++++++++++ src/auth/definitions.ts | 10 ++++++- src/auth/token-payload.schema.json | 1 - 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 94c71e14..68a31bfd 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -141,10 +141,13 @@ export class AuthService { throw new Error('resourceId must be a number.'); } for (const permission of authorization.permissions) { - let actionMatches = false; - for (const authorizedAction of permission.authorizedActions) { - if (authorizedAction === action) { - actionMatches = true; + let actionMatches = + permission.authorizedActions === undefined ? true : false; + if (permission.authorizedActions !== undefined) { + for (const authorizedAction of permission.authorizedActions) { + if (authorizedAction === action) { + actionMatches = true; + } } } if (actionMatches == false) continue; diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index 126da767..169647db 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -319,4 +319,50 @@ describe('AuthService', () => { expect(handler_called).toBe(true); expect(data).toEqual({ some: 'data' }); }); + it('should always invoke custom logic successfully', async () => { + let handler_called = false; + const handler: CustomAuthLogicHandler = async ( + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + ) => { + handler_called = true; + return true; + }; + authService.customAuthLogics.register('yet_yet_another_logic', handler); + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: undefined, // all actions + authorizedResource: {}, // all resources + customLogic: 'yet_yet_another_logic', + }, + ], + }); + await authService.audit(token, 'some_action', 1, 'user', 1); + expect(handler_called).toBe(true); + handler_called = false; + await authService.audit(token, 'another_action', undefined, 'user', 1); + expect(handler_called).toBe(true); + handler_called = false; + await authService.audit( + token, + 'some_action', + 1, + 'another_resource', + undefined, + ); + expect(handler_called).toBe(true); + handler_called = false; + await authService.audit( + token, + 'another_action', + undefined, + 'another_resource', + undefined, + ); + expect(handler_called).toBe(true); + }); }); diff --git a/src/auth/definitions.ts b/src/auth/definitions.ts index 4d6f254c..1e70a283 100644 --- a/src/auth/definitions.ts +++ b/src/auth/definitions.ts @@ -52,14 +52,22 @@ export class AuthorizedResource { resourceIds?: number[]; data?: any; // additional data } + // The permission to perform all the actions listed in authorizedActions // on all the resources that match the authorizedResource property. +// +// If authorizedActions is undefined, the permission is granted to perform +// all the actions on the resources that match the authorizedResource property. +// +// If customLogic is not undefined, the permission is granted only if the +// custom logic allows the specified action on the specified resource. export class Permission { - authorizedActions: AuthorizedAction[]; + authorizedActions?: AuthorizedAction[]; authorizedResource: AuthorizedResource; customLogic?: string; customLogicData?: any; } + // The user, whose id is userId, is granted the permissions. export class Authorization { userId: number; // authorization identity diff --git a/src/auth/token-payload.schema.json b/src/auth/token-payload.schema.json index 51ae55fe..047f6851 100644 --- a/src/auth/token-payload.schema.json +++ b/src/auth/token-payload.schema.json @@ -64,7 +64,6 @@ "customLogicData": {} }, "required": [ - "authorizedActions", "authorizedResource" ], "type": "object" From e24c5306c603c4d12caaef2fa93f47504330cdc0 Mon Sep 17 00:00:00 2001 From: nictheboy Date: Fri, 6 Sep 2024 20:03:40 +0800 Subject: [PATCH 09/12] feat(auth): add param userId for CustomAuthLogicHandler --- src/auth/auth.service.ts | 3 ++- src/auth/auth.spec.ts | 4 ++++ src/auth/custom-auth-logic.ts | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 68a31bfd..95268bf3 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -188,6 +188,7 @@ export class AuthService { if (permission.customLogic !== undefined) { const result = await this.customAuthLogics.invoke( permission.customLogic, + authorization.userId, action, resourceOwnerId, resourceType, @@ -198,7 +199,7 @@ export class AuthService { } // Now, custom logic matches. - // Action, owner, type and id matches, so the operaton is permitted. + // Action, owner, type and id matches, so the operation is permitted. return; } throw new PermissionDeniedError( diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts index 169647db..fab16fe4 100644 --- a/src/auth/auth.spec.ts +++ b/src/auth/auth.spec.ts @@ -228,6 +228,7 @@ describe('AuthService', () => { it('should register and invoke custom logic successfully', async () => { let handler_called = false; const handler: CustomAuthLogicHandler = async ( + userId: number, action: AuthorizedAction, resourceOwnerId?: number, resourceType?: string, @@ -255,6 +256,7 @@ describe('AuthService', () => { it('should register and invoke custom logic successfully', async () => { let handler_called = false; const handler: CustomAuthLogicHandler = async ( + userId: number, action: AuthorizedAction, resourceOwnerId?: number, resourceType?: string, @@ -287,6 +289,7 @@ describe('AuthService', () => { let handler_called = false; let data = { some: '' }; const handler: CustomAuthLogicHandler = async ( + userId: number, action: AuthorizedAction, resourceOwnerId?: number, resourceType?: string, @@ -322,6 +325,7 @@ describe('AuthService', () => { it('should always invoke custom logic successfully', async () => { let handler_called = false; const handler: CustomAuthLogicHandler = async ( + userId: number, action: AuthorizedAction, resourceOwnerId?: number, resourceType?: string, diff --git a/src/auth/custom-auth-logic.ts b/src/auth/custom-auth-logic.ts index eb5d76b2..af7e9ff3 100644 --- a/src/auth/custom-auth-logic.ts +++ b/src/auth/custom-auth-logic.ts @@ -1,6 +1,7 @@ import { AuthorizedAction } from './definitions'; export type CustomAuthLogicHandler = ( + userId: number, action: AuthorizedAction, resourceOwnerId?: number, resourceType?: string, @@ -21,6 +22,7 @@ export class CustomAuthLogics { invoke( name: string, + userId: number, action: AuthorizedAction, resourceOwnerId?: number, resourceType?: string, @@ -32,6 +34,7 @@ export class CustomAuthLogics { throw new Error(`Custom auth logic '${name}' not found.`); } return handler( + userId, action, resourceOwnerId, resourceType, From ccdf0b11a39245891580215635be9da9fb653c2b Mon Sep 17 00:00:00 2001 From: nictheboy Date: Fri, 6 Sep 2024 20:04:22 +0800 Subject: [PATCH 10/12] feat(user): simplify token --- src/users/role-permission.service.ts | 169 ++++++++++++++++++++++ src/users/users-permission.service.ts | 193 +++++++------------------- src/users/users.module.ts | 2 + 3 files changed, 223 insertions(+), 141 deletions(-) create mode 100644 src/users/role-permission.service.ts diff --git a/src/users/role-permission.service.ts b/src/users/role-permission.service.ts new file mode 100644 index 00000000..39ff00b1 --- /dev/null +++ b/src/users/role-permission.service.ts @@ -0,0 +1,169 @@ +import { Injectable } from '@nestjs/common'; +import { Authorization } from '../auth/definitions'; + +@Injectable() +export class RolePermissionService { + async getAuthorizationForUserWithRole( + userId: number, + roleName: string, + ): Promise { + switch (roleName) { + case 'standard-user': + return await this.getAuthorizationForStandardUser(userId); + /* istanbul ignore next */ + default: + throw new Error(`Role ${roleName} is not supported.`); + } + } + + private async getAuthorizationForStandardUser( + userId: number, + ): Promise { + return { + userId: userId, + permissions: [ + { + authorizedActions: [ + 'query', + 'follow', + 'unfollow', + 'enumerate-followers', + 'enumerate-answers', + 'enumerate-questions', + 'enumerate-followed-users', + 'enumerate-followed-questions', + ], + authorizedResource: { + ownedByUser: undefined, + types: ['user'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['modify-profile'], + authorizedResource: { + ownedByUser: userId, + types: ['user'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['query', 'enumerate'], + authorizedResource: { + ownedByUser: undefined, + types: ['question', 'answer', 'comment'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'delete', 'modify'], + authorizedResource: { + ownedByUser: userId, + types: ['question', 'answer', 'comment'], + resourceIds: undefined, + }, + }, + { + authorizedActions: [ + 'query', + 'query-invitation-recommendations', + 'query-invitation', + 'enumerate', + 'enumerate-answers', + 'enumerate-followers', + 'enumerate-invitations', + 'follow', + 'unfollow', + 'invite', + 'uninvite', + ], + authorizedResource: { + ownedByUser: undefined, + types: ['question'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['accept-answer', 'set-bounty'], + authorizedResource: { + ownedByUser: userId, + types: ['question'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['query', 'favorite', 'unfavorite'], + authorizedResource: { + ownedByUser: undefined, + types: ['answer'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['attitude'], + authorizedResource: { + ownedByUser: undefined, + types: ['comment', 'question', 'answer'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'query'], + authorizedResource: { + ownedByUser: undefined, + types: ['attachment', 'material'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['query', 'enumerate'], + authorizedResource: { + ownedByUser: undefined, + types: ['material-bundle'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'modify', 'delete'], + authorizedResource: { + ownedByUser: undefined, + types: ['material-bundle'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'query', 'enumerate'], + authorizedResource: { + ownedByUser: undefined, + types: ['topic'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'query', 'query-default', 'enumerate'], + authorizedResource: { + ownedByUser: undefined, + types: ['avatar'], + resourceIds: undefined, + }, + }, + { + authorizedActions: [], + authorizedResource: { + ownedByUser: undefined, + types: ['group'], + resourceIds: undefined, + }, + }, + { + authorizedActions: [], + authorizedResource: { + ownedByUser: userId, + types: ['group'], + resourceIds: undefined, + }, + }, + ], + }; + } +} diff --git a/src/users/users-permission.service.ts b/src/users/users-permission.service.ts index f073e459..dff787c7 100644 --- a/src/users/users-permission.service.ts +++ b/src/users/users-permission.service.ts @@ -1,8 +1,52 @@ -import { Injectable } from '@nestjs/common'; -import { Authorization } from '../auth/definitions'; +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Authorization, AuthorizedAction } from '../auth/definitions'; +import { RolePermissionService } from './role-permission.service'; +import { AuthService } from '../auth/auth.service'; +import { PermissionDeniedError } from '../auth/auth.error'; @Injectable() -export class UsersPermissionService { +export class UsersPermissionService implements OnModuleInit { + constructor( + private readonly authService: AuthService, + private readonly rolePermissionService: RolePermissionService, + ) {} + + onModuleInit() { + this.authService.customAuthLogics.register( + 'role-based', + async ( + userId: number, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + customLogicData?: any, + ): Promise => { + const authorization = + await this.rolePermissionService.getAuthorizationForUserWithRole( + userId, + customLogicData.role, + ); + try { + await this.authService.auditWithoutToken( + authorization, + action, + resourceOwnerId, + resourceType, + resourceId, + ); + } catch (e) { + if (e instanceof PermissionDeniedError) { + return false; + } + /* istanbul ignore next */ + throw e; + } + return true; + }, + ); + } + // Although this method is not async now, // it may become async in the future. async getAuthorizationForUser(userId: number): Promise { @@ -10,144 +54,11 @@ export class UsersPermissionService { userId: userId, permissions: [ { - authorizedActions: [ - 'query', - 'follow', - 'unfollow', - 'enumerate-followers', - 'enumerate-answers', - 'enumerate-questions', - 'enumerate-followed-users', - 'enumerate-followed-questions', - ], - authorizedResource: { - ownedByUser: undefined, - types: ['user'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['modify-profile'], - authorizedResource: { - ownedByUser: userId, - types: ['user'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['query', 'enumerate'], - authorizedResource: { - ownedByUser: undefined, - types: ['question', 'answer', 'comment'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['create', 'delete', 'modify'], - authorizedResource: { - ownedByUser: userId, - types: ['question', 'answer', 'comment'], - resourceIds: undefined, - }, - }, - { - authorizedActions: [ - 'query', - 'query-invitation-recommendations', - 'query-invitation', - 'enumerate', - 'enumerate-answers', - 'enumerate-followers', - 'enumerate-invitations', - 'follow', - 'unfollow', - 'invite', - 'uninvite', - ], - authorizedResource: { - ownedByUser: undefined, - types: ['question'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['accept-answer', 'set-bounty'], - authorizedResource: { - ownedByUser: userId, - types: ['question'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['query', 'favorite', 'unfavorite'], - authorizedResource: { - ownedByUser: undefined, - types: ['answer'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['attitude'], - authorizedResource: { - ownedByUser: undefined, - types: ['comment', 'question', 'answer'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['create', 'query'], - authorizedResource: { - ownedByUser: undefined, - types: ['attachment', 'material'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['query', 'enumerate'], - authorizedResource: { - ownedByUser: undefined, - types: ['material-bundle'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['create', 'modify', 'delete'], - authorizedResource: { - ownedByUser: undefined, - types: ['material-bundle'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['create', 'query', 'enumerate'], - authorizedResource: { - ownedByUser: undefined, - types: ['topic'], - resourceIds: undefined, - }, - }, - { - authorizedActions: ['create', 'query', 'query-default', 'enumerate'], - authorizedResource: { - ownedByUser: undefined, - types: ['avatar'], - resourceIds: undefined, - }, - }, - { - authorizedActions: [], - authorizedResource: { - ownedByUser: undefined, - types: ['group'], - resourceIds: undefined, - }, - }, - { - authorizedActions: [], - authorizedResource: { - ownedByUser: userId, - types: ['group'], - resourceIds: undefined, + authorizedActions: undefined, // forward all actions + authorizedResource: {}, // forward all resources + customLogic: 'role-based', + customLogicData: { + role: 'standard-user', }, }, ], diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 2e7ba093..36fb7ab5 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -17,6 +17,7 @@ import { UsersPermissionService } from './users-permission.service'; import { UsersRegisterRequestService } from './users-register-request.service'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { RolePermissionService } from './role-permission.service'; @Module({ imports: [ @@ -32,6 +33,7 @@ import { UsersService } from './users.service'; UsersService, UsersPermissionService, UsersRegisterRequestService, + RolePermissionService, ], exports: [UsersService], }) From 87bfbc37d812ec448b88a60a216cfdc1f62912c7 Mon Sep 17 00:00:00 2001 From: nictheboy Date: Fri, 6 Sep 2024 20:28:29 +0800 Subject: [PATCH 11/12] test: improve coverage --- test/answer.e2e-spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/answer.e2e-spec.ts b/test/answer.e2e-spec.ts index 02d61ea6..3a3282dc 100644 --- a/test/answer.e2e-spec.ts +++ b/test/answer.e2e-spec.ts @@ -276,6 +276,7 @@ describe('Answers Module', () => { const response = await request(app.getHttpServer()) .get(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) .set('Authorization', `Bearer ${auxAccessToken}`) + .set('User-Agent', 'PostmanRuntime/7.26.8') .send(); expect(response.body.message).toBe('Answer fetched successfully.'); expect(response.status).toBe(200); From b0b31d7a613ccef98933a02a2b26f385a5bfe322 Mon Sep 17 00:00:00 2001 From: nictheboy Date: Fri, 6 Sep 2024 20:45:21 +0800 Subject: [PATCH 12/12] test: improve coverage --- test/user.profile.e2e-spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/user.profile.e2e-spec.ts b/test/user.profile.e2e-spec.ts index dfc79518..a2f61c85 100644 --- a/test/user.profile.e2e-spec.ts +++ b/test/user.profile.e2e-spec.ts @@ -151,7 +151,7 @@ describe('Profile Submodule of User Module', () => { it('should get modified user profile', async () => { const respond = await request(app.getHttpServer()) .get(`/users/${TestUserId}`) - //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('User-Agent', 'PostmanRuntime/7.26.8') .set('authorization', 'Bearer ' + TestToken); expect(respond.body.message).toBe('Query user successfully.'); expect(respond.status).toBe(200);