diff --git a/package.json b/package.json index 86ac759..63c02c0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@fusionauth/typescript-client": "^1.43.0", "@golevelup/ts-jest": "^0.3.5", + "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/axios": "^0.0.7", "@nestjs/common": "^8.*", "@nestjs/config": "^1.0.1", @@ -47,6 +48,9 @@ "flagsmith-nodejs": "^2.5.2", "got": "^11.8.2", "helmet": "^7.0.0", + "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.1.0", "passport": "^0.5.2", "passport-http": "^0.3.0", "passport-jwt": "^4.0.1", diff --git a/src/api/api.controller.ts b/src/api/api.controller.ts index 11d2fba..debb73f 100644 --- a/src/api/api.controller.ts +++ b/src/api/api.controller.ts @@ -35,6 +35,7 @@ import { VerifyOtpDto } from './dto/verify-otp.dto'; import { Throttle, SkipThrottle} from '@nestjs/throttler'; import { ConfigService } from '@nestjs/config'; import { v4 as uuidv4 } from 'uuid'; +import { VerifyJWTDto } from './dto/verify-jwt.dto'; // eslint-disable-next-line @typescript-eslint/no-var-requires const CryptoJS = require('crypto-js'); @@ -381,4 +382,20 @@ export class ApiController { } return await this.apiService.loginWithUniqueId(user, authHeader); } + + @Post('jwt/verify') + @UsePipes(new ValidationPipe({transform: true})) + async jwtVerify( + @Body() body: VerifyJWTDto + ): Promise { + return await this.apiService.verifyJWT(body.token); + } + + @Post('logout') + @UsePipes(new ValidationPipe({transform: true})) + async logout( + @Body() body: VerifyJWTDto + ): Promise { + return await this.apiService.logout(body.token); + } } diff --git a/src/api/api.service.ts b/src/api/api.service.ts index 7ae5ba0..76415b9 100644 --- a/src/api/api.service.ts +++ b/src/api/api.service.ts @@ -31,6 +31,10 @@ const CryptoJS = require('crypto-js'); const AES = require('crypto-js/aes'); import Flagsmith from 'flagsmith-nodejs'; import { LoginWithUniqueIdDto } from './dto/login.dto'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +const jwksClient = require('jwks-rsa'); +import * as jwt from 'jsonwebtoken'; CryptoJS.lib.WordArray.words; @@ -43,6 +47,7 @@ export class ApiService { private readonly fusionAuthService: FusionauthService, private readonly otpService: OtpService, private readonly configResolverService: ConfigResolverService, + @InjectRedis() private readonly redis: Redis ) {} login(user: any, authHeader: string): Promise { @@ -535,6 +540,7 @@ export class ApiService { 3.2. If new user, register to this application. 4. Send login response with the token */ + let otp = loginDto.password; const salt = this.configResolverService.getSalt(loginDto.applicationId); let verifyOTPResult; if( @@ -562,6 +568,7 @@ export class ApiService { loginDto.password = salt + loginDto.password; // mix OTP with salt if (verifyOTPResult.status === SMSResponseStatus.success) { + let response; const { statusFA, userId, @@ -595,11 +602,17 @@ export class ApiService { id: registrationId, }, ], + data: { + loginId: loginDto.loginId, + fingerprint: loginDto?.fingerprint, + timestamp: loginDto?.timestamp, + otp + } }, loginDto.applicationId, authHeader, ); - return this.login(loginDto, authHeader); + response = await this.login(loginDto, authHeader); } else { // create a new user const createUserPayload: UserRegistration = { @@ -607,7 +620,13 @@ export class ApiService { timezone: "Asia/Kolkata", username: loginDto.loginId, mobilePhone: loginDto.loginId, - password: loginDto.password + password: loginDto.password, + data: { + loginId: loginDto.loginId, + fingerprint: loginDto?.fingerprint, + timestamp: loginDto?.timestamp, + otp + } }, registration: { applicationId: loginDto.applicationId, @@ -626,8 +645,17 @@ export class ApiService { if (userId == null || user == null) { throw new HttpException(err, HttpStatus.BAD_REQUEST); } - return this.login(loginDto, authHeader); + response = await this.login(loginDto, authHeader); + } + let existingJWTS:any = await this.redis.get(response?.result?.data?.user?.user?.id); + if(existingJWTS) { + existingJWTS = JSON.parse(existingJWTS); + } else { + existingJWTS = [] } + existingJWTS.push(response?.result?.data?.user?.token); + await this.redis.set(response?.result?.data?.user?.user?.id, JSON.stringify(existingJWTS)); + return response; } else { const response: SignupResponse = new SignupResponse().init(uuidv4()); response.responseCode = ResponseCode.FAILURE; @@ -706,4 +734,89 @@ export class ApiService { } return registration; } + + async verifyFusionAuthJWT(token: string): Promise { + let client = jwksClient({ + jwksUri: this.configService.get("JWKS_URI"), + requestHeaders: {}, // Optional + timeout: 30000, // Defaults to 30s + }); + + let getKey = (header: jwt.JwtHeader, callback: any) => { + client.getSigningKey(header.kid, (err, key: any) => { + if (err) { + console.error(`Error fetching signing key: ${err}`); + callback(err); + } else { + const signingKey = key.publicKey || key.rsaPublicKey; + callback(null, signingKey); + } + }); + }; + + return new Promise((resolve, reject) => { + jwt.verify(token, getKey, async (err, decoded) => { + if (err) { + console.error('APP JWT verification error:', err); + resolve({ + isValidFusionAuthToken: false, + claims: null + }) + } else { + resolve({ + isValidFusionAuthToken: true, + claims: decoded + }) + } + }); + }); + } + + async verifyJWT(token:string): Promise { + const { isValidFusionAuthToken, claims} = await this.verifyFusionAuthJWT(token); + + let existingUserJWTS:any = JSON.parse(await this.redis.get(claims.sub)); + + if(!isValidFusionAuthToken){ + if(existingUserJWTS.indexOf(token)!=-1){ + existingUserJWTS.splice(existingUserJWTS.indexOf(token), 1); + await this.redis.set(claims.sub, JSON.stringify(existingUserJWTS)); + } + return { + "isValid": false, + "message": "Invalid/Expired token." + } + } + + if(existingUserJWTS.indexOf(token)==-1){ + return { + "isValid": false, + "message": "Token is not authorized." + } + } + + return { + "isValid": true, + "message": "Token is valid." + } + } + + async logout(token:string): Promise { + const { isValidFusionAuthToken, claims} = await this.verifyFusionAuthJWT(token); + if(isValidFusionAuthToken){ + let existingUserJWTS:any = JSON.parse(await this.redis.get(claims.sub)); + if(existingUserJWTS.indexOf(token)!=-1){ + existingUserJWTS.splice(existingUserJWTS.indexOf(token), 1); + await this.redis.set(claims.sub, JSON.stringify(existingUserJWTS)); + } + return { + "message": "Logout successful. Token invalidated." + } + } else { + return { + "message": "Invalid or expired token." + } + } + } + } diff --git a/src/api/dto/verify-jwt.dto.ts b/src/api/dto/verify-jwt.dto.ts new file mode 100644 index 0000000..b2caf6b --- /dev/null +++ b/src/api/dto/verify-jwt.dto.ts @@ -0,0 +1,10 @@ +import { + IsNotEmpty, IsString, + } from 'class-validator'; + + export class VerifyJWTDto { + @IsString() + @IsNotEmpty() + token: string; + } + \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index c4924c2..d18a2f1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { AuthModule } from './auth/auth.module'; import { ApiModule } from './api/api.module'; import got from 'got/dist/source'; import { TerminusModule } from '@nestjs/terminus'; +import { RedisModule } from '@nestjs-modules/ioredis'; const gupshupFactory = { provide: 'GupshupService', @@ -47,6 +48,10 @@ const otpServiceFactory = { ttl: parseInt(process.env.RATE_LIMIT_TTL), //Seconds limit: parseInt(process.env.RATE_LIMIT), //Number of requests per TTL from a single IP }), + RedisModule.forRoot({ + type: 'single', + url: process.env.REDIS_URL, + }), AdminModule, DstModule, AuthModule, diff --git a/src/user/dto/login.dto.ts b/src/user/dto/login.dto.ts index 898e8f3..503d67c 100644 --- a/src/user/dto/login.dto.ts +++ b/src/user/dto/login.dto.ts @@ -5,4 +5,6 @@ export class LoginDto { password: string; applicationId: UUID; roles?: Array; + fingerprint?: string; + timestamp?: string; }