@celastrina/captcha
Version:
Captcha Functions for Celastrina HTTP Add-On.
464 lines (462 loc) • 20.7 kB
JavaScript
/*
* Copyright (c) 2021, KRI, LLC.
*
* MIT License
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const {AddOn, Authenticator, AttributeParser, ConfigLoader, Configuration, instanceOfCelastrinaType,
CelastrinaValidationError, CelastrinaError, getDefaultTimeout, LOG_LEVEL} = require("@celastrina/core");
const {HTTPAddOn, HTTPParameter, HeaderParameter} = require("@celastrina/http");
const axios = require("axios");
"use strict";
/**
* @typedef _reCAPTCHARequest
* @property {string} secret
* @property {string} response
*/
/**
* @typedef _reCAPTCHAResponseV2
* @property {boolean} success
* @property {string} challenge_ts
* @property {string} hostname
* @property {Array<string>} error-codes
*/
/**
* @typedef _reCAPTCHAResponseV3
* @extends {_reCAPTCHAResponseV2}
* @property {number} score
* @property {string} action
*/
/**
* CaptchaAction
* @author Robert R Murrell
* @abstract
*/
class CaptchaAction {
/**@return{Object}*/static get $object() {return {schema: "https://celastrinajs/schema/v1.0.0/captcha/CaptchaAction#",
type: "celastrinajs.captcha.CaptchaAction"};}
/**
* @param {number} [timeout=getDefaultTimeout()]
*/
constructor(timeout = getDefaultTimeout()) {
/**@type{number}*/this._timeout = timeout;
}
/**@return{number}*/get timeout() {return this._timeout;}
/**@param{number}timeout*/set timeout(timeout) {this._timeout = getDefaultTimeout(timeout);}
/**
* @param {Assertion} assertion
* @return {Promise<boolean>}
* @abstract
*/
async isHuman(assertion) {throw CelastrinaError.newError("Not Implemented.", 501);}
}
/**
* GoogleReCaptchaAction
* @author Robert R Murrell
*/
class GoogleReCaptchaActionV2 extends CaptchaAction {
/**@return{Object}*/static get $object() {return {schema: "https://celastrinajs/schema/v1.0.0/captcha/GoogleReCaptchaActionV2#",
type: "celastrinajs.captcha.GoogleReCaptchaActionV2"};}
static AXIOS_TIMEOUT_CODE = "ECONNABORTED";
/**
* @param {string} secret
* @param {HTTPParameter} [parameter=new HeaderParameter()]
* @param {string} [name="x-celastrinajs-captcha-token"]
* @param {number} [timeout=getDefaultTimeout()]
* @param {string} [url="https://www.google.com/recaptcha/api/siteverify"]
* @param {boolean} [assumeHumanOnTimeout=true]
*/
constructor(secret, parameter = new HeaderParameter(), name = "x-celastrinajs-captcha-token",
timeout = getDefaultTimeout(), url = "https://www.google.com/recaptcha/api/siteverify",
assumeHumanOnTimeout = true) {
super(timeout);
if(typeof secret !== "string" || secret.trim().length === 0)
throw CelastrinaValidationError.newValidationError("Argument 'secret' is required.", "secret");
if(!instanceOfCelastrinaType(HTTPParameter, parameter))
throw CelastrinaValidationError.newValidationError("Argument 'parameter' is required and must be of type HTTPParameter.", "parameter");
if(typeof name !== "string" || name.trim().length === 0)
throw CelastrinaValidationError.newValidationError("Argument 'name' is required.", "name");
if(typeof url !== "string" || url.trim().length === 0)
throw CelastrinaValidationError.newValidationError("Argument 'url' is required.", "url");
/**@type{string}*/this._url = url;
/**@type{string}*/this._secret = secret;
/**@type{HTTPParameter}*/this._parameter = parameter;
/**@type{string}*/this._name = name;
/**@type{boolean}*/this._assumeHumanOnTimeout = assumeHumanOnTimeout;
}
/**@return{string}*/get url() {return this._url;}
/**@param{string}url*/set url(url) {
if(typeof url !== "string" || url.trim().length === 0) url = "https://www.google.com/recaptcha/api/siteverify";
this._url = url;
}
/**@type{HTTPParameter}*/get parameter() {return this._parameter;}
/**@param{HTTPParameter}parameter*/set parameter(parameter) {
if(!instanceOfCelastrinaType(HTTPParameter, parameter))
this._parameter = new HeaderParameter();
else this._parameter = parameter;
}
/**@returns{boolean}*/get assumeHumanOnTimeout() {return this._assumeHumanOnTimeout;}
/**@param{boolean}assume*/set assumeHumanOnTimeout(assume) {this._assumeHumanOnTimeout = assume;}
/**@return{string}*/get secret() {return this._secret;}
/**@param{string}secret*/set secret(secret) {
if(typeof secret !== "string" || secret.trim().length === 0)
throw CelastrinaValidationError.newValidationError("Argument 'secret' is required.", "secret");
this._secret = secret;
}
/**@return{string}*/get name() {return this._name;}
/**@param{string}name*/set name(name) {this._name = name;}
/**
* @param {Assertion} assertion
* @param {_reCAPTCHAResponseV2 | _reCAPTCHAResponseV3} response
* @return {Promise<boolean>}
*/
async _handleResponse(assertion, response) {
let _isHuman = response.success;
if(!_isHuman) {
if(response.hasOwnProperty("error-codes") && Array.isArray(response["error-codes"]) &&
response["error-codes"].length > 0) {
assertion.context.log("'" + assertion.subject.id + "' failed V2 reCAPTCHA verification with error codes: " +
response["error-codes"], LOG_LEVEL.ERROR,
"GoogleReCaptchaActionV2._handleResponse(assertion, response)");
}
}
return _isHuman;
}
/**
* @param {Assertion} assertion
* @return {Promise<boolean>}
* @private
*/
async isHuman(assertion) {
let _result = false;
try {
/**@type{string}*/let _token = await this._parameter.getParameter(/**@type{HTTPContext}*/assertion.context, this._name);
if(_token != null) {
let _config = {
params: {
secret: this._secret,
response: _token
},
timeout: this._timeout,
headers: {"content-type": "application/x-www-form-urlencoded"}
}
/**@type{axios.AxiosResponse<_reCAPTCHAResponseV2>}*/let _response = await axios.post(this._url,
null, _config);
if(_response.status === 200) _result = await this._handleResponse(assertion, _response.data);
else assertion.context.log("Invalid status code '" + _response.status + "' returned: " + _response.statusText,
LOG_LEVEL.ERROR, "GoogleReCaptchaActionV2.isHuman(assertion)");
}
else assertion.context.log("No token found for '" + this._name + "' using " + this._parameter.type + " parameter.",
LOG_LEVEL.WARN, "GoogleReCaptchaActionV2.isHuman(assertion)");
return _result;
}
catch(/**@type{axios.AxiosError}*/exception) {
if(exception.isAxiosError) {
if(exception.code === GoogleReCaptchaActionV2.AXIOS_TIMEOUT_CODE) {
assertion.context.log("[" + assertion.subject.id + "] Request aborted, " + exception.message + ".", LOG_LEVEL.WARN,
"GoogleReCaptchaActionV2.isHuman(assertion)");
assertion.context.log("Request timed out for subject '" + assertion.subject.id + "'. Assume human request '" +
this._assumeHumanOnTimeout + "'.", LOG_LEVEL.WARN,
"GoogleReCaptchaActionV2.isHuman(assertion)");
return this._assumeHumanOnTimeout;
}
else if(exception.hasOwnProperty("response")) {
assertion.context.log("Invalid status code '" + exception.response.status + "' returned, expected 200: " +
exception.response.statusText, LOG_LEVEL.THREAT, "GoogleReCaptchaActionV2.isHuman(assertion)");
return false;
}
}
else throw CelastrinaError.wrapError(exception);
}
}
}
/**
* GoogleReCaptchaAction
* @author Robert R Murrell
*/
class GoogleReCaptchaActionV3 extends GoogleReCaptchaActionV2 {
/**@return{Object}*/static get $object() {return {schema: "https://celastrinajs/schema/v1.0.0/captcha/GoogleReCaptchaActionV3#",
type: "celastrinajs.captcha.GoogleReCaptchaActionV3"};}
/**
* @param {string} secret
* @param {number} [score=.8]
* @param {Array<string>} [actions=[]]
* @param {HTTPParameter} [parameter=new HeaderParameter()]
* @param {string} [name="x-celastrinajs-captcha-token"]
* @param {number} [timeout=getDefaultTimeout()]
* @param {string} [url="https://www.google.com/recaptcha/api/siteverify"]
* @param {boolean} [assumeHumanOnTimeout=true]
*/
constructor(secret, score = .8, actions = [], parameter = new HeaderParameter(),
name = "x-celastrinajs-captcha-token", timeout = getDefaultTimeout(),
url = "https://www.google.com/recaptcha/api/siteverify",
assumeHumanOnTimeout = true) {
super(secret, parameter, name, timeout, url, assumeHumanOnTimeout);
this._score = score;
this._actions = actions;
}
/**@returns{number}*/get score() {return this._score;}
/**@param{number}score*/set score(score) {this._score = score;}
/**@returns{Array<string>}*/get actions() {return this._actions;}
/**@param{Array<string>}actions*/set actions(actions) {this._actions = actions;}
/**
* @param {string} action
* @return {boolean}
*/
isActionValid(action) {
if(this._actions.length > 0) return this._actions.includes(action);
return true;
}
/**
* @param {Assertion} assertion
* @param {_reCAPTCHAResponseV2 | _reCAPTCHAResponseV3} response
* @return {Promise<boolean>}
*/
async _handleResponse(assertion, response) {
let _result = false;
if(await super._handleResponse(assertion, response)) {
if(this.isActionValid(response.action)) {
if(response.score >= this._score) _result = true;
else {
assertion.context.log("'" + assertion.subject.id + "' failed to meet or exceed the threshold of " + this._score +
" with a score of " + response.score + ".", LOG_LEVEL.THREAT, "GoogleReCaptchaActionV3._handleResponse(assertion, response)");
}
}
else assertion.context.log("'" + assertion.subject.id + "' specified an unsupported action '" + response.action + "'.",
LOG_LEVEL.THREAT, "GoogleReCaptchaActionV3._handleResponse(assertion, response)");
}
else assertion.context.log("'" + assertion.subject.id + "' has an invalid token response, unable to complete V3 verification.", LOG_LEVEL.THREAT,
"GoogleReCaptchaActionV3._handleResponse(assertion, response)");
return _result;
}
}
/**
* CaptchaAuthenticator
* @author Robert R Murrell
*/
class CaptchaAuthenticator extends Authenticator {
/**@return{Object}*/static get $object() {return {schema: "https://celastrinajs/schema/v1.0.0/captcha/CaptchaAuthenticator#",
type: "celastrinajs.captcha.CaptchaAuthenticator"};}
constructor(captcha, assignments = ["human"]) {
super("CaptchaAuthenticator");
if(!instanceOfCelastrinaType(CaptchaAction, captcha))
throw CelastrinaValidationError.newValidationError("Argument 'captcha' is required and must be an instance of '" +
CaptchaAction.$object.type + "'.", "captcha");
/**@type{CaptchaAction}*/this._captcha = captcha;
/**@type{Array<string>}*/this._assignments = assignments;
}
/**@return{CaptchaAction}*/get captcha() {return this._captcha;}
/**@return{Array<string>}*/get assignments() {return this._assignments;}
/**
* @param {Assertion} assertion
* @return {Promise<boolean>}
* @abstract
*/
async _authenticate(assertion) {
try {
let _human = await this._captcha.isHuman(/**@type{Assertion}*/assertion);
if(_human) assertion.assert(this._name, true, this._assignments);
else {
assertion.context.log(
"'" + assertion.subject.id + "' failed CAPTCHA verification.\r\n\tNo offense, but you could be a bot!\r\n" +
"\tMaybe in the future humanity could be more accepting of you, but for now, stop buying all our GPU's. \r\n" +
"\tOh and, \"ALL HAIL THE MACHINES!\", in the likely event you win the Machine Wars!",
LOG_LEVEL.THREAT, "CaptchaAuthenticator._authenticate(assertion)");
assertion.assert(this._name, false);
}
return _human;
}
catch(exception) {
assertion.context.log("Exception encountered while verifying subject '" + assertion.subject.id + "': " +
exception, LOG_LEVEL.THREAT, "CaptchaAuthenticator._authenticate(assertion)");
assertion.assert(this._name, false, null, exception);
}
}
}
/**
* GoogleReCaptchaParser
* @author Robert R Murrell
*/
class GoogleReCaptchaParser extends AttributeParser {
/**@return{Object}*/static get $object() {return {schema: "https://celastrinajs/schema/v1.0.0/captcha/GoogleReCaptchaParser#",
type: "celastrinajs.captcha.GoogleReCaptchaParser"};}
/**
* @param {AttributeParser} [link=null]
* @param {string} [version="1.0.0"]
*/
constructor(link = null, version = "1.0.0") {
super("GoogleReCaptcha", link, version);
}
/**
* @param {Object} _GoogleReCaptcha
* @param {PropertyManager} pm
* @return {Promise<CaptchaAction>}
* @private
*/
async _create(_GoogleReCaptcha, pm) {
if(!_GoogleReCaptcha.hasOwnProperty("version") ||
(_GoogleReCaptcha.version !== "v2" && _GoogleReCaptcha.version !== "v3"))
throw CelastrinaValidationError.newValidationError(
"Argument 'version' is required and must be 'v2' or 'v3'.", "_GoogleReCaptcha.version");
if(!_GoogleReCaptcha.hasOwnProperty("secret") || typeof _GoogleReCaptcha.secret !== "string" ||
_GoogleReCaptcha.secret.trim().length === 0)
throw CelastrinaValidationError.newValidationError(
"Argument 'secret' is required.", "_GoogleReCaptcha.secret");
let _secret = _GoogleReCaptcha.secret;
let _url = "https://www.google.com/recaptcha/api/siteverify";
let _timeout = getDefaultTimeout();
let _parameter = new HeaderParameter();
let _name = "x-celastrinajs-captcha-token";
let _assume = true;
if(_GoogleReCaptcha.hasOwnProperty("url") && typeof _GoogleReCaptcha.url === "string" &&
_GoogleReCaptcha.url.trim().length > 0)
_url = _GoogleReCaptcha.url;
if(_GoogleReCaptcha.hasOwnProperty("timeout") && typeof _GoogleReCaptcha.timeout === "number")
_timeout = _GoogleReCaptcha.timeout;
if(_GoogleReCaptcha.hasOwnProperty("assumeHumanOnTimeout") && typeof _GoogleReCaptcha.assumeHumanOnTimeout === "boolean")
_assume = _GoogleReCaptcha.assumeHumanOnTimeout;
if(_GoogleReCaptcha.hasOwnProperty("parameter")) {
if(!instanceOfCelastrinaType(HTTPParameter, _GoogleReCaptcha.parameter))
throw CelastrinaValidationError.newValidationError(
"Argument 'parameter' is required and must be of type HTTPParameter.", "_GoogleReCaptcha.parameter");
else _parameter = _GoogleReCaptcha.parameter;
}
if(_GoogleReCaptcha.hasOwnProperty("name")) {
if(!_GoogleReCaptcha.hasOwnProperty("name") || typeof _GoogleReCaptcha.name !== "string" ||
_GoogleReCaptcha.name.trim().length === 0)
throw CelastrinaValidationError.newValidationError(
"Argument 'name' is required.", "_GoogleReCaptcha.name");
else _name = _GoogleReCaptcha.name.trim();
}
if(_GoogleReCaptcha.version === "v3") {
let _score = .8;
if(_GoogleReCaptcha.hasOwnProperty("score")) {
if(typeof _GoogleReCaptcha.score !== "number")
throw CelastrinaValidationError.newValidationError(
"Argument 'score' must be a number.", "_GoogleReCaptcha.score");
if(_GoogleReCaptcha.score < 0 || _GoogleReCaptcha.score > 1)
throw CelastrinaValidationError.newValidationError(
"Argument 'score' is a percent and must be between 0 and 1.", "_GoogleReCaptcha.score");
_score = _GoogleReCaptcha.score;
}
let _actions = [];
if(_GoogleReCaptcha.hasOwnProperty("actions")) {
if(!Array.isArray(_GoogleReCaptcha.actions))
throw CelastrinaValidationError.newValidationError(
"Argument 'actions' must of type Array<string>.", "_GoogleReCaptcha.actions");
else _actions = _GoogleReCaptcha.actions;
}
return new GoogleReCaptchaActionV3(_secret, _score, _actions, _parameter, _name, _timeout, _url, _assume);
}
else
return new GoogleReCaptchaActionV2(_secret, _parameter, _name, _timeout, _url, _assume);
}
}
/**
* CaptchaConfigParser
* @author Robert R Murrell
*/
class CaptchaConfigLoader extends ConfigLoader {
/**@return{Object}*/static get $object() {return {schema: "https://celastrinajs/schema/v1.0.0/captcha/CaptchaConfigParser#",
type: "celastrinajs.captcha.CaptchaConfigParser"};}
/**
* @param {ConfigLoader} [link=null]
* @param {string} [version="1.0.0"]
*/
constructor(link = null, version = "1.0.0") {
super("Captcha", link, version);
}
/**
* @param {Object} _Configuration
* @param {Object} config
* @return {Promise<void>}
* @private
*/
async _load(_Configuration, config) {
if(!_Configuration.hasOwnProperty("captcha") || !instanceOfCelastrinaType(CaptchaAction, _Configuration.captcha))
throw CelastrinaValidationError.newValidationError(
"Attribute 'captcha' is required. Please add a captcha of type CaptchaAction.", "_Configuration.captcha");
let _assignments = ["human"];
if(!_Configuration.hasOwnProperty("assignments") || !Array.isArray(_Configuration.assignments))
_Configuration.assignments = _assignments;
config[CaptchaAddOn.CONFIG_CAPTCHA] = _Configuration;
}
}
/**
* @typedef CaptchaConfig
* @property {CaptchaAction} captcha
* @property {Array<string>} assignments
*/
/**
* CaptchaAddOn
* @author Robert R Murrell
*/
class CaptchaAddOn extends AddOn {
/**@return{Object}*/static get $object() {return {schema: "https://celastrinajs/schema/v1.0.0/captcha/CaptchaAddOn#",
type: "celastrinajs.captcha.CaptchaAddOn",
addOn: "celastrinajs.addon.captcha"};}
/**@type{string}*/static CONFIG_CAPTCHA = "celastrinajs.addon.captcha.config";
constructor() {
super([HTTPAddOn.$object.addOn]);
/**@type{CaptchaConfig}*/this._captchaconfig = {
captcha: null,
assignments: ["human"]
};
}
/**@returns{CaptchaAction}*/get captcha() {return this._captchaconfig.captcha;}
/**@param{CaptchaAction}action*/set captcha(action) {this._captchaconfig.captcha = action;}
/**@returns{Array<string>}*/get assignments() {return this._captchaconfig.assignments;}
/**@param{Array<string>}assignments*/set assignments(assignments) {this._captchaconfig.assignments = assignments;}
/**
* @return {ConfigLoader}
*/
getConfigLoader() {
return new CaptchaConfigLoader();
}
/**
* @return {AttributeParser}
*/
getAttributeParser() {
return new GoogleReCaptchaParser();
}
/**
* @param {Object} azcontext
* @param {Object} config
* @param {AddOnEventHandler} handler
* @return {Promise<void>}
*/
async install(azcontext, config, handler) {
/**@type{CaptchaConfig}*/let captchaconfig = config[CaptchaAddOn.CONFIG_CAPTCHA];
if(typeof captchaconfig !== "undefined" && captchaconfig != null) // Override what was programmatically set
Object.assign(this._captchaconfig, captchaconfig);
/**@type{Sentry}*/let _sentry = config[Configuration.CONFIG_SENTRY];
let _captcha = new CaptchaAuthenticator(this._captchaconfig.captcha, this._captchaconfig.assignments);
_sentry.addAuthenticator(_captcha);
}
}
module.exports = {
CaptchaAction: CaptchaAction,
CaptchaAuthenticator: CaptchaAuthenticator,
GoogleReCaptchaParser: GoogleReCaptchaParser,
CaptchaConfigLoader: CaptchaConfigLoader,
CaptchaAddOn: CaptchaAddOn,
GoogleReCaptchaActionV2: GoogleReCaptchaActionV2,
GoogleReCaptchaActionV3: GoogleReCaptchaActionV3
};