From 24b4b8177c60c6a033291b0e49a94128b116413e Mon Sep 17 00:00:00 2001 From: Konstantinos Kopanidis Date: Thu, 6 Apr 2023 17:49:22 +0300 Subject: [PATCH] feat(authentication, router,forms): global captcha support from router (#580) --- .../grpc-sdk/src/classes/ConduitModule.ts | 7 +- modules/authentication/package.json | 1 - modules/authentication/src/config/config.ts | 9 -- .../authentication/src/routes/middleware.ts | 38 ++------- modules/authentication/src/utils/index.ts | 15 ---- modules/forms/src/config/config.ts | 5 +- .../forms/src/controllers/forms.controller.ts | 9 +- modules/forms/src/routes/index.ts | 15 ++-- modules/router/package.json | 2 + modules/router/src/config/config.ts | 15 ++++ .../handlers/captcha-validation/index.ts | 83 +++++++++++++++++++ modules/router/src/security/index.ts | 7 ++ packages/admin/src/config/config.ts | 2 +- 13 files changed, 139 insertions(+), 69 deletions(-) create mode 100644 modules/router/src/security/handlers/captcha-validation/index.ts diff --git a/libraries/grpc-sdk/src/classes/ConduitModule.ts b/libraries/grpc-sdk/src/classes/ConduitModule.ts index a64daa007..e32c6eb50 100644 --- a/libraries/grpc-sdk/src/classes/ConduitModule.ts +++ b/libraries/grpc-sdk/src/classes/ConduitModule.ts @@ -6,7 +6,6 @@ import { HealthCheckResponse, HealthDefinition } from '../protoUtils/grpc_health import { EventEmitter } from 'events'; export class ConduitModule { - active: boolean = false; private _client?: Client; private _healthClient?: Client; protected channel?: Channel; @@ -51,7 +50,10 @@ export class ConduitModule { }, }); this._healthClient = clientFactory.create(HealthDefinition, this.channel); - this.active = true; + } + + get active(): boolean { + return this.channel ? this.channel!.getConnectivityState(true) === 2 : false; } get client(): Client | undefined { @@ -71,7 +73,6 @@ export class ConduitModule { // ConduitGrpcSdk.Logger.warn(`Closing connection for ${this._serviceName}`); this.channel.close(); this.channel = undefined; - this.active = false; } check(service: string = '') { diff --git a/modules/authentication/package.json b/modules/authentication/package.json index 63feaee79..051645224 100644 --- a/modules/authentication/package.json +++ b/modules/authentication/package.json @@ -35,7 +35,6 @@ "convict": "^6.2.3", "crypto": "^1.0.1", "escape-string-regexp": "^4.0.0", - "hcaptcha": "^0.1.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.1.4", "lodash": "^4.17.21", diff --git a/modules/authentication/src/config/config.ts b/modules/authentication/src/config/config.ts index 7b767cccc..c0dcc29bc 100644 --- a/modules/authentication/src/config/config.ts +++ b/modules/authentication/src/config/config.ts @@ -42,11 +42,6 @@ export default { format: 'Boolean', default: false, }, - provider: { - format: 'String', - default: 'recaptcha', - enum: ['recaptcha', 'hcaptcha'], - }, routes: { login: { format: 'Boolean', @@ -71,9 +66,5 @@ export default { default: true, }, }, - secretKey: { - format: 'String', - default: '', - }, }, }; diff --git a/modules/authentication/src/routes/middleware.ts b/modules/authentication/src/routes/middleware.ts index d916a0b54..48eb86870 100644 --- a/modules/authentication/src/routes/middleware.ts +++ b/modules/authentication/src/routes/middleware.ts @@ -13,7 +13,6 @@ import { isNil } from 'lodash'; import { status } from '@grpc/grpc-js'; import { JwtPayload } from 'jsonwebtoken'; import moment from 'moment/moment'; -import { verify as hcaptchaVerify } from 'hcaptcha'; /* * Expects access token in 'Authorization' header or 'accessToken' cookie @@ -113,26 +112,13 @@ function getToken(headers: Headers, cookies: Cookies, reqType: 'access' | 'refre return headerArgs[1] || tokenCookie; } -async function verifyCaptcha(secretKey: string, provider: string, token: string) { - let success = false; - if (provider === 'recaptcha') { - success = await AuthUtils.recaptchaVerify(secretKey, token); - } else { - const response = await hcaptchaVerify(secretKey, token); - success = response.success; - } - return success; -} - export async function captchaMiddleware(call: ParsedRouterRequest) { - const { acceptablePlatform, secretKey, enabled, provider } = - ConfigController.getInstance().config.captcha; - const { clientId } = call.request.context; + const { acceptablePlatform, enabled } = ConfigController.getInstance().config.captcha; + const { clientId, captcha } = call.request.context; let clientPlatform; - const { captchaToken } = call.request.params; - if (!enabled) { + if (!enabled || captcha === 'disabled') { throw new GrpcError(status.INTERNAL, 'Captcha is disabled.'); } @@ -146,24 +132,16 @@ export async function captchaMiddleware(call: ParsedRouterRequest) { if (!acceptablePlatform[clientPlatform.toLowerCase()]) { break; } - if (captchaToken == null) { + if (captcha === 'missing') { throw new GrpcError(status.INTERNAL, `Captcha token is missing.`); } - if (!secretKey) { - throw new GrpcError(status.INTERNAL, 'Secret key for recaptcha is required.'); - } - if (!(await verifyCaptcha(secretKey, provider, captchaToken))) { - throw new GrpcError(status.INTERNAL, 'Can not verify captcha.'); + if (captcha === 'failed') { + throw new GrpcError(status.PERMISSION_DENIED, 'Can not verify captcha.'); } break; case 'anonymous-client': - if (captchaToken != null) { - if (!secretKey) { - throw new GrpcError(status.INTERNAL, 'Secret key for recaptcha is required.'); - } - if (!(await verifyCaptcha(secretKey, provider, captchaToken))) { - throw new GrpcError(status.INTERNAL, 'Can not verify captcha.'); - } + if (captcha === 'failed') { + throw new GrpcError(status.PERMISSION_DENIED, 'Can not verify captcha.'); } break; } diff --git a/modules/authentication/src/utils/index.ts b/modules/authentication/src/utils/index.ts index f0c3cb3da..43cca00e0 100644 --- a/modules/authentication/src/utils/index.ts +++ b/modules/authentication/src/utils/index.ts @@ -6,7 +6,6 @@ import { Team, Token, User } from '../models'; import { isNil } from 'lodash'; import { status } from '@grpc/grpc-js'; import { v4 as uuid } from 'uuid'; -import axios from 'axios'; import escapeStringRegexp from 'escape-string-regexp'; import { FetchMembersParams } from '../interfaces'; @@ -129,20 +128,6 @@ export namespace AuthUtils { } } - export async function recaptchaVerify(secret: string, token: string) { - const googleUrl = `https://www.google.com/siteverify?secret=${secret}&response=${token}`; - const response = await axios.post( - googleUrl, - {}, - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', - }, - }, - ); - return response.data.success; - } - export async function fetchMembers(params: FetchMembersParams) { const { relations, search, sort, populate } = params; const skip = params.skip ?? 0; diff --git a/modules/forms/src/config/config.ts b/modules/forms/src/config/config.ts index b2552dcc6..5d6be4d3c 100644 --- a/modules/forms/src/config/config.ts +++ b/modules/forms/src/config/config.ts @@ -3,9 +3,8 @@ export default { format: 'Boolean', default: true, }, - // If set to false the forms module will utilize the storage module for sending blobs - useAttachments: { + captcha: { format: 'Boolean', - default: true, + default: false, }, }; diff --git a/modules/forms/src/controllers/forms.controller.ts b/modules/forms/src/controllers/forms.controller.ts index 47e168fa9..ab434d49c 100644 --- a/modules/forms/src/controllers/forms.controller.ts +++ b/modules/forms/src/controllers/forms.controller.ts @@ -1,6 +1,8 @@ import ConduitGrpcSdk, { ConduitRouteActions, ConduitRouteReturnDefinition, + ConduitString, + ConfigController, RoutingManager, TYPE, } from '@conduitplatform/grpc-sdk'; @@ -59,7 +61,12 @@ export class FormsController { path: `/${r._id}`, action: ConduitRouteActions.POST, description: `Submits form with id ${r._id}.`, - bodyParams: r.fields, + bodyParams: { + ...r.fields, + ...(ConfigController.getInstance().config.captcha + ? { captchaToken: ConduitString.Required } + : {}), + }, }, new ConduitRouteReturnDefinition(`SubmitForm${r.name}`, 'String'), this.router.submitForm.bind(this.router), diff --git a/modules/forms/src/routes/index.ts b/modules/forms/src/routes/index.ts index 718e9eff4..f5aa4ee3d 100644 --- a/modules/forms/src/routes/index.ts +++ b/modules/forms/src/routes/index.ts @@ -1,5 +1,6 @@ import { status } from '@grpc/grpc-js'; import ConduitGrpcSdk, { + ConfigController, GrpcError, GrpcServer, ParsedRouterRequest, @@ -8,7 +9,6 @@ import ConduitGrpcSdk, { UntypedArray, } from '@conduitplatform/grpc-sdk'; import { FormReplies, Forms } from '../models'; -import { isNil } from 'lodash'; import axios from 'axios'; export class FormsRoutes { @@ -20,6 +20,10 @@ export class FormsRoutes { } async submitForm(call: ParsedRouterRequest): Promise { + const captchaRequired = ConfigController.getInstance().config.captcha; + if (captchaRequired && call.request.context.captcha !== 'success') { + throw new GrpcError(status.PERMISSION_DENIED, 'Invalid or missing captcha'); + } const formId = call.request.path.split('/')[2]; const form = await Forms.getInstance() .findOne({ _id: formId }) @@ -31,17 +35,16 @@ export class FormsRoutes { } const data = call.request.params; + if (data.captchaToken) { + delete data.captchaToken; + } const fileData: any = {}; - let honeyPot: boolean = false; let possibleSpam: boolean = false; Object.keys(data).forEach(r => { if (form.fields[r] === 'File') { fileData[r] = data[r]; delete data[r]; } - if (isNil(form.fields[r])) { - honeyPot = true; - } }); if (form.emailField && data[form.emailField]) { const response = await axios @@ -58,7 +61,7 @@ export class FormsRoutes { possibleSpam = true; } } - if (honeyPot && possibleSpam) { + if (possibleSpam) { await FormReplies.getInstance() .create({ form: form._id, diff --git a/modules/router/package.json b/modules/router/package.json index 592939a22..464506938 100644 --- a/modules/router/package.json +++ b/modules/router/package.json @@ -22,6 +22,7 @@ "@grpc/grpc-js": "^1.6.7", "@grpc/proto-loader": "^0.6.13", "bcrypt": "^5.0.1", + "hcaptcha": "^0.1.1", "deep-object-diff": "^1.1.9", "deepdash": "^5.3.9", "cors": "^2.8.5", @@ -30,6 +31,7 @@ "helmet": "^5.1.0", "ioredis": "^5.1.0", "lodash": "^4.17.21", + "axios": "^1.2.1", "moment": "^2.29.4", "rate-limiter-flexible": "^2.3.7", "swagger-ui-express": "4.4.0" diff --git a/modules/router/src/config/config.ts b/modules/router/src/config/config.ts index ec969b762..15fcc2c94 100644 --- a/modules/router/src/config/config.ts +++ b/modules/router/src/config/config.ts @@ -4,6 +4,21 @@ export default { format: 'String', default: '', }, + captcha: { + enabled: { + format: 'Boolean', + default: false, + }, + provider: { + format: 'String', + default: 'recaptcha', + enum: ['recaptcha', 'hcaptcha', 'turnstile'], + }, + secretKey: { + format: 'String', + default: '', + }, + }, cors: { enabled: { format: 'Boolean', diff --git a/modules/router/src/security/handlers/captcha-validation/index.ts b/modules/router/src/security/handlers/captcha-validation/index.ts new file mode 100644 index 000000000..f9b13cb53 --- /dev/null +++ b/modules/router/src/security/handlers/captcha-validation/index.ts @@ -0,0 +1,83 @@ +import { NextFunction, Response } from 'express'; +import ConduitGrpcSdk, { + ConfigController, + DatabaseProvider, +} from '@conduitplatform/grpc-sdk'; +import { ConduitRequest } from '@conduitplatform/hermes'; +import { verify as hcaptchaVerify } from 'hcaptcha'; +import axios from 'axios'; + +export class CaptchaValidator { + prod = false; + database: DatabaseProvider; + + constructor(private readonly grpcSdk: ConduitGrpcSdk) { + this.database = this.grpcSdk.database!; + const self = this; + this.grpcSdk.config.get('core').then(res => { + if (res.env === 'production') { + self.prod = true; + } + }); + } + + async recaptchaVerify(secret: string, token: string) { + const googleUrl = `https://www.google.com/siteverify?secret=${secret}&response=${token}`; + const response = await axios.post( + googleUrl, + {}, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + }, + }, + ); + return response.data.success; + } + + async turnstileVerify(secret: string, token: string) { + const response = await axios.post( + `https://challenges.cloudflare.com/turnstile/v0/siteverify`, + { + secret, + response: token, + }, + ); + return response.data.success; + } + + async verifyCaptcha(secretKey: string, provider: string, token: string) { + let success = false; + if (provider === 'recaptcha') { + success = await this.recaptchaVerify(secretKey, token); + } else if (provider === 'hcaptcha') { + const response = await hcaptchaVerify(secretKey, token); + success = response.success; + } else { + success = await this.turnstileVerify(secretKey, token); + } + return success; + } + + async middleware(req: ConduitRequest, res: Response, next: NextFunction) { + const { enabled, provider, secretKey } = + ConfigController.getInstance().config.captcha; + const { captchaToken } = req.body; + if (!enabled) { + req.conduit!.captcha = 'disabled'; + return next(); + } + if (!captchaToken) { + req.conduit!.captcha = 'missing'; + return next(); + } + const success = await this.verifyCaptcha(secretKey, provider, captchaToken); + if (!success) { + req.conduit!.captcha = 'failed'; + return next(); + } else { + req.conduit!.captcha = 'success'; + return next(); + } + } +} diff --git a/modules/router/src/security/index.ts b/modules/router/src/security/index.ts index 581fca046..b342e094b 100644 --- a/modules/router/src/security/index.ts +++ b/modules/router/src/security/index.ts @@ -5,6 +5,7 @@ import { ClientValidator } from './handlers/client-validation'; import { NextFunction, Request, Response } from 'express'; import ConduitDefaultRouter from '../Router'; import cors from 'cors'; +import { CaptchaValidator } from './handlers/captcha-validation'; export default class SecurityModule { constructor( @@ -14,6 +15,7 @@ export default class SecurityModule { setupMiddlewares() { const clientValidator: ClientValidator = new ClientValidator(this.grpcSdk); + const captchaValidator: CaptchaValidator = new CaptchaValidator(this.grpcSdk); this.router.registerGlobalMiddleware( 'rateLimiter', @@ -57,5 +59,10 @@ export default class SecurityModule { clientValidator.middleware.bind(clientValidator), true, ); + this.router.registerGlobalMiddleware( + 'captchaMiddleware', + captchaValidator.middleware.bind(captchaValidator), + false, + ); } } diff --git a/packages/admin/src/config/config.ts b/packages/admin/src/config/config.ts index e619b59ec..e70973624 100644 --- a/packages/admin/src/config/config.ts +++ b/packages/admin/src/config/config.ts @@ -28,7 +28,7 @@ export default { }, allowedHeaders: { format: 'String', - default: 'Content-Type,Authorization,Cache-Control', + default: 'Content-Type,Authorization,Cache-Control,masterkey', }, exposedHeaders: { format: 'String',