diff --git a/package-lock.json b/package-lock.json index 0f7e9e2..9f2f15d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "ticket-backend-22th", "version": "0.0.1", "license": "UNLICENSED", "dependencies": { @@ -19,14 +18,17 @@ "@nestjs/platform-express": "^8.0.0", "@nestjs/platform-socket.io": "^8.4.7", "@nestjs/swagger": "^5.0.9", + "@nestjs/throttler": "^3.0.0", "@nestjs/typeorm": "^8.1.4", "@nestjs/websockets": "^8.4.7", "@redis/client": "^1.2.0", + "@types/crypto-js": "^4.1.1", "axios": "^0.27.2", "bull": "^4.8.4", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "cross-env": "^7.0.3", + "crypto-js": "^4.1.1", "express-basic-auth": "^1.2.1", "joi": "^17.6.0", "jsonwebtoken": "^8.5.1", @@ -41,6 +43,7 @@ "rxjs": "^7.2.0", "swagger-ui-express": "^4.4.0", "typeorm": "^0.3.7", + "uuid": "^8.3.2", "winston": "^3.8.1" }, "devDependencies": { @@ -53,6 +56,7 @@ "@types/jsonwebtoken": "^8.5.8", "@types/node": "^16.0.0", "@types/supertest": "^2.0.11", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "codecov": "^3.8.3", @@ -1829,6 +1833,19 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-3.0.0.tgz", + "integrity": "sha512-E5aLstJ1a3yZE6AgcN+BgHLiRd8lonR5E4E4I3wzVHRGfgglHQS1sa2zEUuD/pdzLPlbI8pvVDJom8Z2D1oDug==", + "dependencies": { + "md5": "^2.2.1" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0", + "reflect-metadata": "^0.1.13" + } + }, "node_modules/@nestjs/typeorm": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-8.1.4.tgz", @@ -2151,6 +2168,11 @@ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" }, + "node_modules/@types/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==" + }, "node_modules/@types/eslint": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", @@ -2351,6 +2373,12 @@ "@types/superagent": "*" } }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, "node_modules/@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -3458,6 +3486,14 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -4012,6 +4048,19 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, + "node_modules/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "node_modules/cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", @@ -5848,6 +5897,11 @@ "node": ">=8" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-core-module": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", @@ -7634,6 +7688,16 @@ "tmpl": "1.0.5" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -12195,6 +12259,14 @@ "tslib": "2.4.0" } }, + "@nestjs/throttler": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-3.0.0.tgz", + "integrity": "sha512-E5aLstJ1a3yZE6AgcN+BgHLiRd8lonR5E4E4I3wzVHRGfgglHQS1sa2zEUuD/pdzLPlbI8pvVDJom8Z2D1oDug==", + "requires": { + "md5": "^2.2.1" + } + }, "@nestjs/typeorm": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-8.1.4.tgz", @@ -12462,6 +12534,11 @@ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" }, + "@types/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==" + }, "@types/eslint": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", @@ -12662,6 +12739,12 @@ "@types/superagent": "*" } }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, "@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -13483,6 +13566,11 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==" + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -13914,6 +14002,16 @@ "which": "^2.0.1" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==" + }, + "crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", @@ -15278,6 +15376,11 @@ "binary-extensions": "^2.0.0" } }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "is-core-module": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", @@ -16645,6 +16748,16 @@ "tmpl": "1.0.5" } }, + "md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", diff --git a/package.json b/package.json index 3f9e8c4..f1bad99 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "prebuild": "rimraf dist", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "cross-env NODE_ENV=dev nest start", + "start": "cross-env NODE_ENV=prod nest start", "start:dev": "cross-env NODE_ENV=dev nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "cross-env NODE_ENV=prod node dist/main", @@ -33,14 +33,17 @@ "@nestjs/platform-express": "^8.0.0", "@nestjs/platform-socket.io": "^8.4.7", "@nestjs/swagger": "^5.0.9", + "@nestjs/throttler": "^3.0.0", "@nestjs/typeorm": "^8.1.4", "@nestjs/websockets": "^8.4.7", "@redis/client": "^1.2.0", + "@types/crypto-js": "^4.1.1", "axios": "^0.27.2", "bull": "^4.8.4", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "cross-env": "^7.0.3", + "crypto-js": "^4.1.1", "express-basic-auth": "^1.2.1", "joi": "^17.6.0", "jsonwebtoken": "^8.5.1", @@ -55,6 +58,7 @@ "rxjs": "^7.2.0", "swagger-ui-express": "^4.4.0", "typeorm": "^0.3.7", + "uuid": "^8.3.2", "winston": "^3.8.1" }, "lint-staged": { @@ -72,6 +76,7 @@ "@types/jsonwebtoken": "^8.5.8", "@types/node": "^16.0.0", "@types/supertest": "^2.0.11", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "codecov": "^3.8.3", diff --git a/src/app.module.ts b/src/app.module.ts index 35397a6..af2a395 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,8 @@ import { AllExceptionsFilter } from './common/exceptions/http-exception.filter'; import { APP_FILTER } from '@nestjs/core'; import { DatabaseModule } from './database/database.module'; import { UsersModule } from './users/users.module'; +import { SmsModule } from './sms/sms.module'; +import { ThrottlerModule } from '@nestjs/throttler'; @Module({ imports: [ @@ -34,7 +36,11 @@ import { UsersModule } from './users/users.module'; POSTGRES_PORT: Joi.number().default(5432), POSTGRES_USER: Joi.string().default('gosrock'), POSTGRES_PASSWORD: Joi.string().default('gosrock22th'), - POSTGRES_DB: Joi.string().default('ticket') + POSTGRES_DB: Joi.string().default('ticket'), + NAVER_SERVICE_ID: Joi.string(), + NAVER_ACCESS_KEY: Joi.string(), + NAVER_SECRET_KEY: Joi.string(), + NAVER_CALLER: Joi.string() }) }), BullModule.forRootAsync({ @@ -60,7 +66,12 @@ import { UsersModule } from './users/users.module'; SlackModule, SocketModule, DatabaseModule.forRoot({ isTest: false }), - UsersModule + UsersModule, + SmsModule, + ThrottlerModule.forRoot({ + ttl: process.env.NODE_ENV === 'prod' ? 300 : 60, + limit: 3 + }) ], providers: [ diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 390ffa5..bfb2147 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -21,12 +21,14 @@ import { ResponseRequestValidationDto } from './dtos/RequestValidation.response. import { RequestValidateNumberDto } from './dtos/ValidateNumber.request.dto'; import { ResponseValidateNumberDto } from './dtos/ValidateNumber.response.dto'; import { RegisterTokenGuard } from './guards/RegisterToken.guard'; +import { ThrottlerBehindProxyGuard } from './guards/TrottlerBehindProxy.guard'; @ApiTags('auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} + @UseGuards(ThrottlerBehindProxyGuard) @ApiOperation({ summary: '휴대전화번호 인증번호를 요청한다.' }) @ApiBody({ type: RequestPhoneNumberDto }) @ApiResponse({ @@ -34,6 +36,10 @@ export class AuthController { description: '요청 성공시', type: ResponseRequestValidationDto }) + @ApiResponse({ + status: 429, + description: '과도한 요청을 보낼시에' + }) @Post('message/send') async requestPhoneValidationNumber( @Body() requestPhoneNumberDto: RequestPhoneNumberDto @@ -82,6 +88,7 @@ export class AuthController { ); } + @UseGuards(ThrottlerBehindProxyGuard) @ApiOperation({ summary: '슬랙 인증번호를 발송한다 (관리자 용 )' }) @ApiResponse({ status: 200, @@ -92,6 +99,10 @@ export class AuthController { status: 400, description: '슬랙에 들어와있는 유저가 아닐때 , 어드민 유저가 아닐 때' }) + @ApiResponse({ + status: 429, + description: '과도한 요청을 보낼시에' + }) @ApiBody({ type: RequestAdminSendValidationNumberDto }) @Post('/slack/send') async slackSendValidationNumber( diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index ad41370..7fb163f 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,6 +5,7 @@ import { User } from 'src/database/entities/user.entity'; import { UserRepository } from 'src/database/repositories/user.repository'; import { RedisModule } from 'src/redis/redis.module'; import { SlackModule } from 'src/slack/slack.module'; +import { SmsModule } from 'src/sms/sms.module'; import { UsersModule } from 'src/users/users.module'; import { UsersService } from 'src/users/users.service'; import { AuthController } from './auth.controller'; @@ -14,6 +15,13 @@ import { RegisterTokenGuard } from './guards/RegisterToken.guard'; @Module({ imports: [ + SmsModule.forRootAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + isProd: configService.get('NODE_ENV') === 'prod' ? true : false + }), + inject: [ConfigService] + }), UsersModule, SlackModule, TypeOrmModule.forFeature([User]), diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 35f38bd..4d65399 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -3,11 +3,9 @@ import { Injectable, InternalServerErrorException, Logger, - LoggerService, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { throwIfEmpty } from 'rxjs'; import { User } from 'src/database/entities/user.entity'; import { UserRepository } from 'src/database/repositories/user.repository'; import { RedisService } from 'src/redis/redis.service'; @@ -24,12 +22,13 @@ import { RequestRegisterUserDto } from './dtos/RegisterUser.request.dto'; import { DataSource } from 'typeorm'; import { getConnectedRepository } from 'src/common/funcs/getConnectedRepository'; import { ResponseRegisterUserDto } from './dtos/RegisterUser.response.dto'; -import { classToPlain, instanceToPlain } from 'class-transformer'; import { RequestAdminSendValidationNumberDto } from './dtos/AdminSendValidationNumber.request.dto copy'; import { SlackService } from 'src/slack/slack.service'; import { ResponseAdminSendValidationNumberDto } from './dtos/AdminSendValidationNumber.response.dto'; import { RequestAdminLoginDto } from './dtos/AdminLogin.request.dto'; import { ResponseAdminLoginDto } from './dtos/AdminLogin.response.dto'; +import { SmsService } from 'src/sms/sms.service'; +import { MessageDto } from 'src/sms/dtos/message.dto'; @Injectable() export class AuthService { @@ -39,7 +38,8 @@ export class AuthService { private dataSource: DataSource, private redisSerivce: RedisService, private configService: ConfigService, - private slackService: SlackService + private slackService: SlackService, + private smsService: SmsService ) {} async requestPhoneValidationNumber( @@ -48,10 +48,8 @@ export class AuthService { //TODO : 전화번호 인증번호 발송 로직 추가 , 이찬진 2022.07.14 const userPhoneNumber = requestPhoneNumberDto.phoneNumber; //유저가 이미 회원가입했는지확인한다. - console.log('asdcfasdfasdfdsaf'); const checkSingUpState = await this.checkUserAlreadySignUp(userPhoneNumber); // generate randomNumber - console.log('asdcfasdfasdfdsaf'); const generatedRandomNumber = generateRandomCode(4); // insert to redis @@ -61,6 +59,17 @@ export class AuthService { 180 ); + const message = new MessageDto( + userPhoneNumber, + `고스락 티켓예매\n인증번호 [${generatedRandomNumber}]` + ); + + try { + await this.smsService.sendMessages([message]); + } catch (error) { + console.log(error); + } + return { alreadySingUp: checkSingUpState, validationNumber: generatedRandomNumber, diff --git a/src/auth/guards/TrottlerBehindProxy.guard.ts b/src/auth/guards/TrottlerBehindProxy.guard.ts new file mode 100644 index 0000000..51b2da0 --- /dev/null +++ b/src/auth/guards/TrottlerBehindProxy.guard.ts @@ -0,0 +1,29 @@ +// throttler-behind-proxy.guard.ts +// 고스락 백엔드 서버는 nginx 뒤에 프록시 형태로 연결되어있기 때문에 +// X-Forwarded-For 헤더값을 통해서 +// 요청한 사람의 원래 ip 주소를 가져와야합니다. +import { ThrottlerGuard } from '@nestjs/throttler'; +import { Injectable } from '@nestjs/common'; +import { Request } from 'express'; +import { v4 } from 'uuid'; + +@Injectable() +export class ThrottlerBehindProxyGuard extends ThrottlerGuard { + protected getTracker(req: Request): string { + if (process.env.NODE_ENV === 'prod') { + const clientProxyIps = req.headers['x-forwarded-for']; + if (!clientProxyIps) { + return v4(); + } + if (Array.isArray(clientProxyIps)) { + return clientProxyIps[0]; + } else { + return clientProxyIps; + } + } else { + return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs + } + } +} + +// app.controller.ts diff --git a/src/main.ts b/src/main.ts index 01be366..84cf5ea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,7 @@ async function bootstrap() { format: winston.format.combine( winston.format.timestamp(), nestWinstonModuleUtilities.format.nestLike('TicketBackend', { + colors: true, prettyPrint: true }) ) diff --git a/src/redis/Redis.const.ts b/src/redis/config/Redis.const.ts similarity index 100% rename from src/redis/Redis.const.ts rename to src/redis/config/Redis.const.ts diff --git a/src/redis/RedisAsyncConfig.interface.ts b/src/redis/config/RedisAsyncConfig.interface.ts similarity index 100% rename from src/redis/RedisAsyncConfig.interface.ts rename to src/redis/config/RedisAsyncConfig.interface.ts diff --git a/src/redis/RedisOption.interface.ts b/src/redis/config/RedisOption.interface.ts similarity index 100% rename from src/redis/RedisOption.interface.ts rename to src/redis/config/RedisOption.interface.ts diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts index aea9f7d..0533a7c 100644 --- a/src/redis/redis.module.ts +++ b/src/redis/redis.module.ts @@ -9,9 +9,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { createClient, RedisClientOptions } from 'redis'; import { FakeLogger } from './FakeLogger'; import { RedisService } from './redis.service'; -import { RedisAsyncConfig } from './RedisAsyncConfig.interface'; -import { RedisOption } from './RedisOption.interface'; -import { REDIS_CLIENT_PROVIDER, REDIS_MODULE_OPTIONS } from './Redis.const'; +import { RedisAsyncConfig } from './config/RedisAsyncConfig.interface'; +import { RedisOption } from './config/RedisOption.interface'; +import { + REDIS_CLIENT_PROVIDER, + REDIS_MODULE_OPTIONS +} from './config/Redis.const'; import { RedisTestService } from './redisTest.service'; export type RedisClientType = ReturnType; diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index c74ecdb..759615b 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable, Logger, LoggerService } from '@nestjs/common'; import { RedisClientType } from '@redis/client'; import { createClient } from 'redis'; import { ValidationNumberDto } from './dtos/ValidationNumber.dto'; -import { REDIS_CLIENT_PROVIDER } from './Redis.const'; +import { REDIS_CLIENT_PROVIDER } from './config/Redis.const'; @Injectable() export class RedisService { diff --git a/src/slack/slack.service.ts b/src/slack/slack.service.ts index 2097ab7..4be9229 100644 --- a/src/slack/slack.service.ts +++ b/src/slack/slack.service.ts @@ -1,6 +1,7 @@ import { HttpService } from '@nestjs/axios'; import { Injectable, Logger } from '@nestjs/common'; import { lastValueFrom, map } from 'rxjs'; +import { NaverError } from 'src/sms/SMSError'; @Injectable() export class SlackService { constructor(private readonly httpService: HttpService) {} @@ -22,8 +23,8 @@ export class SlackService { return data.user.id; } catch (error) { - Logger.log(error); - return null; + Logger.log(error.response.data); + throw new NaverError('문자발송실패', error.response.data); } } diff --git a/src/sms/SMSError.ts b/src/sms/SMSError.ts new file mode 100644 index 0000000..a16a3fc --- /dev/null +++ b/src/sms/SMSError.ts @@ -0,0 +1,12 @@ +export class NaverError extends Error { + constructor(error, stack) { + super(error); + this.name = error; + this.message = error; + this.stack = JSON.stringify(stack); + + Object.setPrototypeOf(this, NaverError.prototype); + } + + stack?: string | undefined; +} diff --git a/src/sms/config/SMS.const.ts b/src/sms/config/SMS.const.ts new file mode 100644 index 0000000..f21cee7 --- /dev/null +++ b/src/sms/config/SMS.const.ts @@ -0,0 +1 @@ +export const SMS_MODULE_OPTIONS = 'SMS_MODULE_OPTIONS'; diff --git a/src/sms/config/SMSAsyncConfig.interface.ts b/src/sms/config/SMSAsyncConfig.interface.ts new file mode 100644 index 0000000..807d979 --- /dev/null +++ b/src/sms/config/SMSAsyncConfig.interface.ts @@ -0,0 +1,13 @@ +import { FactoryProvider, ModuleMetadata } from '@nestjs/common'; +import { SMSOption } from './sms.config.interface'; + +export interface SMSAsyncConfig extends Pick { + /** + * Factory function that returns an instance of the provider to be injected. + */ + useFactory: (...args: any[]) => Promise; + /** + * Optional list of providers to be injected into the context of the Factory function. + */ + inject: FactoryProvider['inject']; +} diff --git a/src/sms/config/sms.config.interface.ts b/src/sms/config/sms.config.interface.ts new file mode 100644 index 0000000..84d0c32 --- /dev/null +++ b/src/sms/config/sms.config.interface.ts @@ -0,0 +1,9 @@ +/** + * sms 모듈을 임포트할때 설정하는 옵션 + * 2022-07-19 이찬진 + */ +export interface SMSOption { + // 실제로 메시지를 보내는 것을 원치 않으면 + // 실 서버에서만 문자를 보내도록 함 + isProd: boolean; +} diff --git a/src/sms/dtos/message.dto.ts b/src/sms/dtos/message.dto.ts new file mode 100644 index 0000000..646477e --- /dev/null +++ b/src/sms/dtos/message.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class MessageDto { + /** + * 메시지를 보낼 Dto + * @param to 보낼 사람 전화번호 - 없이 + * @param content 보낼 내용 + */ + constructor(to: string, content: string) { + this.to = to; + this.content = content; + } + + @ApiProperty({ + description: '메시지 컨텐츠 내용.', + type: String + }) + @Expose() + content: string; + + @ApiProperty({ description: '수신번호', type: String }) + @Expose() + to: string; +} diff --git a/src/sms/dtos/sendSMS.dto.ts b/src/sms/dtos/sendSMS.dto.ts new file mode 100644 index 0000000..d908ca9 --- /dev/null +++ b/src/sms/dtos/sendSMS.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { MessageDto } from './message.dto'; + +export class SendSMSDto { + constructor(from: string, messages: MessageDto[]) { + this.from = from; + this.messages = messages; + } + // SMS 만 허용 고스락 프로젝트는 + @Expose() + type = 'SMS'; + + // 일반메시지 if AD : 광고메시지 + @Expose() + contentType = 'COMM'; + @Expose() + content = '기본메시지내용'; + // 전화번호 인증 번호 + @Expose() + from: string; + + // 메시지 배열 + @Expose() + @Type(() => MessageDto) + messages: MessageDto[]; +} diff --git a/src/sms/sms.module.ts b/src/sms/sms.module.ts new file mode 100644 index 0000000..edd1e90 --- /dev/null +++ b/src/sms/sms.module.ts @@ -0,0 +1,90 @@ +import { HttpModule, HttpService } from '@nestjs/axios'; +import { DynamicModule, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +import { SMSOption } from './config/sms.config.interface'; +import { SMS_MODULE_OPTIONS } from './config/SMS.const'; +import { SMSAsyncConfig } from './config/SMSAsyncConfig.interface'; + +import { SmsService } from './sms.service'; +import { SmsFakeService } from './smsFake.service'; + +@Module({}) +export class SmsModule { + /** + * 문자인증모듈의 forRoot 함수를 통해 실 문자 보낼지를 정할 수있습니다. + * @param smsOption 실제로 문자를 보낼지 안보낼지에 true false 로 껏다킵니다. 개발환경과,테스팅환경은 초기 확인 이후 꺼놓아주시길 바랍니다. + * @returns + */ + static forRoot(smsOption: SMSOption): DynamicModule { + return { + module: SmsModule, + imports: [ + HttpModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + baseURL: 'https://sens.apigw.ntruss.com/sms/v2/services/', + headers: { + 'Content-type': 'application/json; charset=utf-8', + 'x-ncp-iam-access-key': '' + configService.get('NAVER_ACCESS_KEY') + } + }), + inject: [ConfigService] + }) + ], + providers: [ + { + provide: SmsService, + useClass: smsOption.isProd ? SmsService : SmsFakeService + } + ], + exports: [SmsService] + }; + } + + /** + * configService 주입을 통해서 문자 모듈을 임포트 시킬 수있습니다. + * SMSoption 을 설정해 주시면 됩니다. + * @param smsAsyncConfig + * @returns + */ + static forRootAsync(smsAsyncConfig: SMSAsyncConfig): DynamicModule { + return { + module: SmsModule, + imports: [ + HttpModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + baseURL: 'https://sens.apigw.ntruss.com/sms/v2/services/', + headers: { + 'Content-type': 'application/json; charset=utf-8', + 'x-ncp-iam-access-key': '' + configService.get('NAVER_ACCESS_KEY') + } + }), + inject: [ConfigService] + }) + ], + providers: [ + { + provide: SMS_MODULE_OPTIONS, + useFactory: smsAsyncConfig.useFactory, + inject: smsAsyncConfig.inject || [] + }, + { + provide: SmsService, + useFactory: async ( + options: SMSOption, + configService: ConfigService, + httpService: HttpService + ) => { + return options.isProd + ? new SmsService(configService, httpService) + : new SmsFakeService(); + }, + inject: [SMS_MODULE_OPTIONS, ConfigService, HttpService] + } + ], + exports: [SmsService] + }; + } +} diff --git a/src/sms/sms.service.spec.ts b/src/sms/sms.service.spec.ts new file mode 100644 index 0000000..4085265 --- /dev/null +++ b/src/sms/sms.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SmsService } from './sms.service'; + +describe('SmsService', () => { + let service: SmsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SmsService], + }).compile(); + + service = module.get(SmsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts new file mode 100644 index 0000000..3cda910 --- /dev/null +++ b/src/sms/sms.service.ts @@ -0,0 +1,73 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { lastValueFrom, map } from 'rxjs'; +import { MessageDto } from './dtos/message.dto'; +import { SendSMSDto } from './dtos/sendSMS.dto'; +import * as CryptoJS from 'crypto-js'; +import { NaverError } from './SMSError'; +@Injectable() +export class SmsService { + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService + ) {} + + /** + * 문자를 보내기위한 함수입니다. + * @param messages MessageDto[]를 인자로 받습니다. 단건 메시지인경우 [MessageDto] 로 보내시면됩니다. + * @returns 리턴값이 없습니다. + */ + async sendMessages(messages: MessageDto[]) { + const serviceId = this.configService.get('NAVER_SERVICE_ID'); + const caller = this.configService.get('NAVER_CALLER'); + const sendSmsDto = new SendSMSDto(caller, messages); + const date = Date.now().toString(); + const signature = this.makeSignature(serviceId, 'POST', date); + + Logger.log('실제 문자메시지 전송' + JSON.stringify(messages), 'SmsService'); + + try { + const data = await lastValueFrom( + this.httpService + .post(`/${serviceId}/messages`, sendSmsDto, { + headers: { + 'x-ncp-apigw-signature-v2': signature, //401 + 'x-ncp-apigw-timestamp': date //401 + } + }) + .pipe(map(response => response.data)) + ); + + if (data.ok !== true) { + return null; + } + } catch (error) { + Logger.log(error.response.data); + throw new NaverError('문자발송실패', error.response.data); + } + } + + private makeSignature( + serviceId: string, + method: string, + date: string + ): string { + const secretKey = this.configService.get('NAVER_SECRET_KEY'); // Secret Key + const accessKey = this.configService.get('NAVER_ACCESS_KEY'); //Access Key + const space = ' '; + const newLine = '\n'; + const url = `/sms/v2/services/${serviceId}/messages`; + const hmac = CryptoJS.algo.HMAC.create(CryptoJS.algo.SHA256, secretKey); + hmac.update(method); + hmac.update(space); + hmac.update(url); + hmac.update(newLine); + hmac.update(date); + hmac.update(newLine); + hmac.update(accessKey); + const hash = hmac.finalize(); + const signature = hash.toString(CryptoJS.enc.Base64); + return signature; + } +} diff --git a/src/sms/smsFake.service.ts b/src/sms/smsFake.service.ts new file mode 100644 index 0000000..18b56df --- /dev/null +++ b/src/sms/smsFake.service.ts @@ -0,0 +1,21 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { MessageDto } from './dtos/message.dto'; + +@Injectable() +export class SmsFakeService { + /** + * fake 서비스의 메시지보내는 모듈 아무일도 안한다. + * @param messages + * @returns + */ + async sendMessages(messages: MessageDto[]) { + Logger.log( + '가짜 문자메시지 전송' + JSON.stringify(messages), + 'SmsFakeService' + ); + return; + } +}