Skip to content

Commit

Permalink
feat(authentication, router,forms): global captcha support from router (
Browse files Browse the repository at this point in the history
  • Loading branch information
kkopanidis committed Apr 6, 2023
1 parent ef84def commit 24b4b81
Show file tree
Hide file tree
Showing 13 changed files with 139 additions and 69 deletions.
7 changes: 4 additions & 3 deletions libraries/grpc-sdk/src/classes/ConduitModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { HealthCheckResponse, HealthDefinition } from '../protoUtils/grpc_health
import { EventEmitter } from 'events';

export class ConduitModule<T extends CompatServiceDefinition> {
active: boolean = false;
private _client?: Client<T>;
private _healthClient?: Client<typeof HealthDefinition>;
protected channel?: Channel;
Expand Down Expand Up @@ -51,7 +50,10 @@ export class ConduitModule<T extends CompatServiceDefinition> {
},
});
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<T> | undefined {
Expand All @@ -71,7 +73,6 @@ export class ConduitModule<T extends CompatServiceDefinition> {
// ConduitGrpcSdk.Logger.warn(`Closing connection for ${this._serviceName}`);
this.channel.close();
this.channel = undefined;
this.active = false;
}

check(service: string = '') {
Expand Down
1 change: 0 additions & 1 deletion modules/authentication/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 0 additions & 9 deletions modules/authentication/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ export default {
format: 'Boolean',
default: false,
},
provider: {
format: 'String',
default: 'recaptcha',
enum: ['recaptcha', 'hcaptcha'],
},
routes: {
login: {
format: 'Boolean',
Expand All @@ -71,9 +66,5 @@ export default {
default: true,
},
},
secretKey: {
format: 'String',
default: '',
},
},
};
38 changes: 8 additions & 30 deletions modules/authentication/src/routes/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.');
}

Expand All @@ -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;
}
Expand Down
15 changes: 0 additions & 15 deletions modules/authentication/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions modules/forms/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
9 changes: 8 additions & 1 deletion modules/forms/src/controllers/forms.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import ConduitGrpcSdk, {
ConduitRouteActions,
ConduitRouteReturnDefinition,
ConduitString,
ConfigController,
RoutingManager,
TYPE,
} from '@conduitplatform/grpc-sdk';
Expand Down Expand Up @@ -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),
Expand Down
15 changes: 9 additions & 6 deletions modules/forms/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { status } from '@grpc/grpc-js';
import ConduitGrpcSdk, {
ConfigController,
GrpcError,
GrpcServer,
ParsedRouterRequest,
Expand All @@ -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 {
Expand All @@ -20,6 +20,10 @@ export class FormsRoutes {
}

async submitForm(call: ParsedRouterRequest): Promise<UnparsedRouterResponse> {
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 })
Expand All @@ -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
Expand All @@ -58,7 +61,7 @@ export class FormsRoutes {
possibleSpam = true;
}
}
if (honeyPot && possibleSpam) {
if (possibleSpam) {
await FormReplies.getInstance()
.create({
form: form._id,
Expand Down
2 changes: 2 additions & 0 deletions modules/router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions modules/router/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
83 changes: 83 additions & 0 deletions modules/router/src/security/handlers/captcha-validation/index.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
7 changes: 7 additions & 0 deletions modules/router/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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',
Expand Down Expand Up @@ -57,5 +59,10 @@ export default class SecurityModule {
clientValidator.middleware.bind(clientValidator),
true,
);
this.router.registerGlobalMiddleware(
'captchaMiddleware',
captchaValidator.middleware.bind(captchaValidator),
false,
);
}
}
Loading

0 comments on commit 24b4b81

Please sign in to comment.