From af7f4f950b97006d96662b5a10925b1c877c612b Mon Sep 17 00:00:00 2001 From: Nikolay <46225163+vigdorov@users.noreply.github.com> Date: Sat, 29 Aug 2020 23:44:11 +0300 Subject: [PATCH] =?UTF-8?q?HM-113.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=85=D1=83=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F/?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D1=89=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/hooks.http | 19 ++++++ http/store.http | 3 + src/app.module.ts | 2 + src/consts.ts | 1 + src/store/hookTokens.controller.ts | 86 ++++++++++++++++++++++++++ src/store/hookTokens.responses.ts | 56 +++++++++++++++++ src/store/store.controller.ts | 41 +++++++++---- src/store/store.schema.ts | 85 +++++++++++++++++++++++++ src/store/store.service.ts | 99 +++++++++++++++++++++++++++++- src/store/utils.ts | 8 +++ 10 files changed, 387 insertions(+), 13 deletions(-) create mode 100644 http/hooks.http create mode 100644 src/store/hookTokens.controller.ts create mode 100644 src/store/hookTokens.responses.ts create mode 100644 src/store/utils.ts diff --git a/http/hooks.http b/http/hooks.http new file mode 100644 index 0000000..6fff7e6 --- /dev/null +++ b/http/hooks.http @@ -0,0 +1,19 @@ +GET http://localhost:4001/hooks?key=rgfdfff HTTP/1.1 + +### +POST http://localhost:4001/hooks?key=rgfdfff HTTP/1.1 +content-type: application/json + +{ + "holder": "john", + "description": "test token", + "author": "john smith", + "token": "sdf6sdfhs99-sdf", + "rights": { + "write": true, + "read": true + } +} + +### +DELETE http://localhost:4001/hooks?key=rgfdfff&id=5f4a0347c3a82d5a571f058a HTTP/1.1 diff --git a/http/store.http b/http/store.http index 76b6b30..ed1793d 100644 --- a/http/store.http +++ b/http/store.http @@ -6,6 +6,9 @@ Api-Name: store-service-test GET http://localhost:4001/store/testApi-4345345345 HTTP/1.1 Api-Name: store-service-test +### +GET http://localhost:4001/store/rgfdfff?hook=sdf6sdfhs99-sdf HTTP/1.1 + ### POST http://localhost:4001/store HTTP/1.1 content-type: application/json diff --git a/src/app.module.ts b/src/app.module.ts index fbb6e64..369c7b9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import {LogsService} from './logs/logs.service'; import {LogsController} from './logs/logs.controller'; import {ClientLog, ClientLogSchema, ServerLog, ServerLogSchema} from './logs/logs.schema'; import {AuthService} from './services/auth.service'; +import {HookTonesController} from './store/hookTokens.controller'; @Module({ imports: [ @@ -36,6 +37,7 @@ import {AuthService} from './services/auth.service'; controllers: [ StoreController, LogsController, + HookTonesController, ], providers: [ StoreService, diff --git a/src/consts.ts b/src/consts.ts index 67587ae..f4dc1b3 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -3,6 +3,7 @@ export const DB_TEST_NAME = 'store-service-test'; export const DB_LOGGER = 'logger'; export const MONGO_URL = 'mongodb://localhost:27017'; export const COLLECTION_STORE = 'store'; +export const COLLECTION_HOOKS = 'hooks'; export const COLLECTION_LOGS = 'logs'; export const LOG_TYPE = { CLIENT: 'client-logs', diff --git a/src/store/hookTokens.controller.ts b/src/store/hookTokens.controller.ts new file mode 100644 index 0000000..3aa4228 --- /dev/null +++ b/src/store/hookTokens.controller.ts @@ -0,0 +1,86 @@ +import {Controller, Get, Header, Req, Post, Delete, HttpCode, Options, UseInterceptors} from '@nestjs/common'; +import {ApiTags, ApiQuery, ApiBody, ApiResponse, ApiSecurity} from '@nestjs/swagger'; +import {StoreService} from './store.service'; +import {AuthService} from 'src/services/auth.service'; +import {COLLECTION_HOOKS, ALLOW_ORIGIN_ALL, ALLOW_METHOD, ALLOW_CREDENTIALS, CONTENT_LENGTH, ALLOW_HEADERS} from 'src/consts'; +import {Request} from 'express'; +import {makeApiHeader} from './utils'; +import {HookTokenMap, HookTokenResponse} from './store.schema'; +import {FIND_ALL_SUCCESS, FIND_ALL_NOT_FOUND, CREATE_TOKEN_SUCCESS, CREATE_TOKEN_NOT_VALID, CREATE_TOKEN_CONFLICT, DELETE_TOKEN_SUCCESS, DELETE_TOKEN_NOT_FOUND} from './hookTokens.responses'; +import {LoggingInterceptor} from 'src/logs/logging.interceptor'; + +@ApiSecurity('apiKey') +@UseInterceptors(LoggingInterceptor) +@Controller(COLLECTION_HOOKS) +@ApiTags(COLLECTION_HOOKS) +export class HookTonesController { + constructor( + private readonly storeService: StoreService, + private readonly authService: AuthService, + ) {} + + @Get() + @Header(...ALLOW_ORIGIN_ALL) + @ApiQuery({ + name: 'key', + description: 'Ключ хранилища' + }) + @ApiResponse(FIND_ALL_SUCCESS) + @ApiResponse(FIND_ALL_NOT_FOUND) + async findAll(@Req() request: Request): Promise { + await this.authService.checkRequest(request); + const api = makeApiHeader(request); + const {key} = request?.query ?? {}; + return await this.storeService.findApiTokens(api, key); + } + + @Post() + @Header(...ALLOW_ORIGIN_ALL) + @ApiQuery({ + name: 'key', + description: 'Ключ хранилища' + }) + @ApiBody({ + type: HookTokenMap, + description: 'Объект создания токена' + }) + @ApiResponse(CREATE_TOKEN_SUCCESS) + @ApiResponse(CREATE_TOKEN_NOT_VALID) + @ApiResponse(CREATE_TOKEN_CONFLICT) + async createToken(@Req() request: Request): Promise { + await this.authService.checkRequest(request); + const api = makeApiHeader(request); + const {key} = request?.query ?? {}; + return await this.storeService.createApiToken(api, key, request?.body); + } + + @Delete() + @Header(...ALLOW_ORIGIN_ALL) + @ApiQuery({ + name: 'key', + description: 'Ключ хранилища' + }) + @ApiQuery({ + name: 'id', + description: 'Id токена' + }) + @ApiResponse(DELETE_TOKEN_SUCCESS) + @ApiResponse(DELETE_TOKEN_NOT_FOUND) + async deleteToken(@Req() request: Request): Promise { + await this.authService.checkRequest(request); + const api = makeApiHeader(request); + const {key, id} = request?.query ?? {}; + return await this.storeService.deleteApiToken(api, key, id); + } + + @Options('') + @Header(...ALLOW_ORIGIN_ALL) + @Header(...ALLOW_METHOD) + @Header(...ALLOW_CREDENTIALS) + @Header(...CONTENT_LENGTH) + @Header(...ALLOW_HEADERS) + @HttpCode(204) + async optionsKey(): Promise { + return ''; + } +} \ No newline at end of file diff --git a/src/store/hookTokens.responses.ts b/src/store/hookTokens.responses.ts new file mode 100644 index 0000000..293f4ed --- /dev/null +++ b/src/store/hookTokens.responses.ts @@ -0,0 +1,56 @@ +import {ApiResponseOptions, ApiProperty} from '@nestjs/swagger'; +import {HookTokenResponse} from './store.schema'; + +class Error { + @ApiProperty() + statusCode: number; + + @ApiProperty() + message: string; + + @ApiProperty() + error: string; +} + +export const FIND_ALL_SUCCESS: ApiResponseOptions = { + status: 200, + description: 'Список всех хуков хранилища', + type: HookTokenResponse, + isArray: true, +}; + +export const FIND_ALL_NOT_FOUND: ApiResponseOptions = { + status: 404, + description: 'Ошибка при попытке получить хуки для несуществующего хранилища', + type: Error, +}; + +export const CREATE_TOKEN_SUCCESS: ApiResponseOptions = { + status: 201, + description: 'Объект созданного хука', + type: HookTokenResponse, +}; + +export const CREATE_TOKEN_NOT_VALID: ApiResponseOptions = { + status: 400, + description: 'Ошибка при попытке отправить хук с невалидными полями', + type: Error, +}; + +export const CREATE_TOKEN_CONFLICT: ApiResponseOptions = { + status: 409, + description: 'Ошибка при попытке создать хук с уже имеющимся именем в базе', + type: Error, +}; + +export const DELETE_TOKEN_SUCCESS: ApiResponseOptions = { + status: 200, + description: 'Удаляет хук из хранилища', + type: HookTokenResponse, +}; + +export const DELETE_TOKEN_NOT_FOUND: ApiResponseOptions = { + status: 404, + description: 'Ошибка при попытке удалить несуществующий хук или обратится к несуществующем хранилищу', + type: Error, +}; diff --git a/src/store/store.controller.ts b/src/store/store.controller.ts index ba86714..693706d 100644 --- a/src/store/store.controller.ts +++ b/src/store/store.controller.ts @@ -1,7 +1,7 @@ import {Controller, Get, Req, Post, Options, Header, Delete, HttpCode, Put, UseInterceptors} from '@nestjs/common'; import {StoreService} from './store.service'; import {Store, StoreRequest} from './store.schema'; -import {ApiResponse, ApiTags, ApiParam, ApiBody, ApiBearerAuth, ApiSecurity} from '@nestjs/swagger'; +import {ApiResponse, ApiTags, ApiParam, ApiBody, ApiBearerAuth, ApiSecurity, ApiQuery} from '@nestjs/swagger'; import {ALLOW_ORIGIN_ALL, ALLOW_METHOD, ALLOW_CREDENTIALS, CONTENT_LENGTH, ALLOW_HEADERS, COLLECTION_STORE} from 'src/consts'; import {Request} from 'express'; import {LoggingInterceptor} from 'src/logs/logging.interceptor'; @@ -19,6 +19,7 @@ import { REMOVE_NOT_FOUND, } from './store.responses'; import {AuthService} from 'src/services/auth.service'; +import {makeApiHeader} from './utils'; const prepareStoreToStoreRequest = ({ key, value, description, service_name, author @@ -26,11 +27,6 @@ const prepareStoreToStoreRequest = ({ key, value, description, service_name, author, }); -const makeApiHeader = (request: Request): string => { - const apiHeader = request.headers?.['api-name']; - return typeof apiHeader === 'string' ? apiHeader : ''; -}; - @ApiSecurity('apiKey') @UseInterceptors(LoggingInterceptor) @Controller(COLLECTION_STORE) @@ -61,11 +57,20 @@ export class StoreController { name: 'key', description: 'Ключ для поиска хранилища', }) - async findOne(@Req() request: Request<{key: string}>): Promise { - await this.authService.checkRequest(request); - - const {key} = request.params; + @ApiQuery({ + name: 'hook', + description: 'Хук для получения доступа без авторизации', + required: false, + }) + async findOne(@Req() request: Request<{key: string}, null, null, {hook: string}>): Promise { const api = makeApiHeader(request); + const {key} = request?.params ?? {}; + const {hook} = request?.query ?? {}; + const isActualHook = await this.storeService.checkHook(api, key, hook, ['read']); + if (!isActualHook) { + await this.authService.checkRequest(request); + } + const store = await this.storeService.findOne(api, key); return prepareStoreToStoreRequest(store); } @@ -79,7 +84,7 @@ export class StoreController { type: StoreRequest, description: 'Объект для создания хранилища' }) - async create(@Req() request: Request): Promise { + async create(@Req() request: Request): Promise { const api = makeApiHeader(request); const store = await this.storeService.create(api, request.body, request.headers.authorization); return prepareStoreToStoreRequest(store); @@ -94,8 +99,20 @@ export class StoreController { type: StoreRequest, description: 'Объект для обновления хранилища' }) - async update(@Req() request: Request): Promise { + @ApiQuery({ + name: 'hook', + description: 'Хук для получения доступа без авторизации', + required: false, + }) + async update(@Req() request: Request): Promise { const api = makeApiHeader(request); + const {hook} = request?.query ?? {}; + const {key} = request?.body; + const isActualHook = await this.storeService.checkHook(api, key, hook, ['write', 'read']); + if (!isActualHook) { + await this.authService.checkRequest(request); + } + const store = await this.storeService.update(api, request.body); return prepareStoreToStoreRequest(store); } diff --git a/src/store/store.schema.ts b/src/store/store.schema.ts index 454a14f..fb01e9c 100644 --- a/src/store/store.schema.ts +++ b/src/store/store.schema.ts @@ -2,6 +2,50 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document } from 'mongoose'; import {ApiProperty} from '@nestjs/swagger'; +class HookRightMap { + @ApiProperty() + write: boolean; + + @ApiProperty() + read: boolean; +} + +export type RightType = keyof HookRightMap; + +export class HookTokenResponse { + @ApiProperty() + holder: string; + + @ApiProperty() + description: string; + + @ApiProperty() + author: string; + + @ApiProperty() + rights: HookRightMap; + + @ApiProperty() + id: string; +} + +export class HookTokenMap { + @ApiProperty() + holder: string; + + @ApiProperty() + description: string; + + @ApiProperty() + author: string; + + @ApiProperty() + token: string; + + @ApiProperty() + rights: HookRightMap; +} + export class StoreRequest { @ApiProperty() key: string; @@ -17,8 +61,44 @@ export class StoreRequest { @ApiProperty() author: string; + + @ApiProperty() + hook_tokens?: HookTokenResponse[]; } +@Schema() +export class HookToken extends Document { + @Prop({ + type: String, + }) + holder: string; + + @Prop({ + type: String, + }) + description: string; + + @Prop({ + type: String, + }) + author: string; + + @Prop({ + type: String, + }) + token: string; + + @Prop({ + type: { + write: Boolean, + read: Boolean, + }, + }) + rights: HookRightMap; +} + +const HookTokenSchema = SchemaFactory.createForClass(HookToken); + @Schema() export class Store extends Document { @Prop({ @@ -53,6 +133,11 @@ export class Store extends Document { type: String, }) author: string; + + @Prop({ + type: [HookTokenSchema] + }) + hook_tokens: HookToken[]; } export const StoreSchema = SchemaFactory.createForClass(Store); diff --git a/src/store/store.service.ts b/src/store/store.service.ts index 0d647cc..7538447 100644 --- a/src/store/store.service.ts +++ b/src/store/store.service.ts @@ -1,7 +1,7 @@ import {Model, Connection} from 'mongoose'; import {Injectable, NotFoundException, BadGatewayException, ConflictException, BadRequestException, HttpException, HttpService} from '@nestjs/common'; import {InjectConnection} from '@nestjs/mongoose'; -import {Store, StoreRequest, StoreSchema} from './store.schema'; +import {Store, StoreRequest, StoreSchema, HookToken, HookTokenResponse, HookTokenMap, RightType} from './store.schema'; import {DB_TEST_NAME, DB_NAME, COLLECTION_STORE} from 'src/consts'; import * as jwt from 'jsonwebtoken'; @@ -12,6 +12,23 @@ interface Token { exp: number; } +const prepareHook = (baseHook: HookToken): HookTokenResponse => ({ + holder: baseHook.holder, + description: baseHook.description, + author: baseHook.author, + rights: baseHook.rights, + id: baseHook._id, +}); + +const prepareStore = (baseStore: Store): StoreRequest => ({ + key: baseStore.key, + value: baseStore.value, + description: baseStore.description, + author: baseStore.author, + service_name: baseStore.service_name, + hook_tokens: baseStore.hook_tokens?.map(prepareHook), +}) + const validateModel = async (store: Store) => { try { await store.validate(); @@ -134,4 +151,84 @@ export class StoreService { throw new NotFoundException(`Not found api key "${key}"`); } + + async findApiTokens(api: string, key: string): Promise { + const searchStore = await this.storeModel(api).findOne({key}); + if (!searchStore) { + throw new NotFoundException(`Not found api key "${key}"`); + } + return searchStore.hook_tokens?.map(prepareHook); + } + + async createApiToken(api: string, key: string, hook: HookTokenMap): Promise { + const searchStore = await this.storeModel(api).findOne({key}); + + if (!searchStore) { + throw new NotFoundException(`Not found api key "${key}"`); + } + + const store = { + ...prepareStore(searchStore), + hook_tokens: (searchStore.hook_tokens || []).map(baseHook => { + const {id, ...omitHook} = prepareHook(baseHook); + return omitHook; + }).concat(hook), + }; + + const updateStore = new (this.storeModel(api))(store); + + try { + await validateModel(updateStore); + } catch (e) { + if (e?.message?.includes('validation failed')) { + throw new BadRequestException(e.message); + } + throw e; + } + + await searchStore.updateOne(store); + + const updatedStore = await this.storeModel(api).findOne({key}); + const oldTokenIds = searchStore.hook_tokens.map(hook => hook._id); + const newToken = updatedStore.hook_tokens.find(token => !oldTokenIds.includes(token)); + + return prepareHook(newToken); + } + + async deleteApiToken(api: string, key: string, id: string): Promise { + const searchStore = await this.storeModel(api).findOne({key}); + + if (!searchStore) { + throw new NotFoundException(`Not found api key "${key}"`); + } + + const deleteToken = searchStore.hook_tokens?.find(token => token._id.toString() === id); + + if (!deleteToken) { + throw new NotFoundException(`Not found token id "${id}" in api key "${key}"`); + } + + const store = { + ...prepareStore(searchStore), + hook_tokens: (searchStore.hook_tokens || []).filter(token => token._id.toString() !== id), + }; + + await searchStore.updateOne(store); + + return prepareHook(deleteToken); + } + + async checkHook(api: string, key: string, hook: string, checkObject: RightType[]): Promise { + const searchStore = await this.storeModel(api).findOne({key}); + + if (!searchStore) { + throw new NotFoundException(`Not found api key "${key}"`); + } + + const token = searchStore.hook_tokens?.find(baseToken => baseToken.token === hook); + + const available = checkObject.every(right => token?.rights[right]); + + return !!token && available; + } } \ No newline at end of file diff --git a/src/store/utils.ts b/src/store/utils.ts new file mode 100644 index 0000000..516ada8 --- /dev/null +++ b/src/store/utils.ts @@ -0,0 +1,8 @@ +import {Request} from 'express'; + +const apiName = 'api-name' as const; + +export const makeApiHeader = (request: Request): string => { + const apiHeader = request.headers?.[apiName]; + return typeof apiHeader === 'string' ? apiHeader : ''; +};