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

Add HCaptcha and ReCaptcha V3 and refacto ReCaptcha V2 #421

Open
wants to merge 1 commit into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
67 changes: 4 additions & 63 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"express-brute": "^0.6.0",
"express-github-webhook": "^1.0.5",
"express-jsdoc-swagger": "^1.2.0",
"express-recaptcha": "^2.1.0",
"gitlab": "^3.5.1",
"js-yaml": "^3.10.0",
"jsonwebtoken": "^8.4.0",
Expand Down
52 changes: 11 additions & 41 deletions source/controllers/process.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,18 @@
import reCaptcha from 'express-recaptcha';
import universalAnalytics from 'universal-analytics';

import config from '../config';
import errorHandler, { getInstance } from '../lib/ErrorHandler';
import CaptchaFactory from '../lib/CaptchaFactory';
import { getInstance } from '../lib/ErrorHandler';
import Staticman from '../lib/Staticman';

export function checkRecaptcha(staticman, req) {
return new Promise((resolve, reject) => {
staticman
.getSiteConfig()
.then((siteConfig) => {
if (!siteConfig.get('reCaptcha.enabled')) {
return resolve(false);
}

const reCaptchaOptions = req?.body?.options?.reCaptcha;

if (!reCaptchaOptions || !reCaptchaOptions.siteKey || !reCaptchaOptions.secret) {
return reject(errorHandler('RECAPTCHA_MISSING_CREDENTIALS'));
}

let decryptedSecret;

try {
decryptedSecret = staticman.decrypt(reCaptchaOptions.secret);
} catch (err) {
return reject(errorHandler('RECAPTCHA_CONFIG_MISMATCH'));
}

if (
reCaptchaOptions.siteKey !== siteConfig.get('reCaptcha.siteKey') ||
decryptedSecret !== siteConfig.get('reCaptcha.secret')
) {
return reject(errorHandler('RECAPTCHA_CONFIG_MISMATCH'));
}

reCaptcha.init(reCaptchaOptions.siteKey, decryptedSecret);
reCaptcha.verify(req, () =>
req?.recaptcha?.error ? reject(errorHandler(req.reCaptcha.error)) : resolve(true)
);
return resolve(true);
})
.catch((err) => reject(err));
});
export const checkRecaptcha = async (staticman, req) => {
const siteConfig = await staticman.getSiteConfig();
// console.log(siteConfig)
if (!siteConfig.get('captcha.enabled')) {
return false;
}
const captcha = CaptchaFactory(siteConfig.get('captcha.service'), siteConfig)
const result = await captcha.verify(req?.body[captcha.getKeyForToken()])
return result
}

export function createConfigObject(apiVersion, property) {
Expand Down
19 changes: 19 additions & 0 deletions source/lib/CaptchaFactory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import errorHandler from './ErrorHandler';
import HCaptcha from './HCaptcha';
import ReCaptcha from './ReCaptcha';

export default (service, options) => {
let captcha
switch (service) {
case 'HCaptcha':
captcha = new HCaptcha(options);
break;
case 'ReCaptcha':
captcha = new ReCaptcha(options);
break;
default:
throw errorHandler('CAPTCHA_SERVICE_MISSING')
}
captcha.verifyConfig();
return captcha
};
21 changes: 21 additions & 0 deletions source/lib/CaptchaService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export default class CaptchaService {
constructor(secretKey, domainURL) {
this.secretKey = secretKey;
this.domainURL = domainURL;
}

// eslint-disable-next-line class-methods-use-this, no-unused-vars
async verify() {
throw new Error('Abstract method `verify` should be implemented');
}

// eslint-disable-next-line class-methods-use-this, no-unused-vars
async getKeyForToken() {
throw new Error('Abstract method `getKeyForToken` should be implemented');
}

// eslint-disable-next-line class-methods-use-this, no-unused-vars
verifyConfig() {
throw new Error('Abstract method `verifyConfig` should be implemented');
}
}
4 changes: 4 additions & 0 deletions source/lib/ErrorHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ class ErrorHandler {
'missing-input-response': 'reCAPTCHA: The response parameter is missing',
'invalid-input-response': 'reCAPTCHA: The response parameter is invalid or malformed',
RECAPTCHA_MISSING_CREDENTIALS: 'Missing reCAPTCHA API credentials',
RECAPTCHA_V3_SCORE_HIGH: 'ReCaptcha Score to hight',
RECAPTCHA_FAILED_DECRYPT: 'Could not decrypt reCAPTCHA secret',
RECAPTCHA_CONFIG_MISMATCH: 'reCAPTCHA options do not match Staticman config',
RECAPTCHA_TOKEN_MISSING: 'reCAPTCHA token from form no\'t found in body[g-recaptcha-response] or body[h-captcha-response]',
CAPTCHA_SERVICE_MISSING: 'captcha enabled in the config but no service specified you can use : HCaptcha or ReCaptcha',
HCAPTCHA_CONFIG_MISSING: 'HCAPTCHA enabled in the config but no secret set in config file',
PARSING_ERROR: 'Error whilst parsing config file',
GITHUB_AUTH_TOKEN_MISSING:
'The site requires a valid GitHub authentication token to be supplied in the `options[github-token]` field',
Expand Down
47 changes: 47 additions & 0 deletions source/lib/HCaptcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import requestPromise from 'request-promise'

import CaptchaService from './CaptchaService'
import errorHandler from './ErrorHandler';

export default class HCaptcha extends CaptchaService {

constructor(siteConfig) {
const secretKey = siteConfig.get('captcha.HCaptcha.secret');
const domainURL = siteConfig.get('captcha.HCaptcha.domainURL');
super(secretKey, domainURL);
}

verifyConfig() {
if (this.secretKey === undefined || this.secretKey === "") {
throw errorHandler("HCAPTCHA_CONFIG_MISSING")
}
}

// eslint-disable-next-line class-methods-use-this
getKeyForToken() {
return 'h-captcha-response'
}

async verify(token) {
if (!token) {
throw errorHandler('RECAPTCHA_TOKEN_MISSING')
}
try {
const res = await requestPromise({
method: 'post',
url: `${this.domainURL}/siteverify`,
form: {
secret: this.secretKey,
response: token,
},
json: true,
})
if (res?.success !== true) {
throw errorHandler(res["error-codes"][0]);
}
return true
} catch (err) {
throw errorHandler(err["error-codes"], { err });
}
}
}
64 changes: 64 additions & 0 deletions source/lib/ReCaptcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import request from 'request-promise'
// request-promise is deprecated maybe use node-fetch
import CaptchaService from './CaptchaService'
import errorHandler from './ErrorHandler';

export default class ReCaptcha extends CaptchaService {

constructor(siteConfig) {
const domainURL = siteConfig.get('captcha.ReCaptcha.domainURL');
const secretKey = siteConfig.get('captcha.ReCaptcha.secret');
super(secretKey, domainURL);
this.version = siteConfig.get('captcha.ReCaptcha.version');
this.score = siteConfig.get('captcha.ReCaptcha.score');
}

// eslint-disable-next-line class-methods-use-this
getKeyForToken() {
return 'g-recaptcha-response'
}

verifyConfig() {
if (this.secretKey === undefined || this.secretKey === "") {
throw errorHandler("RECAPTCHA_CONFIG_MISSING")
}
}

// {
// "success": true|false, // whether this request was a valid reCAPTCHA token for your site
// "score": number // the score for this request (0.0 - 1.0)
// "action": string // the action name for this request (important to verify)
// "challenge_ts": timestamp, // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
// "hostname": string, // the hostname of the site where the reCAPTCHA was solved
// "error-codes": [...] // optional
// }

async verify(token) {
if (!token) {
throw errorHandler('RECAPTCHA_TOKEN_MISSING')
}

try {
const res = await request({
json: true,
method: 'POST',
uri: `${this.domainURL}/recaptcha/api/siteverify`,
form: {
secret:this.secretKey,
response:token,
},
})
if (res?.success !== true) {
throw errorHandler(res["error-codes"][0]);
}
if (this.version === "V3") {
if (typeof res?.score === 'number' && res?.score > this.score) {
throw errorHandler('RECAPTCHA_V3_SCORE_HIGH')
}
}
return true
} catch (err) {
throw errorHandler(err["error-codes"], { err });
}
}
}
56 changes: 44 additions & 12 deletions source/siteConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,23 +185,55 @@ export const schema = {
format: Object,
default: {},
},
reCaptcha: {
captcha: {
service: {
doc: 'Select captcha service ReCaptcha or HCaptcha',
docExample: 'allowedFields: ["ReCaptcha", "HCaptcha"]',
format: String,
default: '',
},
enabled: {
doc:
'Set to `true` to force reCAPTCHA validation, set to `false` to accept comments without reCAPTCHA.',
'Set to `true` to force captcha validation, set to `false` to accept comments without reCAPTCHA.',
format: Boolean,
default: false,
},
siteKey: {
doc: 'Site Key for your reCAPTCHA site registration',
format: String,
default: '',
},
secret: {
doc: 'Encrypted Secret for your reCAPTCHA site registration',
format: 'EncryptedString',
default: '',
},
ReCaptcha: {
domainURL: {
doc: 'domain URL for your reCAPTCHA',
format: String,
default: 'https://www.google.com',
},
secret: {
doc: 'Encrypted Secret for your reCAPTCHA site registration',
format: 'EncryptedString',
default: '',
},
version: {
doc: 'Version for your reCAPTCHA site registration V2 or V3',
docExample: 'allowedFields: ["V2", "V3"]',
format: String,
default: 'V2',
},
score: {
doc: 'Score maximum for your reCAPTCHA V3 (0.0 - 1.0)',
format: Number,
default: 0.5,
},
},
HCaptcha: {
domainURL: {
doc: 'domain URL for your HCaptcha',
format: String,
default: 'https://hcaptcha.com',
},
secret: {
doc: 'Encrypted Secret for your HCaptcha site registration',
format: 'EncryptedString',
default: '',
}
}

},
};

Expand Down
Loading