Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(authentication, router,forms): global captcha support from router #580

Merged
merged 6 commits into from
Apr 6, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
);
}
}
2 changes: 1 addition & 1 deletion packages/admin/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down