diff --git a/.env.sample b/.env.sample index 97ba36978d92576c2497e8527c02eb83234bba8f..f7856c2a57d0b3de1da61357ead48f7ed47469cc 100644 --- a/.env.sample +++ b/.env.sample @@ -7,6 +7,16 @@ GUPSHUP_USERNAME="2000xxxxxxx" GUPSHUP_PASSWORD="password" GUPSHUP_OTP_TEMPLATE="Hi User! The OTP to reset password for Samagra Shiksha App is %code%." +# CDAC +CDAC_SERVICE_URL= +CDAC_OTP_TEMPLATE_ID="123456" +CDAC_OTP_TEMPLATE="Respected User, The OTP to reset password for %phone% is %code%." + +# SMS Adapter +SMS_ADAPTER_TYPE= # CDAC or GUPSHUP +SMS_TOTP_SECRET= # any random string, needed for CDAC +SMS_TOTP_EXPIRY=600 # in seconds, needed for CDAC + # Fusionauth FUSIONAUTH_APPLICATION_ID="f0ddb3f6-091b-45e4-8c0f-889f89d4f5da" FUSIONAUTH_SAMARTH_HP_APPLICATION_ID=f18c3f6f-45b8-4928-b978-a9906fd03f22 @@ -14,6 +24,6 @@ FUSIONAUTH_HP_ADMIN_CONSOLE_APPLICATION_ID= FUSIONAUTH_BASE_URL="https://auth.samarth.samagra.io" FUSIONAUTH_API_KEY="bla" ENCRYPTION_KEY="bla" -FUSIONAUTH_ADMIN_SEARCH_APPLICATION_IDS=["1", "2"] # JSON array of application IDs +FUSIONAUTH_ADMIN_SEARCH_APPLICATION_IDS=["1","2"] # JSON array of application IDs # APP_abcd3f6f_45b8_4928_b978_a9906fd03f22={"host": "dummy.com", "encryption": {"enabled": true, "key": "veryhardkey"}, "salt": "sampl-salt-for-otp-encrypption"} # the key "application_id" must be underscore(_) separated instead of hyphen(-). Also it must be prefixed with <APP_> \ No newline at end of file diff --git a/package.json b/package.json index e9e2361ffedfc5430d63cebb561dc9152f8ec06d..5b18f7bb9c7f14b95614bf617f74437b29c8d1b3 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.8.0", + "speakeasy": "^2.0.0", "swagger-ui-express": "^4.1.6", "uuid": "^8.3.2" }, diff --git a/src/api/api.module.ts b/src/api/api.module.ts index ea5ec0b3ede7084d5b45b59abcb901f17593b28c..7c4276d95a0b4e318142d16cb14197b42e62a281 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -10,24 +10,35 @@ import { OtpService } from './otp/otp.service'; import { GupshupService } from './sms/gupshup/gupshup.service'; import { SmsService } from './sms/sms.service'; import got from 'got/dist/source'; - -const gupshupFactory = { - provide: 'OtpService', - useFactory: (username, password, baseUrl) => { - return new GupshupService( - username, - password, - baseUrl, - got, - ); - }, - inject: [], -}; +import { CdacService } from './sms/cdac/cdac.service'; const otpServiceFactory = { provide: OtpService, useFactory: (config: ConfigService) => { - return new OtpService(gupshupFactory.useFactory(config.get('GUPSHUP_USERNAME'), config.get('GUPSHUP_PASSWORD'), config.get('GUPSHUP_BASEURL'),)); + let factory; + if (config.get<string>('SMS_ADAPTER_TYPE') == 'CDAC') { + factory = { + provide: 'OtpService', + useFactory: () => { + return new CdacService(config); + }, + inject: [], + }.useFactory(); + } else { + factory = { + provide: 'OtpService', + useFactory: (username, password, baseUrl) => { + return new GupshupService( + username, + password, + baseUrl, + got, + ); + }, + inject: [], + }.useFactory(config.get('GUPSHUP_USERNAME'), config.get('GUPSHUP_PASSWORD'), config.get('GUPSHUP_BASEURL')); + } + return new OtpService(factory); }, inject: [ConfigService], }; @@ -35,6 +46,7 @@ const otpServiceFactory = { @Module({ imports: [HttpModule, ConfigModule], controllers: [ApiController], - providers: [ApiService, FusionauthService, SmsService, otpServiceFactory, QueryGeneratorService, ConfigResolverService] + providers: [ApiService, FusionauthService, SmsService, otpServiceFactory, QueryGeneratorService, ConfigResolverService], }) -export class ApiModule {} +export class ApiModule { +} diff --git a/src/api/sms/cdac/cdac.service.spec.ts b/src/api/sms/cdac/cdac.service.spec.ts index 103ede18e58cabbfed6fa774a591afc5bcadb478..8baae1d6f8315e0fec81ddb661a023bd5b50e039 100644 --- a/src/api/sms/cdac/cdac.service.spec.ts +++ b/src/api/sms/cdac/cdac.service.spec.ts @@ -1,12 +1,30 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CdacService } from './cdac.service'; +import { ConfigService } from '@nestjs/config'; +import { OtpService } from '../../otp/otp.service'; + +const otpServiceFactory = { + provide: OtpService, + useFactory: (config: ConfigService) => { + let factory; + factory = { + provide: 'OtpService', + useFactory: () => { + return new CdacService(config); + }, + inject: [], + }.useFactory(); + return new OtpService(factory); + }, + inject: [ConfigService], +}; describe('CdacService', () => { let service: CdacService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [CdacService], + providers: [ConfigService, CdacService, otpServiceFactory], }).compile(); service = module.get<CdacService>(CdacService); @@ -14,5 +32,6 @@ describe('CdacService', () => { it('should be defined', () => { expect(service).toBeDefined(); + expect(service).toBeDefined(); }); }); diff --git a/src/api/sms/cdac/cdac.service.ts b/src/api/sms/cdac/cdac.service.ts index 02edba95c9bb3ffc9086c75ebb0505c20cc9f0ef..2c1ee26710b860a3c81200f3b6f2c35b0d919faf 100644 --- a/src/api/sms/cdac/cdac.service.ts +++ b/src/api/sms/cdac/cdac.service.ts @@ -1,82 +1,196 @@ -import { SMS, SMSResponse } from '../sms.interface'; +import { + OTPResponse, + SMS, + SMSData, + SMSError, + SMSProvider, + SMSResponse, + SMSResponseStatus, + SMSType, + TrackResponse, +} from '../sms.interface'; -import { Injectable } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import { SmsService } from '../sms.service'; -import fetch from 'node-fetch'; +import { ConfigService } from '@nestjs/config'; +import got, {Got} from 'got'; +import * as speakeasy from 'speakeasy'; @Injectable() export class CdacService extends SmsService implements SMS { - send(id: any): Promise<SMSResponse> { - console.log(id); - throw new Error('Method not implemented.'); + baseURL: string; + path = ''; + data: SMSData; + httpClient: Got; + + constructor( + private configService: ConfigService, + ) { + super(); + this.baseURL = configService.get<string>('CDAC_SERVICE_URL'); + this.httpClient = got; } - track(id: any): Promise<SMSResponse> { - console.log(id); - throw new Error('Method not implemented.'); + send(data: SMSData): Promise<SMSResponse> { + if (!data) { + throw new Error('Data cannot be empty'); + } + this.data = data; + if (this.data.type === SMSType.otp) return this.doOTPRequest(data); + else return this.doRequest(); + } + + track(data: SMSData): Promise<SMSResponse> { + if (!data) { + throw new Error('Data cannot be null'); + } + this.data = data; + if (this.data.type === SMSType.otp) return this.verifyOTP(data); + else return this.doRequest(); } - convertFromUnicodeToText = (message) => { - let finalMessage = ''; - console.log(message); - for (let i = 0; i < message.length; i++) { - const ch = message.charCodeAt(i); - const j = ch; - const sss = '&#' + j + ';'; - finalMessage = finalMessage + sss; + private getTotpSecret(phone): string { + return `${this.configService.get<string>('SMS_TOTP_SECRET')}${phone}` + } + + private doOTPRequest(data: SMSData): Promise<any> { + let otp = ''; + try { + otp = speakeasy.totp({ + secret: this.getTotpSecret(data.phone), + encoding: 'base32', + step: this.configService.get<string>('SMS_TOTP_EXPIRY'), + }); + } catch (error) { + console.log(error); + throw new HttpException('TOTP generation failed!', 500); } - return finalMessage; - }; - sendSingleSMS = async (params) => { - const url = `https://msdgweb.mgov.gov.in/esms/sendsmsrequest?`; - const esc = encodeURIComponent; - const query = Object.keys(params) - .map((k) => esc(k) + '=' + esc(params[k])) - .join('&'); + const payload = this.configService.get<string>('CDAC_OTP_TEMPLATE') + .replace('%phone%', data.phone) + .replace('%code%', otp + ''); + const params = new URLSearchParams({ + message: payload, + mobileNumber: data.phone, + templateid: this.configService.get<string>('CDAC_OTP_TEMPLATE_ID'), + }); + this.path = '/api/send_otp_sms' + const url = `${this.baseURL}${this.path}?${params.toString()}`; - // console.log('Sending SMS now'); - // console.log(query); - return fetch(url + query, { - method: 'POST', - timeout: 15000, - }) - .then((r) => { - // console.log('Request finished for: '); - const text = r.text(); - return text; + const status: OTPResponse = {} as OTPResponse; + status.provider = SMSProvider.cdac; + status.phone = data.phone; + + // noinspection DuplicatedCode + return this.httpClient.get(url, {}) + .then((response): OTPResponse => { + status.networkResponseCode = 200; + const r = this.parseResponse(response.body); + status.messageID = r.messageID; + status.error = r.error; + status.providerResponseCode = r.providerResponseCode; + status.providerSuccessResponse = r.providerSuccessResponse; + status.status = r.status; + return status; }) - .catch((e) => { - console.log(e); - return '498, '; + .catch((e: Error): OTPResponse => { + const error: SMSError = { + errorText: `Uncaught Exception :: ${e.message}`, + errorCode: 'CUSTOM ERROR', + }; + status.networkResponseCode = 200; + status.messageID = null; + status.error = error; + status.providerResponseCode = null; + status.providerSuccessResponse = null; + status.status = SMSResponseStatus.failure; + return status; }); - }; + } - parseSMSResponse = (response) => { - // console.log(response); - let s; - // Case for error => 'ERROR : 406 Invalid mobile number\r\n' - try { - s = response.split(':'); - return { - responseCode: parseInt(s[1].trim().split(' ')[0]), - messageID: undefined, - }; - } catch (e) {} + doRequest(): Promise<SMSResponse> { + throw new Error('Method not implemented.'); + } - // Case for normal response => '402,MsgID = 070820191565174573847hpgovt-hpssa\r\n' - s = response.split(','); + parseResponse(response: string) { try { - const messageID = s[1].split('=')[1].trim(); - return { - responseCode: parseInt(s[0]), - messageID: messageID, - }; + const responseCode: string = response.slice(0, 3); + if (responseCode === '402') { + return { + providerResponseCode: null, + status: SMSResponseStatus.success, + messageID: response.slice(12, -1), + error: null, + providerSuccessResponse: null, + }; + } else { + const error: SMSError = { + errorText: 'CDAC Error', + errorCode: responseCode, + }; + return { + providerResponseCode: responseCode, + status: SMSResponseStatus.failure, + messageID: null, + error, + providerSuccessResponse: null, + }; + } } catch (e) { + const error: SMSError = { + errorText: `CDAC response could not be parsed :: ${e.message}; Provider Response - ${response}`, + errorCode: 'CUSTOM ERROR', + }; return { - responseCode: parseInt(s[0]), - messageID: undefined, + providerResponseCode: null, + status: SMSResponseStatus.failure, + messageID: null, + error, + providerSuccessResponse: null, }; } - }; + } + + verifyOTP(data: SMSData): Promise<TrackResponse> { + let verified = false; + try { + verified = speakeasy.totp.verify({ + secret: this.getTotpSecret(data.phone), + encoding: 'base32', + token: data.params.otp, + step: this.configService.get<string>('SMS_TOTP_EXPIRY'), + }); + if (verified) { + return new Promise(resolve => { + const status: TrackResponse = {} as TrackResponse; + status.provider = SMSProvider.cdac; + status.phone = data.phone; + status.messageID = ''; + status.error = null; + status.providerResponseCode = null; + status.providerSuccessResponse = null; + status.status = SMSResponseStatus.success; + resolve(status); + }); + } else { + return new Promise(resolve => { + const status: TrackResponse = {} as TrackResponse; + status.provider = SMSProvider.cdac; + status.phone = data.phone; + status.networkResponseCode = 200; + status.messageID = ''; + status.error = { + errorText: 'Invalid or expired OTP.', + errorCode: '400' + }; + status.providerResponseCode = '400'; + status.providerSuccessResponse = null; + status.status = SMSResponseStatus.failure; + resolve(status); + }); + } + } catch(error) { + throw new HttpException(error, 500); + } + } } diff --git a/src/auth/auth-jwt.guard.ts b/src/auth/auth-jwt.guard.ts index 09ba72b56f3668108b09d4dc78ff6ff00bb614da..dd26ff8227ba194557737e8e56611f793204b5ee 100644 --- a/src/auth/auth-jwt.guard.ts +++ b/src/auth/auth-jwt.guard.ts @@ -1,10 +1,8 @@ import { ExecutionContext, Injectable, - UnauthorizedException, } from '@nestjs/common'; import { AuthGuard, IAuthGuard } from '@nestjs/passport'; - import { JwtService } from '@nestjs/jwt' import { Reflector } from '@nestjs/core'; @Injectable() @@ -36,8 +34,8 @@ import { return isAllowed; } + // noinspection JSUnusedLocalSymbols handleRequest(err, user, info) { - console.log({handleRequest: info, error: err, user: user}) return user; } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c3bba2902f3bee2830618d60691c35aedd807e0b..e433aa364b873dde8b86a23bd5ed9f659e2e49e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2040,6 +2040,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base32.js@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.0.1.tgz#d045736a57b1f6c139f0c7df42518a84e91bb2ba" + integrity sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ== + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -6086,6 +6091,13 @@ spdx-license-ids@^3.0.0: resolved "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz" integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA== +speakeasy@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/speakeasy/-/speakeasy-2.0.0.tgz#85c91a071b09a5cb8642590d983566165f57613a" + integrity sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw== + dependencies: + base32.js "0.0.1" + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"