diff --git a/.deploy/deployment.yaml b/.deploy/deployment.yaml index 052958ef..714f9491 100644 --- a/.deploy/deployment.yaml +++ b/.deploy/deployment.yaml @@ -87,6 +87,8 @@ spec: value: __MAIL_QUEUE__ - name: SERVER_NAME value: __SERVER_NAME__ + - name: NODE_ENV + value: __NODE_ENV__ - name: ALLOWED_ORIGIN value: "__ALLOWED_ORIGIN__" - name: JWT_REFRESH_SECRET @@ -106,7 +108,16 @@ spec: - name: OTP_EXPIRY_MINUTES value: '__OTP_EXPIRY_MINUTES__' - name: MAX_RETRY_ATTEMPT - value: '__MAX_RETRY_ATTEMPT__' + value: '__MAX_RETRY_ATTEMPT__' + - name: MFA_REDIRECT_URL + value: __MFA_REDIRECT_URL__ + - name: CLIENT_APP_URL + value: __CLIENT_APP_URL__ + - name: KYC_WIDGET_URL + value: __KYC_WIDGET_URL__ + - name: MAX_MFA_RETRY_ATTEMPT + value: '__MAX_MFA_RETRY_ATTEMPT__' + volumeMounts: - name: mongo mountPath: "/data" diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index ced9a817..e8263b31 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -147,5 +147,13 @@ jobs: run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__OTP_EXPIRY_MINUTES__#${{ secrets.OTP_EXPIRY_MINUTES }}#" {} \; - name: "Replace Secrets" run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__MAX_RETRY_ATTEMPT__#${{ secrets.MAX_RETRY_ATTEMPT }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__MAX_MFA_RETRY_ATTEMPT__#${{ secrets.MAX_MFA_RETRY_ATTEMPT }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__MFA_REDIRECT_URL__#${{ secrets.MFA_REDIRECT_URL }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__CLIENT_APP_URL__#${{ secrets.CLIENT_APP_URL }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__KYC_WIDGET_URL__#${{ secrets.KYC_WIDGET_URL }}#" {} \; - name: "Deploy to GKE" run: kubectl apply -f .deploy/deployment.yaml diff --git a/dev.env.sample b/dev.env.sample index bec093dd..2a6d2e7a 100644 --- a/dev.env.sample +++ b/dev.env.sample @@ -23,6 +23,10 @@ OTP_HOURLY_LIMIT=10 OTP_EXPIRY_MINUTES=5 MAX_RETRY_ATTEMPT=3 NODE_ENV=development +MFA_REDIRECT_URL='http://localhost:9001/#/studio/mfa' +MAX_MFA_RETRY_ATTEMPT=3 +CLIENT_APP_URL=https://entity.dashboard.hypersign.id +KYC_WIDGET_URL=https://verify.hypersign.id diff --git a/src/app-auth/app-auth.module.ts b/src/app-auth/app-auth.module.ts index dad42a7e..7c9158f0 100644 --- a/src/app-auth/app-auth.module.ts +++ b/src/app-auth/app-auth.module.ts @@ -23,7 +23,6 @@ import { SupportedServiceService } from 'src/supported-service/services/supporte import { SupportedServiceList } from 'src/supported-service/services/service-list'; import { JWTAuthorizeMiddleware } from 'src/utils/middleware/jwt-authorization.middleware'; import { UserModule } from 'src/user/user.module'; -import { TwoFAAuthorizationMiddleware } from 'src/utils/middleware/2FA-jwt-authorization.middleware'; import { CreditModule } from 'src/credits/credits.module'; import { JWTAccessAccountMiddleware } from 'src/utils/middleware/jwt-accessAccount.middlerwere'; import { AdminPeopleRepository } from 'src/people/repository/people.repository'; @@ -32,6 +31,10 @@ import { AdminPeopleSchema, } from 'src/people/schema/people.schema'; import { RateLimitMiddleware } from 'src/utils/middleware/rate-limit.middleware'; +import { + CustomerOnboarding, + CustomerOnboardingSchema, +} from 'src/customer-onboarding/schemas/customer-onboarding.schema'; import { WebpageConfigModule } from 'src/webpage-config/webpage-config.module'; @Module({ @@ -40,6 +43,9 @@ import { WebpageConfigModule } from 'src/webpage-config/webpage-config.module'; MongooseModule.forFeature([ { name: AdminPeople.name, schema: AdminPeopleSchema }, ]), + MongooseModule.forFeature([ + { name: CustomerOnboarding.name, schema: CustomerOnboardingSchema }, + ]), HidWalletModule, EdvModule, UserModule, @@ -47,6 +53,7 @@ import { WebpageConfigModule } from 'src/webpage-config/webpage-config.module'; CreditModule, forwardRef(() => WebpageConfigModule), ], + providers: [ AppAuthService, AppRepository, @@ -80,10 +87,6 @@ export class AppAuthModule implements NestModule { .apply(JWTAccessAccountMiddleware) .exclude({ path: '/api/v1/app/marketplace', method: RequestMethod.GET }) .forRoutes(AppAuthController); - consumer - .apply(TwoFAAuthorizationMiddleware) - .exclude({ path: '/api/v1/app/marketplace', method: RequestMethod.GET }) - .forRoutes(AppAuthController); consumer.apply(RateLimitMiddleware).forRoutes(AppAuthController); } } diff --git a/src/app-auth/dtos/create-app.dto.ts b/src/app-auth/dtos/create-app.dto.ts index 92832536..27ddc99a 100644 --- a/src/app-auth/dtos/create-app.dto.ts +++ b/src/app-auth/dtos/create-app.dto.ts @@ -19,6 +19,7 @@ import { SERVICE_TYPES, APP_ENVIRONMENT, } from 'src/supported-service/services/iServiceList'; +import { IsUrlOrBase64Image } from 'src/utils/customDecorator/IsUrlOrBase64Image.decorator'; export class CreateAppDto { @ApiProperty({ @@ -60,7 +61,7 @@ export class CreateAppDto { }) @IsOptional() @IsString() - @IsUrlEmpty() + @IsUrlOrBase64Image() logoUrl?: string; @ApiProperty({ description: 'services', diff --git a/src/app-auth/services/app-auth.service.ts b/src/app-auth/services/app-auth.service.ts index 6e647a5d..23f4bf81 100644 --- a/src/app-auth/services/app-auth.service.ts +++ b/src/app-auth/services/app-auth.service.ts @@ -22,6 +22,7 @@ import * as url from 'url'; import { SupportedServiceService } from 'src/supported-service/services/supported-service.service'; import { APP_ENVIRONMENT, + Context, SERVICE_TYPES, } from 'src/supported-service/services/iServiceList'; import { UserRepository } from 'src/user/repository/user.repository'; @@ -30,6 +31,17 @@ import { AuthZCreditsRepository } from 'src/credits/repositories/authz.repositor import { EdvClientKeysManager } from 'src/edv/services/edv.singleton'; import { UserRole } from 'src/user/schema/user.schema'; import { WebPageConfigRepository } from 'src/webpage-config/repositories/webpage-config.repository'; +import { InjectModel } from '@nestjs/mongoose'; +import { CustomerOnboarding } from 'src/customer-onboarding/schemas/customer-onboarding.schema'; +import { Model } from 'mongoose'; +import { + evaluateAccessPolicy, + generateHash, + getAccessListForModule, +} from 'src/utils/utils'; +import { TokenModule } from 'src/config/access-matrix'; +import { redisClient } from 'src/utils/redis.provider'; +import { TIME } from 'src/utils/time-constant'; export enum GRANT_TYPES { access_service_kyc = 'access_service_kyc', @@ -53,6 +65,8 @@ export class AppAuthService { private readonly userRepository: UserRepository, private readonly authzCreditService: AuthzCreditService, private readonly authzCreditRepository: AuthZCreditsRepository, + @InjectModel(CustomerOnboarding.name) + private readonly onboardModel: Model, private readonly webpageConfigRepo: WebPageConfigRepository, ) {} @@ -534,17 +548,47 @@ export class AppAuthService { { appId, userId }, updataAppDto, ); - // update webpage detail - if ((app.services[0].id = SERVICE_TYPES.CAVACH_API)) { - this.updateWebPageConfigDetail(app, userDetail).catch((err) => { - Logger.error( - `updateWebPageConfigDetail failed for ${appId}: ${err.message}`, - err.stack, - ); - }); + const updatedapp = await this.getAppResponse(app); + // update redis + const baseKey = generateHash(appId); + const dashboardRedisKey = generateHash(`${appId}_${Context.idDashboard}`); + const updatedFields = { + whitelistedCors: updatedapp.whitelistedCors, + env: updatedapp.env ?? APP_ENVIRONMENT.dev, + appName: updatedapp.appName, + }; + + const [baseDataString, dashboardDataString] = await Promise.all([ + redisClient.get(baseKey), + redisClient.get(dashboardRedisKey), + ]); + + const updatePromises = []; + + if (baseDataString) { + const baseData = JSON.parse(baseDataString); + const updatedBase = { ...baseData, ...updatedFields }; + updatePromises.push( + redisClient.set(baseKey, JSON.stringify(updatedBase), 'KEEPTTL'), + ); + } + + if (dashboardDataString) { + const dashboardData = JSON.parse(dashboardDataString); + const updatedDashboard = { ...dashboardData, ...updatedFields }; + updatePromises.push( + redisClient.set( + dashboardRedisKey, + JSON.stringify(updatedDashboard), + 'KEEPTTL', + ), + ); } - return this.getAppResponse(app); + if (updatePromises.length > 0) { + await Promise.all(updatePromises); + } + return updatedapp; } async deleteApp(appId: string, userId: string): Promise { @@ -611,8 +655,23 @@ export class AppAuthService { } const appDbConnectionSuffix = `service:${appDetail.services[0].dBSuffix}:${appDetail.subdomain}`; await this.appRepository.findAndDeleteServiceDB(appDbConnectionSuffix); + if ( + appDetail?.services?.length > 0 && + appDetail.services[0].id === SERVICE_TYPES.CAVACH_API + ) { + // delete onboarding data + await this.onboardModel.deleteOne({ kycServiceId: appId }); + // delete webpage config data of that service + await this.webpageConfigRepo.findOneAndDelete({ appId }); + } this.authzCreditRepository.deleteAuthzDetail({ appId }); appDetail = await this.appRepository.findOneAndDelete({ appId, userId }); + // delete from redis + await Promise.all([ + redisClient.del(generateHash(appId)), + redisClient.del(generateHash(`${appId}_${Context.idDashboard}`)), + ]); + Logger.debug(`Redis cache cleaned for appId: ${appId}`); return { appId: appDetail.appId }; } @@ -637,9 +696,7 @@ export class AppAuthService { grantType, ): Promise<{ access_token; expiresIn; tokenType }> { Logger.log('generateAccessToken() method: starts....', 'AppAuthService'); - const apikeyIndex = appSecreatKey.split('.')[0]; - const appDetail = await this.appRepository.findOne({ apiKeyPrefix: apikeyIndex, }); @@ -677,20 +734,32 @@ export class AppAuthService { const serviceType = appDetail.services[0]?.id; // TODO: remove this later let grant_type = ''; let accessList = []; + const redisKey = generateHash(appDetail.appId); + const savedSession = await redisClient.get(redisKey); + if (savedSession) { + Logger.log('Using redis cached session', 'AppAuthService'); + const sessionJson = JSON.parse(savedSession); + const jwtPayload = { + appId: sessionJson.appId, + appName: sessionJson.appName, + grantType: sessionJson.grantType, + subdomain: sessionJson.subdomain, + sessionId: redisKey, + }; + return this.getAccessToken(jwtPayload, expiresin); + } switch (serviceType) { case SERVICE_TYPES.SSI_API: { grant_type = GRANT_TYPES.access_service_ssi; - if (userDetails.accessList && userDetails.accessList.length > 0) { - accessList = userDetails.accessList - .map((x) => { - if (x.serviceType === SERVICE_TYPES.SSI_API) { - if (!this.checkIfDateExpired(x.expiryDate)) { - return x.access; - } - } - }) - .filter((x) => x != undefined); - } + const defaultAccessList = getAccessListForModule( + TokenModule.APP_AUTH, + SERVICE_TYPES.SSI_API, + ); + accessList = evaluateAccessPolicy( + defaultAccessList, + SERVICE_TYPES.SSI_API, + [], + ); break; } case SERVICE_TYPES.CAVACH_API: { @@ -704,32 +773,28 @@ export class AppAuthService { ]); } grant_type = grantType || GRANT_TYPES.access_service_kyc; - if (userDetails.accessList && userDetails.accessList.length > 0) { - accessList = userDetails.accessList - .map((x) => { - if (x.serviceType === SERVICE_TYPES.CAVACH_API) { - if (!this.checkIfDateExpired(x.expiryDate)) { - return x.access; - } - } - }) - .filter((x) => x != undefined); - } + const defaultAccessList = getAccessListForModule( + TokenModule.APP_AUTH, + SERVICE_TYPES.CAVACH_API, + ); + accessList = evaluateAccessPolicy( + defaultAccessList, + SERVICE_TYPES.CAVACH_API, + [], + ); break; } case SERVICE_TYPES.QUEST: { grant_type = GRANT_TYPES.access_service_quest; - if (userDetails.accessList && userDetails.accessList.length > 0) { - accessList = userDetails.accessList - .map((x) => { - if (x.serviceType === SERVICE_TYPES.QUEST) { - if (!this.checkIfDateExpired(x.expiryDate)) { - return x.access; - } - } - }) - .filter((x) => x != undefined); - } + const defaultAccessList = getAccessListForModule( + TokenModule.APP_AUTH, + SERVICE_TYPES.QUEST, + ); + accessList = evaluateAccessPolicy( + defaultAccessList, + SERVICE_TYPES.QUEST, + [], + ); break; } default: { @@ -742,15 +807,33 @@ export class AppAuthService { `You are not authorized to access service of type ${serviceType}`, ]); } - - return this.getAccessToken(grant_type, appDetail, expiresin, accessList); + const jwtPayload = { + appId: appDetail.appId, + appName: appDetail.appName, + grantType: grant_type, + subdomain: appDetail.subdomain, + sessionId: redisKey, + }; + await this.storeDataInRedis(grant_type, appDetail, accessList, redisKey); + return this.getAccessToken(jwtPayload, expiresin); } - public async getAccessToken( + public async getAccessToken(data, expiresin = 4) { + const secret = this.config.get('JWT_SECRET'); + const token = await this.jwt.signAsync(data, { + expiresIn: expiresin.toString() + 'h', + secret, + }); + const expiresIn = (expiresin * 1 * 60 * 60 * 1000) / 1000; + Logger.log('generateAccessToken() method: ends....', 'AppAuthService'); + + return { access_token: token, expiresIn, tokenType: 'Bearer' }; + } + public async storeDataInRedis( grantType, appDetail, - expiresin = 4, accessList = [], + sessionId, ) { const payload = { appId: appDetail.appId, @@ -764,7 +847,6 @@ export class AppAuthService { env: appDetail.env ? appDetail.env : APP_ENVIRONMENT.dev, appName: appDetail.appName, }; - if (appDetail.issuerDid) { payload['issuerDid'] = appDetail.issuerDid; } @@ -780,27 +862,23 @@ export class AppAuthService { ) { payload['dependentServices'] = appDetail.dependentServices; } - - const secret = this.config.get('JWT_SECRET'); - - const token = await this.jwt.signAsync(payload, { - expiresIn: expiresin.toString() + 'h', - secret, - }); - const expiresIn = (expiresin * 1 * 60 * 60 * 1000) / 1000; Logger.log('generateAccessToken() method: ends....', 'AppAuthService'); - - return { access_token: token, expiresIn, tokenType: 'Bearer' }; + redisClient.set(sessionId, JSON.stringify(payload), 'EX', TIME.WEEK); } - //access_service_ssi - //access_service_kyc - async grantPermission( grantType: string, appId: string, user, + session?, ): Promise<{ access_token; expiresIn; tokenType }> { + const context = Context.idDashboard; + let rawRedisKey = `${appId}_${context}_${session.userId}`; + if (session && session.tenantId) { + rawRedisKey = `${rawRedisKey}_tenant`; + } + const sessionId = generateHash(rawRedisKey); + const savedSession = await redisClient.get(sessionId); switch (grantType) { case GRANT_TYPES.access_service_ssi: break; @@ -820,13 +898,23 @@ export class AppAuthService { } } + if (savedSession) { + const app = JSON.parse(savedSession); + const dataToStore = { + appId, + appName: app.appName, + grantType, + subdomain: app.subdomain, + sessionId, + }; + return this.getAccessToken(dataToStore, 12); + } const app = await this.getAppById(appId, user.userId); if (!app) { throw new BadRequestException([ 'Invalid service id or you do not have access of this service', ]); } - const userDetails = user; if (!userDetails) { throw new UnauthorizedException([ @@ -843,15 +931,16 @@ export class AppAuthService { 'Invalid grant type for this service ' + appId, ]); } - accessList = userDetails.accessList - .map((x) => { - if (x.serviceType === SERVICE_TYPES.SSI_API) { - if (!this.checkIfDateExpired(x.expiryDate)) { - return x.access; - } - } - }) - .filter((x) => x != undefined); + const defaultAccessList = getAccessListForModule( + TokenModule.DASHBOARD, + SERVICE_TYPES.SSI_API, + ); + accessList = evaluateAccessPolicy( + defaultAccessList, + SERVICE_TYPES.SSI_API, + user.accessList, + context, + ); break; } case SERVICE_TYPES.CAVACH_API: { @@ -863,15 +952,16 @@ export class AppAuthService { 'Invalid grant type for this service ' + appId, ]); } - accessList = userDetails.accessList - .map((x) => { - if (x.serviceType === SERVICE_TYPES.CAVACH_API) { - if (!this.checkIfDateExpired(x.expiryDate)) { - return x.access; - } - } - }) - .filter((x) => x != undefined); + const defaultAccessList = getAccessListForModule( + TokenModule.DASHBOARD, + SERVICE_TYPES.CAVACH_API, + ); + accessList = evaluateAccessPolicy( + defaultAccessList, + SERVICE_TYPES.CAVACH_API, + user.accessList, + context, + ); break; } case SERVICE_TYPES.QUEST: { @@ -880,15 +970,16 @@ export class AppAuthService { 'Invalid grant type for this service ' + appId, ]); } - accessList = userDetails.accessList - .map((x) => { - if (x.serviceType === SERVICE_TYPES.QUEST) { - if (!this.checkIfDateExpired(x.expiryDate)) { - return x.access; - } - } - }) - .filter((x) => x != undefined); + const defaultAccessList = getAccessListForModule( + TokenModule.DASHBOARD, + SERVICE_TYPES.QUEST, + ); + accessList = evaluateAccessPolicy( + defaultAccessList, + SERVICE_TYPES.QUEST, + user.accessList, + context, + ); break; } default: { @@ -900,86 +991,14 @@ export class AppAuthService { `You are not authorized to access service of type ${serviceType}`, ]); } - - return this.getAccessToken(grantType, app, 12, accessList); - } - - private async updateWebPageConfigDetail(app, userDetail) { - const webpageDetail = await this.webpageConfigRepo.findAWebpageConfig({ - serviceId: app.appId, - }); - if (!webpageDetail) { - Logger.warn(`No webpage config found for serviceId ${app.appId}`); - return; - } - let expiryDate = webpageDetail.expiryDate; - const userAccessList = userDetail.accessList; - Logger.log( - 'Inside updateWebPageConfigDetail(): Method to generate ssi and kyc token', - 'AppAuthService', - ); - const ssiAccessList = (userAccessList || []) - .filter( - (x) => - x.serviceType === SERVICE_TYPES.SSI_API && - !this.checkIfDateExpired(x.expiryDate), - ) - .map((x) => x.access); - - const kycAccessList = (userAccessList || []) - .filter( - (x) => - x.serviceType === SERVICE_TYPES.CAVACH_API && - !this.checkIfDateExpired(x.expiryDate), - ) - .map((x) => x.access); - - if (ssiAccessList.length <= 0 || kycAccessList.length <= 0) { - throw new UnauthorizedException([ - `You are not authorized for both SSI and KYC services.`, - ]); - } - expiryDate = new Date(expiryDate); - - if (isNaN(expiryDate.getTime())) { - throw new BadRequestException(['Invalid custom expiry date format.']); - } - const today = new Date(); - today.setHours(0, 0, 0, 0); - if (expiryDate < today) { - throw new BadRequestException([ - 'Custom expiry date cannot be earlier than today.', - ]); - } - const expiresIn = Math.floor( - (expiryDate.getTime() - Date.now()) / (1000 * 60 * 60), - ); - - const ssiServiceDetail = await this.appRepository.findOne({ - appId: app.dependentServices[0], - }); - if (!ssiServiceDetail) { - throw new BadRequestException([ - `No service found with dependentServiceId: ${app.dependentServices[0]}`, - ]); - } - // Get access tokens - const ssiAccessTokenDetail = await this.getAccessToken( - GRANT_TYPES.access_service_ssi, - ssiServiceDetail, - expiresIn, - ); - const kycAccessTokenDetail = await this.getAccessToken( - GRANT_TYPES.access_service_kyc, - app, - expiresIn, - ); - await this.webpageConfigRepo.findOneAndUpdate( - { _id: webpageDetail['_id'] }, - { - ssiAccessToken: ssiAccessTokenDetail.access_token, - kycAccessToken: kycAccessTokenDetail.access_token, - }, - ); + const tokenPayload = { + appId, + appName: app.appName, + grantType, + subdomain: app.subdomain, + sessionId, + }; + await this.storeDataInRedis(grantType, app, accessList, sessionId); + return this.getAccessToken(tokenPayload, 12); } } diff --git a/src/app-oauth/app-oauth.controller.ts b/src/app-oauth/app-oauth.controller.ts index 61ab0134..a96feb6e 100644 --- a/src/app-oauth/app-oauth.controller.ts +++ b/src/app-oauth/app-oauth.controller.ts @@ -134,9 +134,15 @@ export class AppOauthController { @Req() request, ): Promise<{ access_token; expiresIn; tokenType }> { const { user } = request; + const { session } = request; // Logger.log('reGenerateAppSecretKey() method: starts', 'AppOAuthController'); - return this.appAuthService.grantPermission(grantType, serviceId, user); + return this.appAuthService.grantPermission( + grantType, + serviceId, + user, + session, + ); } } diff --git a/src/app.module.ts b/src/app.module.ts index 515e1f7b..4c96d567 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -59,7 +59,7 @@ export class AppModule implements NestModule { .apply(AllowedOriginMiddleware) .exclude( { - path: '/api/v1/login/callback', + path: '/api/v1/auth/google/callback', method: RequestMethod.GET, }, { path: '/api/v1/app/oauth', method: RequestMethod.POST }, diff --git a/src/config/access-matrix.ts b/src/config/access-matrix.ts new file mode 100644 index 00000000..84fb945d --- /dev/null +++ b/src/config/access-matrix.ts @@ -0,0 +1,75 @@ +import { SERVICES } from '../supported-service/services/iServiceList'; +export enum TokenModule { + DASHBOARD = 'DASHBOARD', + VERIFIER = 'VERIFIER', + APP_AUTH = 'APP_AUTH', + SUPER_ADMIN = 'SUPER_ADMIN', + ID_SERVICE = 'ID_SERVICE', +} +export const KYC_ACCESS_MATRIX = { + [TokenModule.DASHBOARD]: [ + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_CREDIT, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_CREDIT, + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_WEBHOOK_CONFIG, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_WEBHOOK_CONFIG, + SERVICES.CAVACH_API.ACCESS_TYPES.UPDATE_WEBHOOK_CONFIG, + SERVICES.CAVACH_API.ACCESS_TYPES.DELETE_WEBHOOK_CONFIG, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_USAGE, + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_WIDGET_CONFIG, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_WIDGET_CONFIG, + SERVICES.CAVACH_API.ACCESS_TYPES.UPDATE_WIDGET_CONFIG, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_VERIFIED_USER, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_ANALYTICS, + ], + [TokenModule.VERIFIER]: [ + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_SESSION, + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_USER_CONSENT, + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_PASSIVE_LIVELINESS, + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_DOC_OCR, + SERVICES.CAVACH_API.ACCESS_TYPES.CHECK_LIVE_STATUS, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_WIDGET_CONFIG, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_USER_CONSENT, + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_AUTH, + ], + [TokenModule.APP_AUTH]: [ + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_SESSION, + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_USER_CONSENT, + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_PASSIVE_LIVELINESS, + SERVICES.CAVACH_API.ACCESS_TYPES.WRITE_DOC_OCR, + SERVICES.CAVACH_API.ACCESS_TYPES.CHECK_LIVE_STATUS, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_WIDGET_CONFIG, + SERVICES.CAVACH_API.ACCESS_TYPES.READ_USER_CONSENT, + ], + [TokenModule.SUPER_ADMIN]: [SERVICES.SSI_API.ACCESS_TYPES.WRITE_CREDIT], +}; +export const SSI_ACCESS_MATRIX = { + [TokenModule.DASHBOARD]: [ + SERVICES.SSI_API.ACCESS_TYPES.READ_DID, + SERVICES.SSI_API.ACCESS_TYPES.WRITE_DID, + SERVICES.SSI_API.ACCESS_TYPES.WRITE_CREDIT, + SERVICES.SSI_API.ACCESS_TYPES.READ_CREDIT, + SERVICES.SSI_API.ACCESS_TYPES.WRITE_SCHEMA, + SERVICES.SSI_API.ACCESS_TYPES.READ_SCHEMA, + SERVICES.SSI_API.ACCESS_TYPES.CHECK_LIVE_STATUS, + SERVICES.SSI_API.ACCESS_TYPES.READ_TX, + SERVICES.SSI_API.ACCESS_TYPES.READ_CREDENTIAL, + SERVICES.SSI_API.ACCESS_TYPES.VERIFY_CREDENTIAL, + SERVICES.SSI_API.ACCESS_TYPES.WRITE_CREDENTIAL, + SERVICES.SSI_API.ACCESS_TYPES.READ_USAGE, + SERVICES.SSI_API.ACCESS_TYPES.WRITE_PRESENTATION, + SERVICES.SSI_API.ACCESS_TYPES.VERIFY_PRESENTATION, + ], + [TokenModule.VERIFIER]: [SERVICES.SSI_API.ACCESS_TYPES.READ_DID], + [TokenModule.APP_AUTH]: [], + [TokenModule.ID_SERVICE]: [ + SERVICES.SSI_API.ACCESS_TYPES.READ_TX, + SERVICES.SSI_API.ACCESS_TYPES.WRITE_CREDENTIAL, + SERVICES.SSI_API.ACCESS_TYPES.VERIFY_PRESENTATION, + ], + [TokenModule.SUPER_ADMIN]: [SERVICES.SSI_API.ACCESS_TYPES.WRITE_CREDIT], +}; +export const QUEST_ACCESS_MATRIX = { + [TokenModule.DASHBOARD]: [], + [TokenModule.VERIFIER]: [], + [TokenModule.APP_AUTH]: [SERVICES.QUEST.ACCESS_TYPES.VERIFY_USER], +}; diff --git a/src/credits/credits.module.ts b/src/credits/credits.module.ts index 0cee0c0b..f4d52726 100644 --- a/src/credits/credits.module.ts +++ b/src/credits/credits.module.ts @@ -55,7 +55,13 @@ export class CreditModule implements NestModule { method: RequestMethod.GET, }) .forRoutes(CreditsController); - consumer.apply(JWTAccessAccountMiddleware).forRoutes(CreditsController); + consumer + .apply(JWTAccessAccountMiddleware) + .exclude({ + path: '/api/v1/credits/authz/:appId', + method: RequestMethod.GET, + }) + .forRoutes(CreditsController); consumer.apply(RateLimitMiddleware).forRoutes(CreditsController); } } diff --git a/src/customer-onboarding/constants/enum.ts b/src/customer-onboarding/constants/enum.ts index fe994f7f..76138284 100644 --- a/src/customer-onboarding/constants/enum.ts +++ b/src/customer-onboarding/constants/enum.ts @@ -133,7 +133,7 @@ export enum OnboardingStep { CREATE_DID = 'CREATE_DID', REGISTER_DID = 'REGISTER_DID', CREATE_KYC_SERVICE = 'CREATE_KYC_SERVICE', - GIVE_KYC_DASHBOARD_ACCESS = 'GIVE_KYC_DASHBOARD_ACCESS', + GIVE_DASHBOARD_ACCESS = 'GIVE_DASHBOARD_ACCESS', CREDIT_KYC_SERVICE = 'CREDIT_KYC_SERVICE', SETUP_KYC_WIDGET = 'SETUP_KYC_WIDGET', CONFIGURE_KYC_VERIFIER_PAGE = 'CONFIGURE_KYC_VERIFIER_PAGE', diff --git a/src/customer-onboarding/customer-onboarding.module.ts b/src/customer-onboarding/customer-onboarding.module.ts index 5da2ed21..8c1b6111 100644 --- a/src/customer-onboarding/customer-onboarding.module.ts +++ b/src/customer-onboarding/customer-onboarding.module.ts @@ -16,7 +16,6 @@ import { TrimMiddleware } from 'src/utils/middleware/trim.middleware'; import { JWTAuthorizeMiddleware } from 'src/utils/middleware/jwt-authorization.middleware'; import { RateLimitMiddleware } from 'src/utils/middleware/rate-limit.middleware'; import { JWTAccessAccountMiddleware } from 'src/utils/middleware/jwt-accessAccount.middlerwere'; -import { TwoFAAuthorizationMiddleware } from 'src/utils/middleware/2FA-jwt-authorization.middleware'; import { UserModule } from 'src/user/user.module'; import { PeopleModule } from 'src/people/people.module'; import { MailNotificationModule } from 'src/mail-notification/mail-notification.module'; @@ -60,9 +59,6 @@ export class CustomerOnboardingModule implements NestModule { consumer .apply(JWTAccessAccountMiddleware) .forRoutes(CustomerOnboardingController); - consumer - .apply(TwoFAAuthorizationMiddleware) - .forRoutes(CustomerOnboardingController); consumer.apply(RateLimitMiddleware).forRoutes(CustomerOnboardingController); } } diff --git a/src/customer-onboarding/dto/create-customer-onboarding.dto.ts b/src/customer-onboarding/dto/create-customer-onboarding.dto.ts index e2d65e11..fc8bd3be 100644 --- a/src/customer-onboarding/dto/create-customer-onboarding.dto.ts +++ b/src/customer-onboarding/dto/create-customer-onboarding.dto.ts @@ -26,6 +26,7 @@ import { } from '../constants/enum'; import { IsPhoneNumberByCountry } from 'src/utils/customDecorator/validate-phone-no-country.decorator'; import { Type } from 'class-transformer'; +import { IsUrlOrBase64Image } from 'src/utils/customDecorator/IsUrlOrBase64Image.decorator'; export class CustomerOnboardingBasicDto { @ApiProperty({ @@ -47,9 +48,7 @@ export class CustomerOnboardingBasicDto { @IsOptional() @IsNotEmpty() @IsString() - @IsUrl({ - require_protocol: true, - }) + @IsUrlOrBase64Image() companyLogo?: string; @ApiProperty({ name: 'customerEmail', diff --git a/src/customer-onboarding/services/customer-onboarding.service.ts b/src/customer-onboarding/services/customer-onboarding.service.ts index 83bce3e6..effa7cac 100644 --- a/src/customer-onboarding/services/customer-onboarding.service.ts +++ b/src/customer-onboarding/services/customer-onboarding.service.ts @@ -20,7 +20,9 @@ import { } from 'src/app-auth/services/app-auth.service'; import { APP_ENVIRONMENT, + Context, SERVICE_TYPES, + SERVICES, } from 'src/supported-service/services/iServiceList'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; @@ -35,7 +37,12 @@ import { LogDetail, } from '../schemas/customer-onboarding.schema'; import { AppRepository } from 'src/app-auth/repositories/app.repository'; -import { sanitizeUrl } from 'src/utils/utils'; +import { + evaluateAccessPolicy, + generateHash, + getAccessListForModule, + sanitizeUrl, +} from 'src/utils/utils'; import { RoleRepository } from 'src/roles/repository/role.repository'; import { ONBORDING_CONSTANT_DATA } from '../constants/en'; import { WebpageConfigService } from 'src/webpage-config/services/webpage-config.service'; @@ -44,6 +51,9 @@ import { PageType, } from 'src/webpage-config/dto/create-webpage-config.dto'; import getOnboardingRetryNotificationMail from 'src/mail-notification/constants/templates/request-retry-onboarding'; +import { redisClient } from 'src/utils/redis.provider'; +import { TIME } from 'src/utils/time-constant'; +import { TokenModule } from 'src/config/access-matrix'; @Injectable() export class CustomerOnboardingService { @@ -241,12 +251,14 @@ export class CustomerOnboardingService { tenantUrl: string, secret: string, whitelistedCors: string[] = ['*'], + accessList: string[], ) { Logger.debug(tenantUrl); Logger.log( `Inside handleCreditService() to fund credit to the service with tenantUrl ${tenantUrl}`, 'CustomerOnboardingService', ); + const sessionId = generateHash(`credit:${serviceInfo.appId}:${Date.now()}`); const creditPayload = { serviceId: serviceInfo.appId, purpose: 'CreditRecharge', @@ -257,9 +269,21 @@ export class CustomerOnboardingService { subdomain: serviceInfo.subdomain, grantType, whitelistedCors, + accessList, }; - - const creditToken = await this.generateCreditToken(creditPayload, secret); + await redisClient.set( + sessionId, + JSON.stringify(creditPayload), + 'EX', + 5 * TIME.MINUTE, + ); + const tokenPayload = { + appId: serviceInfo.appId, + sessionId, + subdomain: serviceInfo.subdomain, + grantType, + }; + const creditToken = await this.generateCreditToken(tokenPayload, secret); let headers: Record = { authorization: `Bearer ${creditToken}`, 'Content-Type': 'application/json', @@ -321,18 +345,22 @@ export class CustomerOnboardingService { `Customer onboarding detail not found for id: ${id}`, ]); } - // Initialize configuration const { companyName, domain, userId, companyLogo, customerEmail } = customerOnboardingData; const ssiBaseDomain = this.config.get('SSI_API_DOMAIN'); const cavachBaseDomain = this.config.get('CAVACH_API_DOMAIN'); const secret = this.config.get('JWT_SECRET'); - let ssiSubdomain = customerOnboardingData?.ssiSubdomain; let kycSubdomain = customerOnboardingData?.kycSubdomain; let ssiTenantUrl = this.getTenantUrl(ssiBaseDomain, ssiSubdomain); let kycTenantUrl = this.getTenantUrl(cavachBaseDomain, kycSubdomain); + let ssiRedisKey = generateHash( + `${customerOnboardingData?.ssiServiceId}_${Context.idDashboard}`, + ); + let kycRedisKey = generateHash( + `${customerOnboardingData?.kycServiceId}_${Context.idDashboard}`, + ); // Get remaining steps const lastStep = @@ -348,10 +376,43 @@ export class CustomerOnboardingService { throw new BadRequestException(['Customer onboarding is already done']); } let onboardingStatus; + let userDetail = await this.userRepository.findOne({ userId }); // Process each step for (const step of remainingSteps) { try { switch (step) { + case OnboardingStep.GIVE_DASHBOARD_ACCESS: { + Logger.log( + 'GIVE_DASHBOARD_ACCESS step started', + 'CustomerOnboardingService', + ); + userDetail = await this.userRepository.findOneUpdate( + { userId }, + { + $push: { + accessList: { + $each: [ + { + serviceType: SERVICE_TYPES.CAVACH_API, + access: SERVICES.CAVACH_API.ACCESS_TYPES.ALL, + expiryDate: null, + }, + { + serviceType: SERVICE_TYPES.SSI_API, + access: SERVICES.SSI_API.ACCESS_TYPES.ALL, + expiryDate: null, + }, + ], + }, + }, + }, + ); + Logger.debug( + 'GIVE_DASHBOARD_ACCESS step ends', + 'CustomerOnboardingService', + ); + break; + } case OnboardingStep.CREATE_TEAM_ROLE: { Logger.log( 'CREATE_TEAM_ROLE step started', @@ -409,6 +470,9 @@ export class CustomerOnboardingService { 'CREATE_SSI_SERVICE step ends', 'CustomerOnboardingService', ); + ssiRedisKey = generateHash( + `${ssiService.appId}_${Context.idDashboard}`, + ); break; } @@ -430,6 +494,10 @@ export class CustomerOnboardingService { ssiTenantUrl, secret, ssiService?.whitelistedCors, + getAccessListForModule( + TokenModule.SUPER_ADMIN, + SERVICE_TYPES.SSI_API, + ), ); Logger.debug( 'CREDIT_SSI_SERVICE step ends', @@ -448,10 +516,36 @@ export class CustomerOnboardingService { appId: customerOnboardingData.ssiServiceId, }); } - + const ssiServiceDetail = await redisClient.get(ssiRedisKey); + const defaultAccessList = getAccessListForModule( + TokenModule.DASHBOARD, + SERVICE_TYPES.SSI_API, + ); + const accessList = evaluateAccessPolicy( + defaultAccessList, + SERVICE_TYPES.SSI_API, + userDetail.accessList, + Context.idDashboard, + ); + if (!ssiServiceDetail) { + await this.appAuthService.storeDataInRedis( + GRANT_TYPES.access_service_ssi, + ssiService, + accessList, + ssiRedisKey, + ); + } ssiAccessToken = await this.appAuthService.getAccessToken( - GRANT_TYPES.access_service_ssi, - ssiService, + { + appId: + ssiService?.appId || customerOnboardingData.ssiServiceId, + grantType: GRANT_TYPES.access_service_ssi, + appname: ssiService?.appName, + subdomain: + ssiService?.subdomain || + customerOnboardingData.ssiSubdomain, + sessionId: ssiRedisKey, + }, 4, ); @@ -462,6 +556,7 @@ export class CustomerOnboardingService { headers: { authorization: `Bearer ${ssiAccessToken.access_token}`, 'Content-Type': 'application/json', + origin: ssiService.whitelistedCors[0], }, body: JSON.stringify({ namespace: 'testnet' }), }, @@ -480,11 +575,38 @@ export class CustomerOnboardingService { 'REGISTER_DID step started', 'CustomerOnboardingService', ); + const ssiServiceDetail = await redisClient.get(ssiRedisKey); + const defaultAccessList = getAccessListForModule( + TokenModule.DASHBOARD, + SERVICE_TYPES.SSI_API, + ); + const accessList = evaluateAccessPolicy( + defaultAccessList, + SERVICE_TYPES.SSI_API, + userDetail.accessList, + Context.idDashboard, + ); + if (!ssiServiceDetail) { + await this.appAuthService.storeDataInRedis( + GRANT_TYPES.access_service_ssi, + ssiService, + accessList, + ssiRedisKey, + ); + } ssiAccessToken = ssiAccessToken || (await this.appAuthService.getAccessToken( - GRANT_TYPES.access_service_ssi, - ssiService, + { + appId: + ssiService?.appId || customerOnboardingData.ssiServiceId, + grantType: GRANT_TYPES.access_service_ssi, + appname: ssiService?.appName, + subdomain: + ssiService?.subdomain || + customerOnboardingData.ssiSubdomain, + sessionId: ssiRedisKey, + }, 4, )); @@ -502,6 +624,7 @@ export class CustomerOnboardingService { headers: { authorization: `Bearer ${ssiAccessToken.access_token}`, 'Content-Type': 'application/json', + origin: ssiService.whitelistedCors[0], }, }, 'Failed to resolve DID', @@ -541,7 +664,11 @@ export class CustomerOnboardingService { appName: `${companyName}`, domain: domain, serviceIds: [SERVICE_TYPES.CAVACH_API], - whitelistedCors: ['*'], + whitelistedCors: [ + this.config.get('KYC_WIDGET_URL'), + this.config.get('KYC_VERIFIER_APP_BASE_URL'), + this.config.get('CLIENT_APP_URL'), + ], env: APP_ENVIRONMENT.dev, hasDomainVerified: false, dependentServices: [ @@ -561,47 +688,15 @@ export class CustomerOnboardingService { onboardingUpdateData.kycSubdomain = kycService.subdomain; onboardingUpdateData.kycServiceId = kycService.appId; kycTenantUrl = this.getTenantUrl(cavachBaseDomain, kycSubdomain); - Logger.debug( - 'CREATE_KYC_SERVICE step ends', - 'CustomerOnboardingService', - ); - break; - } - - case OnboardingStep.GIVE_KYC_DASHBOARD_ACCESS: { - Logger.log( - 'GIVE_KYC_DASHBOARD_ACCESS step started', - 'CustomerOnboardingService', - ); - await this.userRepository.findOneUpdate( - { - userId, - accessList: { - $not: { - $elemMatch: { - serviceType: 'CAVACH_API', - access: 'ALL', - }, - }, - }, - }, - { - $push: { - accessList: { - serviceType: 'CAVACH_API', - access: 'ALL', - expiryDate: null, - }, - }, - }, + kycRedisKey = generateHash( + `${kycService?.appId}_${Context.idDashboard}`, ); Logger.debug( - 'GIVE_KYC_DASHBOARD_ACCESS step ends', + 'CREATE_KYC_SERVICE step ends', 'CustomerOnboardingService', ); break; } - case OnboardingStep.CREDIT_KYC_SERVICE: { Logger.log( 'CREDIT_KYC_SERVICE step started', @@ -620,6 +715,10 @@ export class CustomerOnboardingService { kycTenantUrl, secret, kycService?.whitelistedCors, + getAccessListForModule( + TokenModule.SUPER_ADMIN, + SERVICE_TYPES.CAVACH_API, + ), ); Logger.debug( 'CREDIT_KYC_SERVICE step ends', @@ -637,9 +736,36 @@ export class CustomerOnboardingService { appId: customerOnboardingData.kycServiceId, }); } + const defaultAccessList = getAccessListForModule( + TokenModule.DASHBOARD, + SERVICE_TYPES.CAVACH_API, + ); + const accessList = evaluateAccessPolicy( + defaultAccessList, + SERVICE_TYPES.CAVACH_API, + userDetail.accessList, + Context.idDashboard, + ); + const kycServiceDetail = await redisClient.get(kycRedisKey); + if (!kycServiceDetail) { + await this.appAuthService.storeDataInRedis( + GRANT_TYPES.access_service_kyc, + kycService, + accessList, + kycRedisKey, + ); + } kycAccessToken = await this.appAuthService.getAccessToken( - GRANT_TYPES.access_service_kyc, - kycService, + { + appId: + kycService?.appId || customerOnboardingData.kycServiceId, + appName: kycService.appName, + grantType: GRANT_TYPES.access_service_kyc, + subdomain: + kycService?.subdomain || + customerOnboardingData.kycSubdomain, + sessionId: kycRedisKey, + }, 4, ); const requestBody = { @@ -679,7 +805,7 @@ export class CustomerOnboardingService { headers: { 'x-kyc-access-token': kycAccessToken.access_token, 'Content-Type': 'application/json', - origin: '*', + origin: kycService.whitelistedCors[0], }, body: JSON.stringify(requestBody), }, @@ -697,23 +823,16 @@ export class CustomerOnboardingService { 'CustomerOnboardingService', ); - const user = await this.userRepository.findOne({ - userId: userId, - }); const serviceId = kycService?.appId || customerOnboardingData.kycServiceId; - await this.webPageConfig.storeWebPageConfigDetial( - serviceId, - { - pageTitle: 'KYC Verification', - pageDescription: 'Complete your KYC verification to proceed', - expiryType: ExpiryType.ONE_MONTH, - pageType: PageType.KYC, - contactEmail: customerEmail, - themeColor: 'vibrant', - }, - user, - ); + await this.webPageConfig.storeWebPageConfigDetial(serviceId, { + pageTitle: 'KYC Verification', + pageDescription: 'Complete your KYC verification to proceed', + expiryType: ExpiryType.ONE_MONTH, + pageType: PageType.KYC, + contactEmail: customerEmail, + themeColor: 'vibrant', + }); Logger.debug( 'CONFIGURE_KYC_VERIFIER_PAGE step ends', 'CustomerOnboardingService', @@ -855,7 +974,8 @@ export class CustomerOnboardingService { userId: user.userId, }); if (!userOnboardingDetail) { - throw new BadRequestException([`No onboarding detail found for user with id: ${user.userId}`, + throw new BadRequestException([ + `No onboarding detail found for user with id: ${user.userId}`, ]); } return userOnboardingDetail; diff --git a/src/people/constant/en.ts b/src/people/constant/en.ts new file mode 100644 index 00000000..28b4a3c5 --- /dev/null +++ b/src/people/constant/en.ts @@ -0,0 +1,21 @@ +export const TENANT_ERRORS = { + ALREADY_IN_TENANT: 'You are already using this tenant.', + ADMIN_NOT_FOUND: 'Tenant administrator could not be found.', + NOT_A_MEMBER: (email: string) => + `You are not a member of the tenant managed by ${email}.`, + INVITATION_NOT_ACCEPTED: + 'You must accept the tenant invitation before switching.', + ROLE_NOT_FOUND: 'The specified role does not exist.', + NO_PERMISSION: 'The assigned role has no available permissions.', +}; +export const TENANT_INVITE_ERRORS = { + SELF_INVITATION_NOT_ALLOWED: 'You cannot invite your own account.', + ALREADY_INVITED: 'This user is already associated with your tenant.', + ROLE_NOT_FOUND: (roleId: string) => + `No role exists for the provided role ID: ${roleId}.`, + NO_ROLE_ASSIGNED: 'A role must be assigned before inviting a member.', +}; +export const TENANT_MESSAGES = { + SWITCH_SUCCESS: 'Switched to the tenant successfully.', + SWITCH_BACK_SUCCESS: 'Returned to the primary account successfully.', +}; diff --git a/src/people/controller/people.controller.ts b/src/people/controller/people.controller.ts index c2fa43ba..a9b01035 100644 --- a/src/people/controller/people.controller.ts +++ b/src/people/controller/people.controller.ts @@ -14,22 +14,21 @@ import { } from '@nestjs/common'; import { PeopleService } from '../services/people.service'; import { - AdminLoginDTO, AttachRoleDTO, CreateInviteDto, InviteListResponseDTO, InviteResponseDTO, PeopleListResponseDTO, + TenantLoginDTO, } from '../dto/create-person.dto'; import { DeletePersonDto } from '../dto/update-person.dto'; import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AllExceptionsFilter } from 'src/utils/utils'; import { ConfigService } from '@nestjs/config'; -import { TOKEN_MAX_AGE } from 'src/utils/time-constant'; @UseFilters(AllExceptionsFilter) @ApiTags('People') @ApiBearerAuth('Authorization') -@Controller('/api/v1/people') +@Controller('/api/v1/tenants') export class PeopleController { constructor( private readonly peopleService: PeopleService, @@ -41,7 +40,7 @@ export class PeopleController { description: 'Invite a user to your account', type: InviteResponseDTO, }) - @Post('/invite') + @Post('/invitations') @UsePipes(ValidationPipe) createInvite(@Body() createInviteDto: CreateInviteDto, @Req() req) { const { user } = req; @@ -53,14 +52,14 @@ export class PeopleController { description: 'Accept invite', type: InviteResponseDTO, }) - @Post('/invite/accept/:inviteCode') + @Post('/invitations/:inviteCode/accept') @UsePipes(ValidationPipe) acceptInvite(@Param('inviteCode') inviteCode: string, @Req() req) { const { user } = req; return this.peopleService.acceptInvite(inviteCode, user); } - @Patch('invite/:inviteCode') + @Patch('invitations/:inviteCode') @UsePipes(ValidationPipe) update(@Param('inviteCode') inviteCode: string, @Req() req) { const { user } = req; @@ -84,7 +83,7 @@ export class PeopleController { type: InviteListResponseDTO, isArray: true, }) - @Get('/invites') + @Get('/invitations') @UsePipes(ValidationPipe) async getAllInvites(@Req() req) { const { user } = req; @@ -104,32 +103,14 @@ export class PeopleController { const { user } = req; return this.peopleService.attachRole(body, user); } - - @Post('/admin/login') + @Post('/access') @UsePipes(ValidationPipe) - async adminLogin(@Body() body: AdminLoginDTO, @Req() req, @Res() res) { - const { user } = req; - const data = await this.peopleService.adminLogin(body, user); - const cookieDomain = this.config.get('COOKIE_DOMAIN'); - const isProduction = this.config.get('NODE_ENV') === 'production'; - res.cookie('authToken', data?.authToken, { - httpOnly: true, - secure: isProduction, - sameSite: isProduction ? 'None' : 'Lax', - maxAge: TOKEN_MAX_AGE.AUTH_TOKEN, - domain: isProduction ? cookieDomain : undefined, - path: '/', - }); - res.cookie('refreshToken', data?.refreshToken, { - httpOnly: true, - secure: isProduction, - sameSite: isProduction ? 'None' : 'Lax', - maxAge: TOKEN_MAX_AGE.REFRESH_TOKEN, - domain: isProduction ? cookieDomain : undefined, - path: '/', - }); - return res.json({ - message: `Successfully switched to the ${data.adminEmail} account`, - }); + async switchTenantAccount(@Body() tenantDto: TenantLoginDTO, @Req() req) { + const { user, session } = req; + return await this.peopleService.switchTenantAccount( + user, + session, + tenantDto, + ); } } diff --git a/src/people/dto/create-person.dto.ts b/src/people/dto/create-person.dto.ts index d6cc6508..5228fee4 100644 --- a/src/people/dto/create-person.dto.ts +++ b/src/people/dto/create-person.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsString, Matches } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class CreateInviteDto { @ApiProperty({ @@ -8,6 +8,14 @@ export class CreateInviteDto { }) @IsEmail({}, { message: 'Email must be a valid email address' }) emailId: string; + @ApiProperty({ + name: 'roleId', + type: String, + required: false, + }) + @IsOptional() + @IsString() + roleId?: string; } export class InviteResponseDTO { @@ -200,7 +208,7 @@ export class AttachRoleDTO { userId: string; } -export class AdminLoginDTO { +export class TenantLoginDTO { @ApiProperty({ name: 'adminId', }) diff --git a/src/people/schema/people.schema.ts b/src/people/schema/people.schema.ts index d730b058..6bb004b6 100644 --- a/src/people/schema/people.schema.ts +++ b/src/people/schema/people.schema.ts @@ -43,6 +43,11 @@ export class AdminPeople { required: false, }) roleName?: string; + @Prop({ + name: 'inviteeEmail', + required: false, + }) + inviteeEmail?: string; } export const AdminPeopleSchema = SchemaFactory.createForClass(AdminPeople); diff --git a/src/people/services/people.service.ts b/src/people/services/people.service.ts index a10479da..cde7c82c 100644 --- a/src/people/services/people.service.ts +++ b/src/people/services/people.service.ts @@ -3,13 +3,14 @@ import { ConflictException, Injectable, NotFoundException, + UnauthorizedException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { - AdminLoginDTO, AttachRoleDTO, CreateInviteDto, + TenantLoginDTO, } from '../dto/create-person.dto'; import { DeletePersonDto } from '../dto/update-person.dto'; import { UserRepository } from 'src/user/repository/user.repository'; @@ -19,9 +20,13 @@ import { SocialLoginService } from 'src/social-login/services/social-login.servi import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { MailNotificationService } from 'src/mail-notification/services/mail-notification.service'; -import { UserRole } from 'src/user/schema/user.schema'; import { JobNames } from 'src/utils/time-constant'; -import { mapUserAccessList } from 'src/utils/utils'; +import { redisClient } from 'src/utils/redis.provider'; +import { + TENANT_ERRORS, + TENANT_INVITE_ERRORS, + TENANT_MESSAGES, +} from '../constant/en'; @Injectable() export class PeopleService { @@ -37,11 +42,14 @@ export class PeopleService { async createInvitation(createPersonDto: CreateInviteDto, adminUserData) { const { emailId } = createPersonDto; if (emailId === adminUserData?.email) { - throw new BadRequestException(['Self invitation is not available']); + throw new BadRequestException([ + TENANT_INVITE_ERRORS.SELF_INVITATION_NOT_ALLOWED, + ]); } const userDetails = await this.userService.findOne({ email: emailId, }); + // if (userDetails == null) { // throw new NotFoundException( // `Cannot invite an non existing user with email: ${emailId}`, @@ -54,21 +62,33 @@ export class PeopleService { adminId: adminUserData.userId, }); if (adminPeople != null) { - throw new ConflictException([ - 'User already exists to your account', - 'Already Invited', - ]); + throw new ConflictException([TENANT_INVITE_ERRORS.ALREADY_INVITED]); } - - // const isInvitedAlready = await this.inviteRepository.findOne({ - // invitor: adminUserData.userId, - // invitee: userDetails.userId, - // }); - - // if (isInvitedAlready !== null) { - // return isInvitedAlready; - // } const invitecode = `${Date.now()}-${uuidv4()}`; + const { roleId } = createPersonDto; + let roleDetail; + if (roleId) { + roleDetail = await this.roleRepository.findOne({ _id: roleId }); + if (!roleDetail) { + throw new BadRequestException([ + TENANT_INVITE_ERRORS.ROLE_NOT_FOUND(roleId), + ]); + } + } else { + const roles = await this.roleRepository.findUsingAggregation([ + { $match: { userId: adminUserData.userId } }, + { + $addFields: { + permissionsCount: { $size: '$permissions' }, + }, + }, + { $sort: { permissionsCount: 1 } }, + { $limit: 1 }, + ]); + roleDetail = roles?.[0]; + } + if (!roleDetail) + throw new BadRequestException([TENANT_INVITE_ERRORS.NO_ROLE_ASSIGNED]); const invite = await this.adminPeopleService.create({ adminId: adminUserData.userId, userId: userDetails?.userId || emailId, @@ -77,6 +97,9 @@ export class PeopleService { invitationValidTill: new Date( Date.now() + 2 * 24 * 60 * 60 * 1000, ).toISOString(), + roleId: roleDetail._id.toString(), + roleName: roleDetail.roleName, + inviteeEmail: emailId, }); this.mailNotificationService.addJobToMailQueue({ mailName: JobNames.SEND_TEAM_MATE_INVITATION_MAIL, @@ -218,77 +241,84 @@ export class PeopleService { }, ); } - async adminLogin(body: AdminLoginDTO, user: any) { - const rawUrl = this.configService.get('INVITATIONURL'); - const url = new URL(rawUrl); - const domain = url.origin; - const { adminId } = body; + async switchTenantAccount( + userDetail, + sessionDetail, + tenantDto: TenantLoginDTO, + ) { + const { adminId } = tenantDto; + // switch back to own account + if (userDetail.userId === adminId) { + if (!sessionDetail?.tenantId) { + throw new BadRequestException([TENANT_ERRORS.ALREADY_IN_TENANT]); + } + return this.updateSession({ + sessionDetail, + message: TENANT_MESSAGES.SWITCH_BACK_SUCCESS, + }); + } + // switching to tenant account + if (adminId === sessionDetail?.tenantId) { + throw new BadRequestException([TENANT_ERRORS.ALREADY_IN_TENANT]); + } const adminData = await this.userService.findOne({ userId: adminId, }); if (adminData == null) { - throw new BadRequestException(['Admin user not found']); + throw new BadRequestException([TENANT_ERRORS.ADMIN_NOT_FOUND]); } - const userId = user.userId; - let adminPeople = user; - let role; - if (userId !== adminId) { - adminPeople = await this.adminPeopleService.findOne({ - adminId, - userId, - }); - - if (adminPeople == null) { - throw new NotFoundException([ - 'You are not the member of ' + adminData.email, - ]); - } - - if (adminPeople.roleId == null) { - throw new BadRequestException([ - 'You do not have any role to access admin account', - ]); - } - - role = await this.roleRepository.findOne({ - _id: adminPeople.roleId, - }); + const tenantDetail = await this.adminPeopleService.findOne({ + adminId, + userId: userDetail.userId, + }); + if (!tenantDetail) { + throw new UnauthorizedException([ + TENANT_ERRORS.NOT_A_MEMBER(adminData.email), + ]); + } + if (!tenantDetail.accepted) { + throw new BadRequestException([TENANT_ERRORS.INVITATION_NOT_ACCEPTED]); + } + const roleDetail = await this.roleRepository.findOne({ + _id: tenantDetail.roleId, + }); + if (!roleDetail) { + throw new BadRequestException([TENANT_ERRORS.ROLE_NOT_FOUND]); + } + if (!roleDetail.permissions?.length) { + throw new BadRequestException([TENANT_ERRORS.NO_PERMISSION]); } - // const jwt = await this.socialLoginService.socialLogin({ - // user: { - // email: adminData.email, - // }, - // }); - - delete adminData.accessList; - delete adminData['_id']; - delete adminData.authenticators; - const accessAccount = { - ...adminData, - accessList: mapUserAccessList( - role?.permissions || adminPeople.accessList, - ), - }; - const payload = { - appUserID: user.userId, - ...user, - accessList: mapUserAccessList(user.accessList), - aud: domain, - }; - delete payload._id; - delete payload.userId; + return this.updateSession({ + sessionDetail, + message: TENANT_MESSAGES.SWITCH_SUCCESS, + tenantId: adminId, // tenantId + permissions: roleDetail.permissions, // permissions + }); + } - payload.accessAccount = accessAccount; - payload.role = adminData?.role || UserRole.ADMIN; - const token = await this.socialLoginService.generateAuthToken(payload); - const refreshToken = await this.socialLoginService.generateRefreshToken( - payload, + private async updateSession({ + sessionDetail, + message, + tenantId = null, + permissions = null, + }: { + sessionDetail: any; + tenantId?: string | null; + permissions?: any[] | null; + message: string; + }) { + sessionDetail.tenantId = tenantId; + sessionDetail.tenantUserPermissions = permissions; + sessionDetail.createdAt = new Date().toISOString(); + const ttl = await redisClient.ttl(`session:${sessionDetail.sessionId}`); + await redisClient.set( + `session:${sessionDetail.sessionId}`, + JSON.stringify(sessionDetail), + 'EX', + ttl, ); - return { - authToken: token, - refreshToken, - adminEmail: adminData.email, - }; + + return { message }; } } diff --git a/src/roles/repository/role.repository.ts b/src/roles/repository/role.repository.ts index 73c1b531..d34c7417 100644 --- a/src/roles/repository/role.repository.ts +++ b/src/roles/repository/role.repository.ts @@ -34,4 +34,7 @@ export class RoleRepository { async findOneAndDelete(roleFilterQuery: FilterQuery) { return this.roleModel.findOneAndDelete(roleFilterQuery); } + async findUsingAggregation(pipeline: any) { + return this.roleModel.aggregate(pipeline); + } } diff --git a/src/social-login/constants/en.ts b/src/social-login/constants/en.ts new file mode 100644 index 00000000..b0aa0081 --- /dev/null +++ b/src/social-login/constants/en.ts @@ -0,0 +1,35 @@ +export const ERROR_MESSAGE = { + SESSION_NOT_FOUND: 'Session not found or expired.', + INVALID_OTP: 'Invalid or expired OTP code.', + USER_NOT_FOUND: 'User not found', + LOGOUT_ISSUE: 'Logout failed on server', +} as const; + +export const AUTH_ERRORS = { + EMPTY_TOKEN: 'Please pass authorization token in cookie', + SESSION_EXPIRED: 'Session expired or logged out', + SESSION_MISMATCH: 'Token does not match session', + TOKEN_DOMAIN_MISMATCH: + 'This token was issued for a different domain than the one making the request.', + TOKEN_DOMAIN_MISSING: 'Token does not contain a valid domain.', + INVALID_TOKEN: 'Invalid token', + TENANT_PERMISSION_ISSUE: 'Tenant does not have any assigned permissions', + ACCESS_REVOKED: 'Your access has been revoked', +}; + +export const REFRESH_TOKEN_ERROR = { + INVALID_REFRESH_TOKEN: 'Invalid refresh token', + REFRESH_TOKEN_NOT_FOUND: 'Refresh token not found or expired.', + REFRESH_VERSION_MISMATCH: 'Your session has expired. Please log in again.', +}; + +export const MFA_ERROR = { + MFA_ALREADY_ENABLED: 'MFA is already enabled for this user.', + MFA_NOT_VERIFIED: 'Two-factor authentication (2FA) is required.', + MFA_ALREADY_VERIFIED: + 'MFA already verified. No further verification required.', + TWO_FA_REQUIRED: '2FA verification required', + INVALID_MFA_METHOD: 'Invalid MFA method selected for this session.', + MFA_MAX_RETRY_EXCEEDED: + 'MFA verification failed too many times. Session expired.', +}; diff --git a/src/social-login/controller/email-otp-login.controller.ts b/src/social-login/controller/email-otp-login.controller.ts index 52e6d6e1..21df21ce 100644 --- a/src/social-login/controller/email-otp-login.controller.ts +++ b/src/social-login/controller/email-otp-login.controller.ts @@ -15,7 +15,7 @@ import { ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; -import { AllExceptionsFilter } from 'src/utils/utils'; +import { AllExceptionsFilter, getCookieOptions } from 'src/utils/utils'; import { GenerateEmailOtpDto, GenerateEmailOtpResponse, @@ -26,10 +26,10 @@ import { AppError } from 'src/app-auth/dtos/fetch-app.dto'; import { Request } from 'express'; import { SocialLoginService } from '../services/social-login.service'; import { UnauthorizedError } from '../dto/response.dto'; -import { TOKEN_MAX_AGE } from 'src/utils/time-constant'; +import { TOKEN } from 'src/utils/time-constant'; @UseFilters(AllExceptionsFilter) @ApiTags('Authentication') -@Controller('/api/v1/auth/otp') +@Controller('/api/v1/auth/email/otp') export class EmailOtpLoginController { constructor( private readonly config: ConfigService, @@ -45,7 +45,7 @@ export class EmailOtpLoginController { status: 400, type: AppError, }) - @Post('generate') + @Post('request') async generateEmailOtp(@Body() body: GenerateEmailOtpDto) { Logger.log('generateEmailOtp() method starts', 'EmailOtpLoginController'); return this.emailOtpService.generateEmailOtp(body); @@ -74,23 +74,20 @@ export class EmailOtpLoginController { 'EmailOtpLoginController', ); try { - const tokens = await this.socialLoginService.socialLogin(req); - res.cookie('authToken', tokens?.authToken, { - httpOnly: true, - secure: true, - domain: cookieDomain, - maxAge: TOKEN_MAX_AGE.AUTH_TOKEN, - sameSite: 'None', - path: '/', - }); - res.cookie('refreshToken', tokens?.refreshToken, { - httpOnly: true, - secure: true, - sameSite: 'None', - domain: cookieDomain, - maxAge: TOKEN_MAX_AGE.REFRESH_TOKEN, - path: '/', - }); + const result = await this.socialLoginService.socialLogin(req); + if (result.isMfaRequired) { + res.redirect(this.config.get('MFA_REDIRECT_URL')); + } + res.cookie( + TOKEN.AUTH.name, + result.accessToken, + getCookieOptions(TOKEN.AUTH.expiry), + ); + res.cookie( + TOKEN.REFRESH.name, + result.refreshToken, + getCookieOptions(TOKEN.REFRESH.expiry), + ); res.redirect(`${this.config.get('REDIRECT_URL')}`); } catch (err) { Logger.error(`Login failed: ${err.message}`, 'EmailOtpLoginController'); diff --git a/src/social-login/controller/social-login.controller.ts b/src/social-login/controller/social-login.controller.ts index 522c5644..bbe3a8a5 100644 --- a/src/social-login/controller/social-login.controller.ts +++ b/src/social-login/controller/social-login.controller.ts @@ -7,10 +7,10 @@ import { UseFilters, Post, Res, - Query, Body, Delete, UnauthorizedException, + BadRequestException, } from '@nestjs/common'; import { SocialLoginService } from '../services/social-login.service'; import { AuthGuard } from '@nestjs/passport'; @@ -19,12 +19,11 @@ import { ApiBearerAuth, ApiExcludeEndpoint, ApiOkResponse, - ApiQuery, ApiResponse, ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; -import { AllExceptionsFilter } from 'src/utils/utils'; +import { AllExceptionsFilter, getCookieOptions } from 'src/utils/utils'; import { ConfigService } from '@nestjs/config'; import { AuthResponse, @@ -38,14 +37,16 @@ import { import { DeleteMFADto, Generate2FA, + LoginMFACodeVerificationDto, MFACodeVerificationDto, } from '../dto/request.dto'; import { AppError } from 'src/app-auth/dtos/fetch-app.dto'; import { UserRole } from 'src/user/schema/user.schema'; -import { TOKEN_MAX_AGE } from 'src/utils/time-constant'; +import { ERROR_MESSAGE, ERROR_MESSAGE as MFA_MESSAGE } from '../constants/en'; +import { TOKEN } from 'src/utils/time-constant'; @UseFilters(AllExceptionsFilter) @ApiTags('Authentication') -@Controller() +@Controller('api/v1') export class SocialLoginController { constructor( private readonly socialLoginService: SocialLoginService, @@ -60,15 +61,10 @@ export class SocialLoginController { status: 401, type: UnauthorizedError, }) - @ApiQuery({ - name: 'provider', - description: 'Authentication provider', - required: true, - }) - @Get('/api/v1/login') - async socialAuthRedirect(@Res() res, @Query() loginProvider) { + @Get('auth/google/authorize') + async socialAuthRedirect(@Res() res) { Logger.log('socialAuthRedirect() method starts', 'SocialLoginController'); - const { provider } = loginProvider; + const provider = 'google'; Logger.log(`Looged in with ${provider}`, 'SocialLoginController'); const { authUrl } = await this.socialLoginService.generateAuthUrlByProvider( provider, @@ -76,33 +72,31 @@ export class SocialLoginController { res.json({ authUrl }); } @ApiExcludeEndpoint() - @Get('/api/v1/login/callback') + @Get('auth/google/callback') @UseGuards(AuthGuard('google')) async socialAuthCallback(@Req() req, @Res() res) { Logger.log('socialAuthCallback() method starts', 'SocialLoginController'); - const cookieDomain = this.config.get('COOKIE_DOMAIN'); - const isProduction = this.config.get('NODE_ENV') === 'production'; - const tokens = await this.socialLoginService.socialLogin(req); - Logger.debug( - `Cookied domain set is ${cookieDomain}`, - 'SocialLoginController', + const result = await this.socialLoginService.socialLogin(req); + if (result.isMfaRequired) { + const arrayString = encodeURIComponent( + JSON.stringify(result.authenticators), + ); + return res.redirect( + `${this.config.get( + 'MFA_REDIRECT_URL', + )}?authenticators=${arrayString}&sessionId=${result.sessionId}`, + ); + } + res.cookie( + TOKEN.AUTH.name, + result.accessToken, + getCookieOptions(TOKEN.AUTH.expiry), + ); + res.cookie( + TOKEN.REFRESH.name, + result.refreshToken, + getCookieOptions(TOKEN.REFRESH.expiry), ); - res.cookie('authToken', tokens?.authToken, { - httpOnly: true, - maxAge: TOKEN_MAX_AGE.AUTH_TOKEN, - secure: isProduction, - domain: isProduction ? cookieDomain : undefined, - sameSite: isProduction ? 'None' : 'Lax', - path: '/', - }); - res.cookie('refreshToken', tokens?.refreshToken, { - httpOnly: true, - maxAge: TOKEN_MAX_AGE.REFRESH_TOKEN, - secure: isProduction, - sameSite: isProduction ? 'None' : 'Lax', - domain: isProduction ? cookieDomain : undefined, - path: '/', - }); res.redirect(`${this.config.get('REDIRECT_URL')}`); } @ApiBearerAuth('Authorization') @@ -114,7 +108,7 @@ export class SocialLoginController { status: 401, type: UnauthorizedError, }) - @Post('/api/v1/auth') + @Post('users/me') dispatchUserDetail(@Req() req) { Logger.log('dispatchUserDetail() method starts', 'SocialLoginController'); const userDetail = req.user; @@ -136,16 +130,8 @@ export class SocialLoginController { error: null, }; } - - @ApiBearerAuth('Authorization') - @Post('/api/v1/auth/login/refresh') - async generateRefreshToken(@Req() req) { - return { - authToken: await this.socialLoginService.socialLogin(req), - }; - } @ApiBearerAuth('Authorization') - @Post('/api/v1/auth/refresh') + @Post('auth/tokens/refresh') async refreshTokenGeneration(@Req() req, @Res() res) { const refreshToken = req.cookies['refreshToken']; if (!refreshToken) { @@ -154,24 +140,24 @@ export class SocialLoginController { const tokens = await this.socialLoginService.verifyAndGenerateRefreshToken( refreshToken, ); - const cookieDomain = this.config.get('COOKIE_DOMAIN'); - const isProduction = this.config.get('NODE_ENV') === 'production'; - res.cookie('authToken', tokens.authToken, { - httpOnly: true, - maxAge: TOKEN_MAX_AGE.AUTH_TOKEN, - secure: isProduction, - domain: isProduction ? cookieDomain : undefined, - sameSite: isProduction ? 'None' : 'Lax', - path: '/', - }); - res.cookie('refreshToken', tokens.refreshToken, { - httpOnly: true, - maxAge: TOKEN_MAX_AGE.REFRESH_TOKEN, - secure: isProduction, - sameSite: isProduction ? 'None' : 'Lax', - domain: isProduction ? cookieDomain : undefined, - path: '/', - }); + if (tokens.error) { + return res.status(401).json({ + statusCode: 401, + message: [tokens.error], + error: 'Unauthorized', + }); + } + + res.cookie( + TOKEN.AUTH.name, + tokens.accessToken, + getCookieOptions(TOKEN.AUTH.expiry), + ); + res.cookie( + TOKEN.REFRESH.name, + tokens.refreshToken, + getCookieOptions(TOKEN.REFRESH.expiry), + ); res.json({ message: 'Tokens refreshed' }); } @@ -184,7 +170,7 @@ export class SocialLoginController { type: UnauthorizedError, }) @ApiBearerAuth('Authorization') - @Post('/api/v1/auth/mfa/generate') + @Post('auth/mfa/setup/generate') async generateMfa(@Req() req, @Body() body: Generate2FA) { const result = await this.socialLoginService.generate2FA(body, req.user); return { twoFADataUrl: result }; @@ -199,33 +185,27 @@ export class SocialLoginController { type: UnauthorizedError, }) @ApiBearerAuth('Authorization') - @Post('/api/v1/auth/mfa/verify') + @Post('auth/mfa/login/verify') async verifyMFA( - @Req() req, - @Body() mfaVerificationDto: MFACodeVerificationDto, + @Body() mfaVerificationDto: LoginMFACodeVerificationDto, @Res() res, ) { const data = await this.socialLoginService.verifyMFACode( - req.user, mfaVerificationDto, ); - const cookieDomain = this.config.get('COOKIE_DOMAIN'); - const isProduction = this.config.get('NODE_ENV') === 'production'; - res.cookie('authToken', data?.authToken, { - httpOnly: true, - maxAge: TOKEN_MAX_AGE.AUTH_TOKEN, - secure: isProduction, - domain: isProduction ? cookieDomain : undefined, - sameSite: isProduction ? 'None' : 'Lax', - path: '/', - }); - res.cookie('refreshToken', data?.refreshToken, { - httpOnly: true, - maxAge: TOKEN_MAX_AGE.REFRESH_TOKEN, - secure: isProduction, - sameSite: isProduction ? 'None' : 'Lax', - path: '/', - }); + if (data.isVerified) { + res.cookie( + TOKEN.AUTH.name, + data.accessToken, + getCookieOptions(TOKEN.AUTH.expiry), + ); + res.cookie( + TOKEN.REFRESH.name, + data.refreshToken, + getCookieOptions(TOKEN.REFRESH.expiry), + ); + } + res.json({ isVerified: data.isVerified }); } @ApiOkResponse({ @@ -241,7 +221,7 @@ export class SocialLoginController { type: UnauthorizedError, }) @ApiBearerAuth('Authorization') - @Delete('/api/v1/auth/mfa') + @Delete('auth/mfa') async removeMFA(@Req() req, @Body() mfaremoveDto: DeleteMFADto) { return this.socialLoginService.removeMFA(req.user, mfaremoveDto); } @@ -258,24 +238,52 @@ export class SocialLoginController { type: UnauthorizedError, }) @ApiBearerAuth('Authorization') - @Post('/api/v1/auth/logout') + @Post('auth/logout') async logout(@Req() req, @Res() res) { - const cookieDomain = this.config.get('COOKIE_DOMAIN'); - const isProduction = this.config.get('NODE_ENV') === 'production'; - res.clearCookie('authToken', { - path: '/', - domain: isProduction ? cookieDomain : undefined, - sameSite: isProduction ? 'None' : 'Lax', - secure: isProduction, - httpOnly: true, - }); - res.clearCookie('refreshToken', { - path: '/', - domain: isProduction ? cookieDomain : undefined, - sameSite: isProduction ? 'None' : 'Lax', - secure: isProduction, - httpOnly: true, - }); + const refreshToken = req.cookies[TOKEN.REFRESH.name]; + const result = await this.socialLoginService.logout( + refreshToken, + req.session, + ); + if (!result.success) { + throw new BadRequestException([ERROR_MESSAGE.LOGOUT_ISSUE]); + } + res.clearCookie(TOKEN.AUTH.name, getCookieOptions(undefined, true)); + res.clearCookie(TOKEN.REFRESH.name, getCookieOptions(undefined, true)); return res.status(200).json({ message: 'Logged out successfully' }); } + + @ApiOkResponse({ + description: 'Verified MFA code and generated new token', + type: Verify2FARespDto, + }) + @ApiUnauthorizedResponse({ + status: 401, + type: UnauthorizedError, + }) + @ApiBearerAuth('Authorization') + @Post('auth/mfa/setup/verify') + async confirmMfaSetup( + @Req() req, + @Body() mfaVerificationDto: MFACodeVerificationDto, + @Res() res, + ) { + const data = await this.socialLoginService.confirmMfaSetup( + req.user, + req.session, + mfaVerificationDto, + ); + if (!data.isVerified && data?.error === MFA_MESSAGE.SESSION_NOT_FOUND) { + throw new UnauthorizedException([data.error]); + } + if (!data.isVerified) { + throw new BadRequestException([data.error]); + } + res.cookie( + TOKEN.REFRESH.name, + data.refreshToken, + getCookieOptions(TOKEN.REFRESH.expiry), + ); + return res.json({ isVerified: data.isVerified }); + } } diff --git a/src/social-login/dto/request.dto.ts b/src/social-login/dto/request.dto.ts index 058d6b34..80c07058 100644 --- a/src/social-login/dto/request.dto.ts +++ b/src/social-login/dto/request.dto.ts @@ -20,6 +20,16 @@ export class MFACodeVerificationDto { @IsNotEmpty() twoFactorAuthenticationCode: string; } +export class LoginMFACodeVerificationDto extends MFACodeVerificationDto { + @ApiProperty({ + name: 'sessionId', + description: 'Id of the session', + example: '1764056485204-0644acbc-bbc9-4b07-a613-811d2ad1ad4a', + }) + @IsNotEmpty() + @IsString() + sessionId: string; +} export class Generate2FA { @ApiProperty({ diff --git a/src/social-login/services/social-login.service.ts b/src/social-login/services/social-login.service.ts index e192e633..3c38efd3 100644 --- a/src/social-login/services/social-login.service.ts +++ b/src/social-login/services/social-login.service.ts @@ -10,18 +10,21 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { v4 as uuidv4 } from 'uuid'; import { Providers } from '../strategy/social.strategy'; -import { mapUserAccessList, sanitizeUrl } from 'src/utils/utils'; +import { generateHash, sanitizeUrl } from 'src/utils/utils'; import { SupportedServiceList } from 'src/supported-service/services/service-list'; import { SERVICE_TYPES } from 'src/supported-service/services/iServiceList'; -import { AuthneticatorType } from '../dto/response.dto'; import { authenticator } from 'otplib'; import { toDataURL } from 'qrcode'; import { DeleteMFADto, Generate2FA, + LoginMFACodeVerificationDto, MFACodeVerificationDto, } from '../dto/request.dto'; import { UserDocument, UserRole } from 'src/user/schema/user.schema'; +import { redisClient } from 'src/utils/redis.provider'; +import { TIME } from 'src/utils/time-constant'; +import { MFA_ERROR, ERROR_MESSAGE, REFRESH_TOKEN_ERROR } from '../constants/en'; @Injectable() export class SocialLoginService { @@ -42,7 +45,7 @@ export class SocialLoginService { this.config.get('GOOGLE_CALLBACK_URL') || sanitizeUrl( this.config.get('DEVELOPER_DASHBOARD_SERVICE_PUBLIC_EP'), - ) + '/api/v1/login/callback' + ) + '/api/v1/auth/google/callback' }&scope=email%20profile&prompt=select_account&client_id=${this.config.get( 'GOOGLE_CLIENT_ID', )}`; @@ -54,75 +57,58 @@ export class SocialLoginService { } return { authUrl }; } - - async socialLogin(req) { - Logger.log('socialLogin() starts', 'SocialLoginService'); + async socialLogin(req): Promise<{ + isMfaRequired: boolean; + refreshToken?: string; + accessToken?: string; + authenticators?: string[]; + sessionId?: string; + }> { + Logger.log( + 'Inside handleGoogleLogin() to create or fetch user detail based on login', + 'SocialLoginService', + ); const { email, name, profileIcon } = req.user; - const rawUrl = this.config.get('INVITATIONURL'); - const url = new URL(rawUrl); - const domain = url.origin; - let userInfo = await this.userRepository.findOne({ + let user = await this.userRepository.findOne({ email, }); - let appUserID; - if (!userInfo) { - appUserID = `${Date.now()}-${uuidv4()}`; - // Giving default access of services... - const ssiAccessList = this.supportedServiceList.getDefaultServicesAccess( - SERVICE_TYPES.SSI_API, - ); - const kycAccessList = this.supportedServiceList.getDefaultServicesAccess( - SERVICE_TYPES.CAVACH_API, - ); - // const questAccessList = - // this.supportedServiceList.getDefaultServicesAccess(SERVICE_TYPES.QUEST); - userInfo = await this.userRepository.create({ + if (!user) { + const userId = `${Date.now()}-${uuidv4()}`; + user = await this.userRepository.create({ email, - userId: appUserID, - name: name, + userId, + name, profileIcon, - accessList: [...ssiAccessList, ...kycAccessList], - role: UserRole.ADMIN, }); - } else { - const updates: Partial = {}; - if (!userInfo.name) updates.name = name; - if (!userInfo.profileIcon) updates.profileIcon = profileIcon; - if (Object.keys(updates).length > 0) { - this.userRepository.findOneUpdate({ email }, updates); - } } - Logger.log('socialLogin() starts', 'SocialLoginService'); + const updates: Partial = {}; + if (!user.name) updates.name = name; + if (!user.profileIcon) updates.profileIcon = profileIcon; + if (Object.keys(updates).length > 0) + this.userRepository.findOneUpdate({ email }, updates); - let isVerified = false; - let authenticator = null; - if (userInfo.authenticators && userInfo.authenticators.length > 0) { - authenticator = userInfo.authenticators?.find((x) => { - if (x && x.isTwoFactorAuthenticated) { - return x; - } - }); - isVerified = authenticator - ? authenticator.isTwoFactorAuthenticated - : false; + const { sessionId, activeAuthenticators, isMfaRequired, refreshVersion } = + await this.createSession(user); + + if (isMfaRequired) { + return { + isMfaRequired, + sessionId, + authenticators: activeAuthenticators.map((a) => a.type), + }; } - const payload = { - name, - email, - profileIcon, - appUserID: userInfo.userId, - userAccessList: mapUserAccessList(userInfo.accessList), - isTwoFactorEnabled: authenticator ? true : false, - isTwoFactorAuthenticated: req.user.isTwoFactorAuthenticated - ? req.user.isTwoFactorAuthenticated - : false, - authenticatorType: authenticator?.type, - aud: domain, - role: userInfo?.role || UserRole.ADMIN, + const tokens = await this.generateTokensForSession( + sessionId, + user.userId, + user?.role || UserRole.ADMIN, + refreshVersion, + ); + + return { + isMfaRequired, + sessionId, + ...tokens, }; - const authToken = await this.generateAuthToken(payload); - const refreshToken = await this.generateRefreshToken(payload); - return { authToken, refreshToken }; } async generate2FA(genrate2FADto: Generate2FA, user) { @@ -158,57 +144,79 @@ export class SocialLoginService { const otpAuthUrl = authenticator.keyuri(user.email, issuer, secret); return toDataURL(otpAuthUrl); } - async verifyMFACode(user, mfaVerificationDto: MFACodeVerificationDto) { + async verifyMFACode( + mfaVerificationDto: LoginMFACodeVerificationDto, + ): Promise<{ + isVerified: boolean; + accessToken?: string; + refreshToken?: string; + }> { Logger.log( 'Inside verifyMFACode() method to verify MFA code', 'SocialLoginService', ); - const { authenticatorType, twoFactorAuthenticationCode } = + + const { authenticatorType, twoFactorAuthenticationCode, sessionId } = mfaVerificationDto; - const authenticatorDetail = user.authenticators.find( + const sessionKey = `session:${sessionId}`; + const sessionDetailJson = await redisClient.get(sessionKey); + if (!sessionDetailJson) { + throw new BadRequestException(['Invalid or expired sessionId']); + } + const sessionDetail = JSON.parse(sessionDetailJson); + if (sessionDetail?.isTwoFactorVerified) { + throw new BadRequestException([MFA_ERROR.MFA_ALREADY_VERIFIED]); + } + const authenticatorDetail = sessionDetail.authenticators.find( (auth) => auth.type === authenticatorType, ); + if (!authenticatorDetail) { + throw new BadRequestException([MFA_ERROR.INVALID_MFA_METHOD]); + } + sessionDetail.twoFactorRetryCount = sessionDetail.twoFactorRetryCount ?? 0; const isVerified = authenticator.verify({ token: twoFactorAuthenticationCode, secret: authenticatorDetail.secret, }); - if (!authenticatorDetail.isTwoFactorAuthenticated && isVerified) { - // update - user.authenticators.map((authn) => { - if (authn.type === authenticatorType) { - authn.isTwoFactorAuthenticated = true; - return authn; - } - return authn; - }); - this.userRepository.findOneUpdate( - { userId: user.userId }, - { authenticators: user.authenticators }, + const maxRetryAttempts = this.config.get( + 'MAX_MFA_RETRY_ATTEMPT', + 3, + ); + + if (!isVerified) { + sessionDetail.twoFactorRetryCount++; + if (sessionDetail.twoFactorRetryCount > maxRetryAttempts) { + await redisClient.del(sessionKey); + throw new BadRequestException([MFA_ERROR.MFA_MAX_RETRY_EXCEEDED]); + } + await redisClient.set( + sessionKey, + JSON.stringify(sessionDetail), + 'EX', + TIME.WEEK, ); + return { isVerified: false }; } - const rawUrl = this.config.get('INVITATIONURL'); - const url = new URL(rawUrl); - const domain = url.origin; - const payload = { - email: user.email, - appUserID: user.userId, - userAccessList: mapUserAccessList(user.accessList), - isTwoFactorEnabled: user.authenticators && user.authenticators.length > 0, - isTwoFactorAuthenticated: isVerified, - authenticatorType, - accessAccount: user.accessAccount, - aud: domain, - role: user?.role || UserRole.ADMIN, - }; - const accessToken = await this.jwt.signAsync(payload, { - expiresIn: '24h', - secret: this.config.get('JWT_SECRET'), - }); - const refreshToken = await this.generateRefreshToken(payload); + delete sessionDetail?.authenticators; + delete sessionDetail.twoFactorRetryCount; + sessionDetail.isTwoFactorVerified = true; + sessionDetail.isTwoFactorAuthenticated = + sessionDetail.isTwoFactorAuthenticated; + await redisClient.set( + sessionKey, + JSON.stringify(sessionDetail), + 'EX', + TIME.WEEK, + ); + const tokens = await this.generateTokensForSession( + sessionId, + sessionDetail.userId, + sessionDetail.role, + sessionDetail.refreshVersion, + ); return { isVerified, - authToken: accessToken, - refreshToken, + ...tokens, }; } @@ -245,30 +253,53 @@ export class SocialLoginService { ); return { message: 'Removed authenticator successfully' }; } - async verifyAndGenerateRefreshToken(token: string) { + async verifyAndGenerateRefreshToken( + token: string, + ): Promise<{ error?: string; accessToken?: string; refreshToken?: string }> { try { - const tokenSecret = this.config.get('JWT_REFRESH_SECRET'); - if (!tokenSecret) { - throw new BadRequestException([ - 'JWT_REFRESH_SECRET is not set. Please contact the admin', - ]); + const sessionId = await redisClient.get(`refresh:${token}`); + if (!sessionId) { + return { error: REFRESH_TOKEN_ERROR.REFRESH_TOKEN_NOT_FOUND }; + } + const sessionKey = `session:${sessionId}`; + const sessionDetail = await redisClient.get(sessionKey); + if (!sessionDetail) { + return { + error: ERROR_MESSAGE.SESSION_NOT_FOUND, + }; + } + const sessionJson = JSON.parse(sessionDetail); + if (sessionJson?.mfaEnabled && !sessionJson?.mfaVerified) { + return { + error: MFA_ERROR.MFA_NOT_VERIFIED, + }; } - const payload = await this.jwt.verify(token, { secret: tokenSecret }); - delete payload?.exp; - delete payload?.iat; + sessionJson.refreshVersion += 1; const user = await this.userRepository.findOne({ - userId: payload.appUserID, + userId: sessionJson.userId, }); if (!user) throw new UnauthorizedException(['User not found']); - const newRefreshToken = await this.generateRefreshToken(payload); // make refresh token small - const authToken = await this.generateAuthToken(payload); - return { authToken, refreshToken: newRefreshToken }; + await redisClient.set( + sessionKey, + JSON.stringify(sessionJson), + 'EX', + TIME.WEEK, + ); + const newToken = await this.generateTokensForSession( + sessionJson.sessionId, + user.userId, + user?.role || UserRole.ADMIN, + sessionJson.refreshVersion, + ); + return { ...newToken }; } catch (e) { Logger.error( - `Error whaile generating refreshToken ${e}`, + `Error while generating refreshToken ${e}`, 'SocialLoginService', ); - throw new UnauthorizedException(['Invalid refresh token']); + throw new UnauthorizedException([ + REFRESH_TOKEN_ERROR.INVALID_REFRESH_TOKEN, + ]); } } async generateRefreshToken(payload: any): Promise { @@ -284,7 +315,7 @@ export class SocialLoginService { }); } - async generateAuthToken(payload: any): Promise { + async generateAuthToken(payload: any, expiry = '4h'): Promise { const secret = this.config.get('JWT_SECRET'); if (!secret) { throw new BadRequestException([ @@ -292,8 +323,148 @@ export class SocialLoginService { ]); } return this.jwt.signAsync(payload, { - expiresIn: '4h', + expiresIn: expiry, secret, }); } + + async createSession(user): Promise<{ + sessionId: string; + activeAuthenticators: any[]; + isMfaRequired: boolean; + refreshVersion: number; + }> { + const sessionId = generateHash(`${Date.now()}-${uuidv4()}`); + const role = user?.role || UserRole.ADMIN; + const activeAuthenticators = + user.authenticators?.filter( + (auth) => auth.isTwoFactorAuthenticated === true, + ) || []; + const refreshVersion = 1; + const isMfaRequired = activeAuthenticators.length > 0; + const sessionData: any = { + sessionId, + role, + refreshVersion: refreshVersion, + userId: user.userId, + isTwoFactorVerified: false, + isTwoFactorAuthenticated: isMfaRequired, + twoFactorRetryCount: 0, + }; + if (isMfaRequired) { + sessionData.authenticators = activeAuthenticators; + } + await redisClient.set( + `session:${sessionId}`, + JSON.stringify(sessionData), + 'EX', + TIME.WEEK, + ); + return { sessionId, activeAuthenticators, isMfaRequired, refreshVersion }; + } + + async generateTokensForSession(sessionId, userId, role, refreshVersion) { + const rawUrl = this.config.get('INVITATIONURL'); + const domain = new URL(rawUrl).origin; + const accessToken = await this.generateAuthToken({ + sid: sessionId, + sub: userId, + role, + aud: domain, + refreshVersion, + }); + const refreshToken = `${uuidv4()}`; + await redisClient.set( + `refresh:${refreshToken}`, + sessionId, + 'EX', + TIME.WEEK, + ); + return { accessToken, refreshToken }; + } + async confirmMfaSetup( + user, + session, + mfaVerificationDto: MFACodeVerificationDto, + ): Promise<{ isVerified: boolean; refreshToken?: string; error?: string }> { + Logger.log( + 'Inside confirmMfaSetup() method to Complete MFA setup', + 'SocialLoginService', + ); + const { authenticatorType, twoFactorAuthenticationCode } = + mfaVerificationDto; + const authenticatorDetail = user.authenticators.find( + (auth) => auth.type === authenticatorType, + ); + if (authenticatorDetail.isTwoFactorAuthenticated) { + return { + isVerified: false, + error: MFA_ERROR.MFA_ALREADY_ENABLED, + }; + } + const isVerified = authenticator.verify({ + token: twoFactorAuthenticationCode, + secret: authenticatorDetail.secret, + }); + if (!isVerified) { + return { isVerified, error: ERROR_MESSAGE.INVALID_OTP }; + } + if (!authenticatorDetail.isTwoFactorAuthenticated && isVerified) { + user.authenticators.map((authn) => { + if (authn.type === authenticatorType) { + authn.isTwoFactorAuthenticated = true; + return authn; + } + return authn; + }); + this.userRepository.findOneUpdate( + { userId: user.userId }, + { authenticators: user.authenticators }, + ); + const sessionKey = `session:${session.sessionId}`; + const sessionJson = await redisClient.get(sessionKey); + if (!sessionJson) { + return { + isVerified, + error: ERROR_MESSAGE.SESSION_NOT_FOUND, + }; + } + const sessionObj = JSON.parse(sessionJson); + sessionObj.isTwoFactorVerifed = true; + sessionObj.isTwoFactorAuthenticated = true; + sessionObj.refreshVersion += 1; + await redisClient.set( + sessionKey, + JSON.stringify(sessionObj), + 'EX', + TIME.WEEK, + ); + const newRefreshToken = uuidv4(); + await redisClient.set( + `refresh:${newRefreshToken}`, + session.sid, + 'EX', + TIME.WEEK, + ); + return { + isVerified, + refreshToken: newRefreshToken, + }; + } + return { isVerified }; + } + async logout(refreshToken, session) { + try { + const sessionId = session.sessionId; + if (sessionId) await redisClient.del(`session:${sessionId}`); + if (refreshToken) await redisClient.del(`refresh:${refreshToken}`); + return { success: true }; + } catch (e) { + Logger.error( + 'Inside logout() to delete data from redis', + 'SocialLoginService', + ); + return { success: false }; + } + } } diff --git a/src/social-login/social-login.module.ts b/src/social-login/social-login.module.ts index cc3dc28c..f13498fc 100644 --- a/src/social-login/social-login.module.ts +++ b/src/social-login/social-login.module.ts @@ -14,7 +14,6 @@ import { AppAuthModule } from 'src/app-auth/app-auth.module'; import { JWTAuthorizeMiddleware } from 'src/utils/middleware/jwt-authorization.middleware'; import { SupportedServiceModule } from 'src/supported-service/supported-service.module'; import { SupportedServiceList } from 'src/supported-service/services/service-list'; -import { TwoFAAuthorizationMiddleware } from 'src/utils/middleware/2FA-jwt-authorization.middleware'; import { RateLimitMiddleware } from 'src/utils/middleware/rate-limit.middleware'; import { EmailOtpLoginController } from './controller/email-otp-login.controller'; import { EmailOtpLoginService } from './services/email-otp-login.service'; @@ -42,7 +41,7 @@ export class SocialLoginModule implements NestModule { consumer .apply(WhitelistAppCorsMiddleware) .exclude({ - path: '/api/v1/login/callback', + path: '/api/v1/auth/google/callback', method: RequestMethod.GET, }) .forRoutes(SocialLoginController, EmailOtpLoginController); @@ -50,48 +49,19 @@ export class SocialLoginModule implements NestModule { .apply(JWTAuthorizeMiddleware) .exclude( { - path: '/api/v1/login', + path: '/api/v1/auth/google/authorize', method: RequestMethod.GET, }, { - path: '/api/v1/login/callback', + path: '/api/v1/auth/google/callback', method: RequestMethod.GET, }, { - path: '/api/v1/auth/refresh', + path: '/api/v1/auth/tokens/refresh', method: RequestMethod.POST, }, - ) - .forRoutes(SocialLoginController); - consumer - .apply(TwoFAAuthorizationMiddleware) - .exclude( - { - path: '/api/v1/login', - method: RequestMethod.GET, - }, - { - path: '/api/v1/auth/logout', - method: RequestMethod.POST, - }, - { - path: '/api/v1/login/callback', - method: RequestMethod.GET, - }, - { - path: '/api/v1/auth/mfa/generate', - method: RequestMethod.POST, - }, - { - path: '/api/v1/auth/mfa/verify', - method: RequestMethod.POST, - }, - { - path: '/api/v1/auth', - method: RequestMethod.POST, - }, // either do this or send the user data in auth api with a message 2FA is required { - path: '/api/v1/auth/refresh', + path: '/api/v1/auth/mfa/login/verify', method: RequestMethod.POST, }, ) @@ -100,15 +70,15 @@ export class SocialLoginModule implements NestModule { .apply(RateLimitMiddleware) .exclude( { - path: '/api/v1/login', + path: '/api/v1/auth/google/authorize', method: RequestMethod.GET, }, { - path: '/api/v1/login/callback', + path: '/api/v1/auth/google/callback', method: RequestMethod.GET, }, { - path: '/api/v1/auth', + path: '/api/v1/users/me', method: RequestMethod.POST, }, ) diff --git a/src/social-login/strategy/social.strategy.ts b/src/social-login/strategy/social.strategy.ts index 89e2b73f..2d3a168c 100644 --- a/src/social-login/strategy/social.strategy.ts +++ b/src/social-login/strategy/social.strategy.ts @@ -15,7 +15,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { callbackURL: process.env.GOOGLE_CALLBACK_URL || sanitizeUrl(process.env.DEVELOPER_DASHBOARD_SERVICE_PUBLIC_EP) + - '/api/v1/login/callback', + '/api/v1/auth/google/callback', scope: ['email', 'profile'], session: false, }); diff --git a/src/supported-service/services/iServiceList.ts b/src/supported-service/services/iServiceList.ts index e2d3bb31..dedb4cb9 100644 --- a/src/supported-service/services/iServiceList.ts +++ b/src/supported-service/services/iServiceList.ts @@ -54,15 +54,21 @@ export namespace SERVICES { export namespace SSI_API { export enum ACCESS_TYPES { ALL = 'ALL', - 'CREATE_DID' = 'CREATE_DID', - 'REGISTER_DID' = 'REGISTER_DID', - 'RESOLVE_DID' = 'RESOLVE_DID', - 'ISSUE_CREDENTIAL' = 'ISSUE_CREDENTIAL', - 'VERIFY_CREDENTIAL' = 'VERIFY_CREDENTIAL', - 'REGISTER_CREDENTIAL_STATUS' = 'REGISTER_CREDENTIAL_STATUS', - 'RESOLVE_CREDENTIAL_STATUS' = 'RESOLVE_CREDENTIAL_STATUS', - 'RESOLVE_SCHEMA' = 'RESOLVE_SCHEMA', - 'REGISTER_SCHEMA' = 'REGISTER_SCHEMA', + READ_DID = 'READ_DID', + WRITE_DID = 'WRITE_DID', + WRITE_CREDIT = 'WRITE_CREDIT', + VERIFY_DID_SIGNATURE = 'VERIFY_DID_SIGNATURE', + READ_CREDIT = 'READ_CREDIT', + WRITE_SCHEMA = 'WRITE_SCHEMA', + READ_SCHEMA = 'READ_SCHEMA', + CHECK_LIVE_STATUS = 'CHECK_LIVE_STATUS', + READ_TX = 'READ_TX', + READ_CREDENTIAL = 'READ_CREDENTIAL', + VERIFY_CREDENTIAL = 'VERIFY_CREDENTIAL', + WRITE_CREDENTIAL = 'WRITE_CREDENTIAL', + READ_USAGE = 'READ_USAGE', + WRITE_PRESENTATION = 'WRITE_PRESENTATION', + VERIFY_PRESENTATION = 'VERIFY_PRESENTATION', } } @@ -79,6 +85,17 @@ export namespace SERVICES { READ_WIDGET_CONFIG = 'READ_WIDGET_CONFIG', WRITE_WIDGET_CONFIG = 'WRITE_WIDGET_CONFIG', UPDATE_WIDGET_CONFIG = 'UPDATE_WIDGET_CONFIG', + WRITE_WEBHOOK_CONFIG = 'WRITE_WEBHOOK_CONFIG', + READ_WEBHOOK_CONFIG = 'READ_WEBHOOK_CONFIG', + UPDATE_WEBHOOK_CONFIG = 'UPDATE_WEBHOOK_CONFIG', + DELETE_WEBHOOK_CONFIG = 'DELETE_WEBHOOK_CONFIG', + READ_VERIFIED_USER = 'READ_VERIFIED_USER', + READ_ANALYTICS = 'READ_ANALYTICS', + READ_USAGE = 'READ_USAGE', + WRITE_CREDIT = 'WRITE_CREDIT', + READ_CREDIT = 'READ_CREDIT', + CHECK_LIVE_STATUS = 'CHECK_LIVE_STATUS', + WRITE_AUTH = 'WRITE_AUTH', } } @@ -99,3 +116,7 @@ export namespace SERVICES { } } } + +export enum Context { + idDashboard = 'idDashboard', +} diff --git a/src/user/schema/user.schema.ts b/src/user/schema/user.schema.ts index b8af9553..2cfc27ed 100644 --- a/src/user/schema/user.schema.ts +++ b/src/user/schema/user.schema.ts @@ -38,9 +38,9 @@ export class User { name?: string; @Prop({ required: false }) profileIcon?: string; - @Prop({ required: false }) + @Prop({ required: false, default: [] }) @Optional() - accessList: Array; + accessList?: Array; @Prop({ default: [] }) authenticators?: Authenticator[]; @Prop({ diff --git a/src/utils/customDecorator/IsUrlOrBase64Image.decorator.ts b/src/utils/customDecorator/IsUrlOrBase64Image.decorator.ts new file mode 100644 index 00000000..507673a4 --- /dev/null +++ b/src/utils/customDecorator/IsUrlOrBase64Image.decorator.ts @@ -0,0 +1,30 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; + +export function IsUrlOrBase64Image(options?: ValidationOptions) { + return function (object: any, propertyName: string) { + registerDecorator({ + name: 'IsImageUrlOrBase64', + target: object.constructor, + propertyName, + options, + validator: { + validate(value: any, args: ValidationArguments) { + if (!value) return true; // allow empty if optional + + const urlRegex = /^(https?:\/\/)[^\s]+$/i; + const base64Regex = + /^data:(image\/(png|jpe?g|gif|webp));base64,[A-Za-z0-9+/=]+$/; + + return urlRegex.test(value) || base64Regex.test(value); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid URL or Base64 encoded image`; + }, + }, + }); + }; +} diff --git a/src/utils/middleware/2FA-jwt-authorization.middleware.ts b/src/utils/middleware/2FA-jwt-authorization.middleware.ts deleted file mode 100644 index 0a71f67d..00000000 --- a/src/utils/middleware/2FA-jwt-authorization.middleware.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - Injectable, - Logger, - NestMiddleware, - UnauthorizedException, -} from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; - -@Injectable() -export class TwoFAAuthorizationMiddleware implements NestMiddleware { - async use(req: Request, res: Response, next: NextFunction) { - Logger.log( - 'Inside TwoFAAuthorizationMiddleware', - 'TwoFAAuthorizationMiddleware', - ); - if (!req['user'] || Object.keys(req['user']).length === 0) { - throw new UnauthorizedException(['User not authenticated']); - } - const user = req['user']; - if (user['authenticators'] && user['authenticators'].length > 0) { - const isAnyAuthenticator2FA = user['authenticators'].some( - (authenticator) => authenticator.isTwoFactorAuthenticated, - ); - if (isAnyAuthenticator2FA && !user['isTwoFactorAuthenticated']) { - throw new UnauthorizedException(['2FA authentication is required']); - } - } - next(); - } -} diff --git a/src/utils/middleware/jwt-accessAccount.middlerwere.ts b/src/utils/middleware/jwt-accessAccount.middlerwere.ts index d67ccc0a..5962ec44 100644 --- a/src/utils/middleware/jwt-accessAccount.middlerwere.ts +++ b/src/utils/middleware/jwt-accessAccount.middlerwere.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { + BadRequestException, Injectable, Logger, NestMiddleware, @@ -7,34 +8,47 @@ import { } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { AdminPeopleRepository } from 'src/people/repository/people.repository'; +import { AUTH_ERRORS } from 'src/social-login/constants/en'; @Injectable() export class JWTAccessAccountMiddleware implements NestMiddleware { constructor(private readonly adminPeople: AdminPeopleRepository) {} async use(req: Request, res: Response, next: NextFunction) { try { // @ts-ignore - if (req.user?.accessAccount !== undefined) { - // @ts-ignore + const user = req.user; + const session = req['session']; + if (!session) { + throw new Error(AUTH_ERRORS.SESSION_EXPIRED); + } + if (!session.tenantId) { + return next(); + } + const tenantId = req['session'].tenantId; - const userId = req.user.userId; + if (tenantId !== undefined) { // @ts-ignore - const adminId = req.user.accessAccount.userId; - if (adminId !== userId) { - const member = await this.adminPeople.findOne({ adminId, userId }); - if (member == null) { - throw new Error('Your access has been revoked'); - } + const userId = user.userId; + // @ts-ignore + const member = await this.adminPeople.findOne({ + adminId: tenantId, + userId, + }); + if (member == null) { + throw new Error(AUTH_ERRORS.ACCESS_REVOKED); } // @ts-ignore - req.user.userId = req.user.accessAccount.userId; + user.userId = tenantId; + if ( + !session?.tenantUserPermissions || + session.tenantUserPermissions.length === 0 + ) { + throw new Error(AUTH_ERRORS.TENANT_PERMISSION_ISSUE); + } // @ts-ignore - - req.user.accessList = req.user.accessAccount.accessList; + user.accessList = session?.tenantUserPermissions; // @ts-ignore - - req.user.email = req.user.accessAccount.email; } Logger.log(JSON.stringify(req.user), 'JWTAccessAccountMiddleware'); @@ -43,7 +57,7 @@ export class JWTAccessAccountMiddleware implements NestMiddleware { `JWTAccessAccountMiddleware: Error ${e}`, 'JWTAccessAccountMiddleware', ); - throw new UnauthorizedException([e]); + throw new UnauthorizedException([e.message]); } next(); } diff --git a/src/utils/middleware/jwt-authorization.middleware.ts b/src/utils/middleware/jwt-authorization.middleware.ts index 3c8d1eb9..37672d48 100644 --- a/src/utils/middleware/jwt-authorization.middleware.ts +++ b/src/utils/middleware/jwt-authorization.middleware.ts @@ -1,4 +1,5 @@ import { + HttpException, Injectable, Logger, NestMiddleware, @@ -8,16 +9,21 @@ import * as jwt from 'jsonwebtoken'; import { NextFunction, Request, Response } from 'express'; import { UserRepository } from 'src/user/repository/user.repository'; import { sanitizeUrl } from '../utils'; +import { redisClient } from '../redis.provider'; +import { + AUTH_ERRORS, + ERROR_MESSAGE, + MFA_ERROR, + REFRESH_TOKEN_ERROR, +} from 'src/social-login/constants/en'; @Injectable() export class JWTAuthorizeMiddleware implements NestMiddleware { constructor(private readonly userRepository: UserRepository) {} async use(req: Request, res: Response, next: NextFunction) { Logger.log('Inside JWTAuthorizeMiddleware', 'JWTAuthorizeMiddleware'); - const authToken: string = req?.cookies?.authToken; + const authToken: string = req?.cookies?.accessToken; if (!authToken) { - throw new UnauthorizedException([ - 'Please pass authorization token in cookie', - ]); + throw new UnauthorizedException([AUTH_ERRORS.EMPTY_TOKEN]); } let decoded; try { @@ -48,42 +54,52 @@ export class JWTAuthorizeMiddleware implements NestMiddleware { sanitizeUrl(decoded.aud, false), ); if (!ifDomainValid) { - throw new Error( - 'This token was issued for a different domain than the one making the request.', - ); + throw new Error(AUTH_ERRORS.TOKEN_DOMAIN_MISMATCH); } } else { - throw new Error('Token does not contain a valid domain.'); + throw new Error(AUTH_ERRORS.TOKEN_DOMAIN_MISSING); } } - const user = await this.userRepository.findOne({ - userId: decoded.appUserID, - }); - if (!user) { - throw new Error('User not found'); + const { sid, sub } = decoded; + if (!sid || !sub) { + throw new UnauthorizedException([AUTH_ERRORS.INVALID_TOKEN]); } - req['user'] = user; - - if (decoded.isTwoFactorEnabled !== undefined) { - req['user']['isTwoFactorEnabled'] = decoded.isTwoFactorEnabled; + const sessionRaw = await redisClient.get(`session:${sid}`); + if (!sessionRaw) { + throw new UnauthorizedException([AUTH_ERRORS.SESSION_EXPIRED]); } - - if (decoded.isTwoFactorAuthenticated !== undefined) { - req['user']['isTwoFactorAuthenticated'] = - decoded.isTwoFactorAuthenticated; + const session = JSON.parse(sessionRaw); + if (session.userId !== decoded.sub) { + throw new UnauthorizedException([AUTH_ERRORS.SESSION_MISMATCH]); } - - if (decoded.accessAccount !== undefined) { - req['user']['accessAccount'] = decoded.accessAccount; + if (session.refreshVersion !== decoded.refreshVersion) { + throw new UnauthorizedException([ + REFRESH_TOKEN_ERROR.REFRESH_VERSION_MISMATCH, + ]); } - Logger.log(JSON.stringify(req.user), 'JWTAuthorizeMiddleware'); + if (session.isTwoFactorAuthenticated) { + if (!session.isTwoFactorVerified) { + throw new UnauthorizedException([MFA_ERROR.TWO_FA_REQUIRED]); + } + } + const user = await this.userRepository.findOne({ + userId: decoded.sub, + }); + if (!user) { + throw new Error(ERROR_MESSAGE.USER_NOT_FOUND); + } + req['user'] = user; + req['session'] = session; } } catch (e) { Logger.error( `JWTAuthorizeMiddleware: Error ${e}`, 'JWTAuthorizeMiddleware', ); + if (e instanceof HttpException) { + throw e; + } throw new UnauthorizedException([e.message]); } next(); diff --git a/src/utils/time-constant.ts b/src/utils/time-constant.ts index f1ad546c..ee1093e4 100644 --- a/src/utils/time-constant.ts +++ b/src/utils/time-constant.ts @@ -10,6 +10,21 @@ export const TOKEN_MAX_AGE = { AUTH_TOKEN: 4 * TIME.HOUR * 1000, // 4 hours REFRESH_TOKEN: 7 * TIME.DAY * 1000, // 7 days }; +export const TOKEN = { + AUTH: { + name: 'accessToken', + expiry: 30 * TIME.MINUTE * 1000, + }, + REFRESH: { + name: 'refreshToken', + expiry: 7 * TIME.DAY * 1000, + }, + VERIFIER_TOKEN: { + name: 'verifierPageToken', + expiry: 30 * TIME.MINUTE, + jwtExpiry: 0.5, + }, +}; export enum JobNames { SEND_EMAIL_LOGIN_OTP = 'send-email-login-otp', diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 48311932..8ad42d1b 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -15,10 +15,17 @@ import { } from '@nestjs/common'; import { Did } from 'hs-ssi-sdk'; import { + Context, SERVICE_TYPES, SERVICES, } from 'src/supported-service/services/iServiceList'; - +import { + KYC_ACCESS_MATRIX, + QUEST_ACCESS_MATRIX, + SSI_ACCESS_MATRIX, + TokenModule, +} from 'src/config/access-matrix'; +import { createHash } from 'crypto'; export const existDir = (dirPath) => { if (!dirPath) throw new Error('Directory path undefined'); return fs.existsSync(dirPath); @@ -154,3 +161,70 @@ export function mapUserAccessList(userAccessList) { }, ]; } + +export function getCookieOptions(maxAge?: number, isClear = false) { + const cookieDomain = process.env.COOKIE_DOMAIN; + const isProd = process.env.NODE_ENV || 'production'; + return { + httpOnly: true, + secure: isProd === 'production' ? true : false, + sameSite: isProd === 'production' ? 'None' : 'Lax', + domain: isProd ? cookieDomain : undefined, + path: '/', + ...(isClear ? {} : { maxAge }), + }; +} + +export const REDIS_KEYS = { + SESSION: 'session:', + REFRESH_TOKEN: 'refreshToken:', + VERIFIER_PAGE_TOKEN: 'verifierPageToken:', +}; +export function getAccessListForModule( + module: TokenModule, + serviceType: SERVICE_TYPES, +) { + switch (serviceType) { + case SERVICE_TYPES.CAVACH_API: + return KYC_ACCESS_MATRIX[module] || []; + case SERVICE_TYPES.SSI_API: + return SSI_ACCESS_MATRIX[module] || []; + case SERVICE_TYPES.QUEST: + return QUEST_ACCESS_MATRIX[module] || []; + } +} +export const evaluateAccessPolicy = ( + defaultAccessList: string[], + serviceType: SERVICE_TYPES, + userAccessList?: { + serviceType: SERVICE_TYPES; + access: string; + expiryDate?: Date; + }[], + context?: string, +): string[] => { + if (!context) { + return defaultAccessList; + } + if (context === Context.idDashboard) { + // No user access info → Return NO access + if (!userAccessList?.length) { + return []; + } + const userServiceAccess = userAccessList + .filter((a) => a.serviceType === serviceType) + .map((a) => a.access); + + // User With ALL access + if (userServiceAccess.includes('ALL')) { + return defaultAccessList; + } + // Intersection rule + return defaultAccessList.filter((p) => userServiceAccess.includes(p)); + } + return defaultAccessList; +}; + +export function generateHash(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} diff --git a/src/webpage-config/constant/en.ts b/src/webpage-config/constant/en.ts new file mode 100644 index 00000000..fde9430b --- /dev/null +++ b/src/webpage-config/constant/en.ts @@ -0,0 +1,9 @@ +export const WEBPAGE_CONFIG_ERRORS = { + WEBPAGE_CONFIG_NOT_FOUND: 'Webpage configuration not found.', + WEBPAGE_CONFIG_LINKED_APP_NOT_FOUND: + 'Linked app not found for the webpage configuration.', + WEBPAGE_CONFIG_SSI_SERVICE_NOT_FOUND: + 'Linked SSI service not found for the webpage configuration.', + WEBPAGE_CONFIG_SSI_SERVICE_DOES_NOT_EXIST: + 'SSI service does not exist or has been deleted.', +}; diff --git a/src/webpage-config/controller/webpage-config.controller.ts b/src/webpage-config/controller/webpage-config.controller.ts index 9b63ec68..1083837f 100644 --- a/src/webpage-config/controller/webpage-config.controller.ts +++ b/src/webpage-config/controller/webpage-config.controller.ts @@ -11,19 +11,21 @@ import { Req, UsePipes, ValidationPipe, + Query, } from '@nestjs/common'; import { WebpageConfigService } from '../services/webpage-config.service'; import { CreateWebpageConfigDto, - CreateWebpageConfigResponseDto, CreateWebpageConfigResponseWithDetailDto, FetchWebpageConfigResponseDto, + VerifierPageTokenResponse, } from '../dto/create-webpage-config.dto'; import { UpdateWebpageConfigDto } from '../dto/update-webpage-config.dto'; import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, + ApiQuery, ApiTags, } from '@nestjs/swagger'; import { AllExceptionsFilter } from 'src/utils/utils'; @@ -31,7 +33,7 @@ import { AllExceptionsFilter } from 'src/utils/utils'; @ApiTags('Webpage-config') @UseFilters(AllExceptionsFilter) @UsePipes(new ValidationPipe()) -@Controller('api/v1/app') +@Controller('api/v1/app/') export class WebpageConfigController { constructor(private readonly webpageConfigService: WebpageConfigService) {} @@ -39,17 +41,16 @@ export class WebpageConfigController { description: 'Webpage configuration saved successfully', type: CreateWebpageConfigResponseWithDetailDto, }) - @Post(':appId/kyc-webpage-config') + @Post('verifier') + @ApiQuery({ name: 'appId', required: true, type: String }) configureWebPageDetail( - @Param('appId') serviceId: string, + @Query('appId') serviceId: string, @Body() createWebpageConfigDto: CreateWebpageConfigDto, - @Req() req, ) { Logger.log('inside configureWebPageDetail(): to configure webpage detail'); return this.webpageConfigService.storeWebPageConfigDetial( serviceId, createWebpageConfigDto, - req.user, ); } @@ -57,7 +58,7 @@ export class WebpageConfigController { description: 'Webpage configuration list', type: FetchWebpageConfigResponseDto, }) - @Get(':appId/kyc-webpage-config') + @Get(':appId/verifier') async fetchWebPageConfigurationDetail(@Param('appId') appId: string) { Logger.log( 'Inside fetchWebPageConfigurationDetail() to fetch webpageData', @@ -70,32 +71,25 @@ export class WebpageConfigController { description: 'Webpage configuration fetched successfully', type: FetchWebpageConfigResponseDto, }) - @Get(':appId/kyc-webpage-config/:id') - fetchAWebPageConfigurationDetail( - @Param('appId') appId: string, - @Param('id') id: string, - ) { - return this.webpageConfigService.fetchAWebPageConfigurationDetail( - id, - appId, - ); + @Get('verifier/:id') + fetchAWebPageConfigurationDetail(@Param('id') id: string) { + return this.webpageConfigService.fetchAWebPageConfigurationDetail(id); } @ApiOkResponse({ description: 'Webpage configuration updated successfully', type: FetchWebpageConfigResponseDto, }) - @Patch(':appId/kyc-webpage-config/:id') + @Patch('verifier/:id') + @ApiQuery({ name: 'appId', required: true, type: String }) updateWebPageConfiguration( - @Param('appId') appId: string, + @Query('appId') appId: string, @Param('id') id: string, @Body() updateWebpageConfigDto: UpdateWebpageConfigDto, - @Req() req, ) { return this.webpageConfigService.updateWebPageConfiguration( id, updateWebpageConfigDto, - req.user, appId, ); } @@ -104,11 +98,24 @@ export class WebpageConfigController { description: 'Webpage configuration deleted successfully', type: FetchWebpageConfigResponseDto, }) - @Delete(':appId/kyc-webpage-config/:id') + @Delete('verifier/:id') + @ApiQuery({ name: 'appId', required: true, type: String }) removeWebPageConfiguration( - @Param('appId') appId: string, + @Query('appId') appId: string, @Param('id') id: string, ) { return this.webpageConfigService.removeWebPageConfiguration(id, appId); } + @ApiOkResponse({ + description: 'Verifier webpage token generated successfully', + type: VerifierPageTokenResponse, + }) + @Post('verifier/:id/tokens') + @ApiQuery({ name: 'appId', required: true, type: String }) + generateWebpageConfigTokens( + @Query('appId') appId: string, + @Param('id') id: string, + ) { + return this.webpageConfigService.generateWebpageConfigTokens(id, appId); + } } diff --git a/src/webpage-config/dto/create-webpage-config.dto.ts b/src/webpage-config/dto/create-webpage-config.dto.ts index 2a729493..68d5e063 100644 --- a/src/webpage-config/dto/create-webpage-config.dto.ts +++ b/src/webpage-config/dto/create-webpage-config.dto.ts @@ -147,35 +147,38 @@ export class CreateWebpageConfigResponseWithDetailDto extends CreateWebpageConfi export class FetchWebpageConfigResponseDto extends CreateWebpageConfigResponseWithDetailDto { @ApiProperty({ - name: 'ssiAccessToken', - description: 'ssiToken', - example: 'eyJhbGciOiJIUzI1Ni.......', + name: 'createdAt', + description: 'Document creation date', + example: '2025-08-14T11:48:37.389Z', }) @IsString() @IsNotEmpty() - ssiAccessToken: string; + createdAt: string; @ApiProperty({ - name: 'kycAccessToken', - description: 'kycAccessToken', - example: 'eyJhbGciOiJIUzI1Ni.......', + name: 'updatedAt', + description: 'Document updation date', + example: '2025-08-14T12:48:37.389Z', }) @IsString() @IsNotEmpty() - kycAccessToken: string; + updatedAt: string; +} + +export class VerifierPageTokenResponse { @ApiProperty({ - name: 'createdAt', - description: 'Document creation date', - example: '2025-08-14T11:48:37.389Z', + name: 'ssiAccessToken', + description: 'ssiToken', + example: 'eyJhbGciOiJIUzI1Ni.......', }) @IsString() @IsNotEmpty() - createdAt: string; + ssiAccessToken: string; @ApiProperty({ - name: 'updatedAt', - description: 'DOcument updation date', - example: '2025-08-14T12:48:37.389Z', + name: 'kycAccessToken', + description: 'kycAccessToken', + example: 'eyJhbGciOiJIUzI1Ni.......', }) @IsString() @IsNotEmpty() - updatedAt: string; + kycAccessToken: string; } diff --git a/src/webpage-config/repositories/webpage-config.repository.ts b/src/webpage-config/repositories/webpage-config.repository.ts index 6cb17fe0..f72ddbf1 100644 --- a/src/webpage-config/repositories/webpage-config.repository.ts +++ b/src/webpage-config/repositories/webpage-config.repository.ts @@ -64,7 +64,6 @@ export class WebPageConfigRepository { 'findOneAndDelete() method: starts, delete app data to db', 'WebPageConfigRepository', ); - return this.webPageConfigModel.findOneAndDelete(appFilterQuery); } } diff --git a/src/webpage-config/schema/webpage-config.schema.ts b/src/webpage-config/schema/webpage-config.schema.ts index 5f0a58c3..c3834ee3 100644 --- a/src/webpage-config/schema/webpage-config.schema.ts +++ b/src/webpage-config/schema/webpage-config.schema.ts @@ -1,15 +1,14 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { ExpiryType, PageType } from '../dto/create-webpage-config.dto'; +import { Types } from 'mongoose'; export type WebpageConfigDocument = WebPageConfig & Document; @Schema({ timestamps: true }) export class WebPageConfig { + @Prop({ type: Types.ObjectId, default: () => new Types.ObjectId() }) + _id: Types.ObjectId; @Prop({ type: String, required: false, default: '#f8f9fa' }) themeColor: string; - @Prop({ type: String }) - ssiAccessToken: string; - @Prop({ type: String }) - kycAccessToken: string; @Prop({ type: String, enum: ExpiryType, required: true }) expiryType: ExpiryType; @Prop({ type: String, unique: true }) diff --git a/src/webpage-config/services/webpage-config.service.ts b/src/webpage-config/services/webpage-config.service.ts index a9c85e40..70cb1c9f 100644 --- a/src/webpage-config/services/webpage-config.service.ts +++ b/src/webpage-config/services/webpage-config.service.ts @@ -8,6 +8,7 @@ import { import { CreateWebpageConfigDto, CreateWebpageConfigResponseDto, + ExpiryType, } from '../dto/create-webpage-config.dto'; import { UpdateWebpageConfigDto } from '../dto/update-webpage-config.dto'; import { AppRepository } from 'src/app-auth/repositories/app.repository'; @@ -19,6 +20,17 @@ import { WebPageConfigRepository } from '../repositories/webpage-config.reposito import { SERVICE_TYPES } from 'src/supported-service/services/iServiceList'; import { ConfigService } from '@nestjs/config'; import { urlSanitizer } from 'src/utils/sanitizeUrl.validator'; +import { isValidObjectId, Types } from 'mongoose'; +import { WEBPAGE_CONFIG_ERRORS } from '../constant/en'; +import { redisClient } from 'src/utils/redis.provider'; +import { TOKEN } from 'src/utils/time-constant'; +import { + evaluateAccessPolicy, + generateHash, + getAccessListForModule, + REDIS_KEYS, +} from 'src/utils/utils'; +import { TokenModule } from 'src/config/access-matrix'; @Injectable() export class WebpageConfigService { @@ -31,7 +43,6 @@ export class WebpageConfigService { async storeWebPageConfigDetial( serviceId: string, createWebpageConfigDto: CreateWebpageConfigDto, - userDetail, ): Promise { Logger.log( 'Inside storeWebPageConfigDetial to store webpage configuration', @@ -66,24 +77,24 @@ export class WebpageConfigService { const { appName, logoUrl, env = 'dev' } = serviceDetail; const tenantUrl: string = serviceDetail['tenantUrl']; - const tokenAndExpiryDetail = await this.generateTokenBasedOnExpiry( - serviceDetail, - userDetail.accessList, + const { expiryDate } = await this.generateExpiryDate( expiryType, customExpiryDate, - serviceDetail.dependentServices[0], ); const veriferAppBaseUrl = this.config.get('KYC_VERIFIER_APP_BASE_URL') || 'https://verifier.hypersign.id'; - const generatedUrl = `${urlSanitizer(veriferAppBaseUrl, true)}${serviceId}`; + const id = new Types.ObjectId(); + const generatedUrl = `${urlSanitizer( + veriferAppBaseUrl, + true, + )}${id.toString()}`; const payload = { + _id: id, serviceId, themeColor, - ssiAccessToken: tokenAndExpiryDetail.ssiAccessToken, - kycAccessToken: tokenAndExpiryDetail.kycAccessToken, expiryType, - expiryDate: tokenAndExpiryDetail.expiryDate, + expiryDate, pageDescription, pageTitle, pageType, @@ -96,10 +107,8 @@ export class WebpageConfigService { payload, ); const webpageConfigObject = webpageConfigData; - const { ssiAccessToken, kycAccessToken, ...responseData } = - webpageConfigObject; return { - ...responseData, + ...webpageConfigObject, serviceName: appName, developmentStage: env, logoUrl, @@ -141,16 +150,19 @@ export class WebpageConfigService { async fetchAWebPageConfigurationDetail( id: string, - serviceId: string, ): Promise { + const isValidId = isValidObjectId(id); + const query: any = { + $or: [{ serviceId: id }], + }; + if (isValidId) { + query.$or.push({ _id: new Types.ObjectId(id) }); + } const webpageConfiguration = - await this.webPageConfigRepo.findAWebpageConfig({ - _id: id, - serviceId, - }); + await this.webPageConfigRepo.findAWebpageConfig(query); if (!webpageConfiguration || webpageConfiguration == null) { throw new NotFoundException([ - `No webpage configuration found for serviceId: ${serviceId} and docId: ${id}`, + `No webpage configuration found for id: ${id}`, ]); } return webpageConfiguration; @@ -159,7 +171,6 @@ export class WebpageConfigService { async updateWebPageConfiguration( id: string, updateWebpageConfigDto: UpdateWebpageConfigDto, - userDetail, serviceId, ): Promise { const serviceDetail = await this.appRepository.findOne({ @@ -178,22 +189,16 @@ export class WebpageConfigService { 'KYC service must have a dependent SSI service linked to it.', ]); } - let tokenDetail; const dataToUpdate = { ...updateWebpageConfigDto }; if (updateWebpageConfigDto.expiryType) { - tokenDetail = await this.generateTokenBasedOnExpiry( - serviceDetail, - userDetail.accessList, + const { expiryDate } = await this.generateExpiryDate( updateWebpageConfigDto.expiryType, updateWebpageConfigDto.customExpiryDate, - serviceDetail.dependentServices[0], ); - dataToUpdate['expiryDate'] = tokenDetail.expiryDate; - dataToUpdate['ssiAccessToken'] = tokenDetail.ssiAccessToken; - dataToUpdate['kycAccessToken'] = tokenDetail.kycAccessToken; + dataToUpdate['expiryDate'] = expiryDate; } const webpageConfiguration = await this.webPageConfigRepo.findOneAndUpdate( - { _id: id }, + { _id: new Types.ObjectId(id) }, dataToUpdate, ); if (!webpageConfiguration || webpageConfiguration == null) { @@ -213,7 +218,7 @@ export class WebpageConfigService { async removeWebPageConfiguration(id: string, serviceId: string) { const deletedConfig = await this.webPageConfigRepo.findOneAndDelete({ - _id: id, + _id: new Types.ObjectId(id), serviceId, }); if (!deletedConfig) { @@ -224,44 +229,10 @@ export class WebpageConfigService { return deletedConfig; } - private async generateTokenBasedOnExpiry( - serviceDetail, - userAccessList, - expiryType, - customExpiryDate, - ssiServiceId, - ) { - // Get both SSI & KYC access lists - Logger.log( - 'Inside generateTokenBasedOnExpiry(): Method to generate ssi and kyc token', - 'removeWebPageConfiguration', - ); - const ssiAccessList = (userAccessList || []) - .filter( - (x) => - x.serviceType === SERVICE_TYPES.SSI_API && - !this.appAuthService.checkIfDateExpired(x.expiryDate), - ) - .map((x) => x.access); - - const kycAccessList = (userAccessList || []) - .filter( - (x) => - x.serviceType === SERVICE_TYPES.CAVACH_API && - !this.appAuthService.checkIfDateExpired(x.expiryDate), - ) - .map((x) => x.access); - - if (ssiAccessList.length <= 0 || kycAccessList.length <= 0) { - throw new UnauthorizedException([ - `You are not authorized for both SSI and KYC services.`, - ]); - } - - // Calculate expiresIn + private async generateExpiryDate(expiryType, customExpiryDate) { let expiresIn: number; let expiryDate: Date; - if (expiryType === 'custom') { + if (expiryType === ExpiryType.CUSTOM) { if (!customExpiryDate) { throw new BadRequestException([ 'Custom expiry date is required when expiryType is "custom".', @@ -292,29 +263,104 @@ export class WebpageConfigService { expiresIn = days * 24; expiryDate = new Date(Date.now() + expiresIn * 60 * 60 * 1000); } - const ssiServiceDetail = await this.appRepository.findOne({ - appId: ssiServiceId, + return { expiryDate }; + } + public async generateWebpageConfigTokens(id, appId) { + const redisKey = generateHash(`${REDIS_KEYS.VERIFIER_PAGE_TOKEN}${id}`); + const cachedData = await redisClient.get(redisKey); + if (cachedData) return JSON.parse(cachedData); + const verifierConfig = await this.webPageConfigRepo.findAWebpageConfig({ + _id: new Types.ObjectId(id), }); - if (!ssiServiceDetail) { + if (!verifierConfig) { throw new BadRequestException([ - `No service found with dependentServiceId: ${ssiServiceId}`, + WEBPAGE_CONFIG_ERRORS.WEBPAGE_CONFIG_NOT_FOUND, ]); } - // Get access tokens - const ssiAccessTokenDetail = await this.appAuthService.getAccessToken( - GRANT_TYPES.access_service_ssi, - ssiServiceDetail, - expiresIn, - ); - const kycAccessTokenDetail = await this.appAuthService.getAccessToken( + const kycServiceDetail = await this.getServiceAndCache( + appId, + SERVICE_TYPES.CAVACH_API, GRANT_TYPES.access_service_kyc, - serviceDetail, - expiresIn, + TokenModule.VERIFIER, ); - return { + if ( + !kycServiceDetail.dependentServices || + kycServiceDetail.dependentServices.length === 0 + ) { + throw new BadRequestException([ + WEBPAGE_CONFIG_ERRORS.WEBPAGE_CONFIG_SSI_SERVICE_NOT_FOUND, + ]); + } + const ssiServiceId = kycServiceDetail?.dependentServices?.[0]; + const ssiServiceDetail = await this.getServiceAndCache( + ssiServiceId, + SERVICE_TYPES.SSI_API, + GRANT_TYPES.access_service_ssi, + TokenModule.ID_SERVICE, + ); + // generate access tokens + const [ssiAccessTokenDetail, kycAccessTokenDetail] = await Promise.all([ + this.appAuthService.getAccessToken( + { + appId: ssiServiceId, + appName: ssiServiceDetail.appName, + grantType: GRANT_TYPES.access_service_ssi, + sessionId: ssiServiceId, + subdomain: ssiServiceDetail.subdomain, + }, + 0.5, + ), + this.appAuthService.getAccessToken( + { + appId, + appName: kycServiceDetail.appName, + grantType: GRANT_TYPES.access_service_kyc, + sessionId: appId, + subdomain: kycServiceDetail.subdomain, + }, + 0.5, + ), + ]); + const redisPayload = { ssiAccessToken: ssiAccessTokenDetail.access_token, kycAccessToken: kycAccessTokenDetail.access_token, - expiryDate, }; + redisClient.set( + redisKey, + JSON.stringify(redisPayload), + 'EX', + TOKEN.VERIFIER_TOKEN.expiry, + ); + return { + ...redisPayload, + }; + } + public async getServiceAndCache( + appId: string, + serviceType: SERVICE_TYPES, + grantType: GRANT_TYPES, + tokenModule, + ) { + const cached = await redisClient.get(generateHash(appId)); + if (cached) return JSON.parse(cached); + const serviceDetail = await this.appRepository.findOne({ appId }); + if (!serviceDetail) { + throw new BadRequestException([ + WEBPAGE_CONFIG_ERRORS.WEBPAGE_CONFIG_LINKED_APP_NOT_FOUND, + ]); + } + const defaultAccessList = getAccessListForModule(tokenModule, serviceType); + const validateAccessList = evaluateAccessPolicy( + defaultAccessList, + serviceType, + [], + ); + await this.appAuthService.storeDataInRedis( + grantType, + serviceDetail, + validateAccessList, + appId, + ); + return serviceDetail; } } diff --git a/src/webpage-config/webpage-config.module.ts b/src/webpage-config/webpage-config.module.ts index 86381dc0..a630a11e 100644 --- a/src/webpage-config/webpage-config.module.ts +++ b/src/webpage-config/webpage-config.module.ts @@ -18,7 +18,6 @@ import { RateLimitMiddleware } from 'src/utils/middleware/rate-limit.middleware' import { TrimMiddleware } from 'src/utils/middleware/trim.middleware'; import { JWTAuthorizeMiddleware } from 'src/utils/middleware/jwt-authorization.middleware'; import { JWTAccessAccountMiddleware } from 'src/utils/middleware/jwt-accessAccount.middlerwere'; -import { TwoFAAuthorizationMiddleware } from 'src/utils/middleware/2FA-jwt-authorization.middleware'; import { UserModule } from 'src/user/user.module'; import { JwtModule } from '@nestjs/jwt'; import { AdminPeopleRepository } from 'src/people/repository/people.repository'; @@ -52,31 +51,42 @@ export class WebpageConfigModule implements NestModule { consumer .apply(JWTAuthorizeMiddleware) - .exclude({ - path: 'api/v1/app/:appId/kyc-webpage-config', - method: RequestMethod.GET, - }) + .exclude( + { + path: 'api/v1/app/verifier/:id', + method: RequestMethod.GET, + }, + { + path: 'api/v1/app/verifier/:id/tokens', + method: RequestMethod.POST, + }, + ) .forRoutes(WebpageConfigController); consumer .apply(JWTAccessAccountMiddleware) - .exclude({ - path: 'api/v1/app/:appId/kyc-webpage-config', - method: RequestMethod.GET, - }) - .forRoutes(WebpageConfigController); - consumer - .apply(TwoFAAuthorizationMiddleware) - .exclude({ - path: 'api/v1/app/:appId/kyc-webpage-config', - method: RequestMethod.GET, - }) + .exclude( + { + path: 'api/v1/app/verifier/:id', + method: RequestMethod.GET, + }, + { + path: 'api/v1/app/verifier/:id/tokens', + method: RequestMethod.POST, + }, + ) .forRoutes(WebpageConfigController); consumer .apply(RateLimitMiddleware) - .exclude({ - path: 'api/v1/app/:appId/kyc-webpage-config', - method: RequestMethod.GET, - }) + .exclude( + { + path: 'api/v1/app/verifier/:id', + method: RequestMethod.GET, + }, + { + path: 'api/v1/app/verifier/:id/tokens', + method: RequestMethod.POST, + }, + ) .forRoutes(WebpageConfigController); } }