Add registration captcha support

This commit is contained in:
Calvin Montgomery 2020-09-22 20:11:34 -07:00
parent f08cce5aed
commit df82d2d4f1
9 changed files with 254 additions and 31 deletions

View file

@ -0,0 +1,9 @@
[hcaptcha]
# Site key from hCaptcha. The value here by default is the dummy test key for local testing
site-key = "10000000-ffff-ffff-ffff-000000000001"
# Secret key from hCaptcha. The value here by default is the dummy test key for local testing
secret = "0x0000000000000000000000000000000000000000"
[register]
# Whether to require a captcha for registration
enabled = true

View file

@ -2,7 +2,7 @@
"author": "Calvin Montgomery",
"name": "CyTube",
"description": "Online media synchronizer and chat",
"version": "3.72.0",
"version": "3.73.0",
"repository": {
"url": "http://github.com/calzoneman/sync"
},

View file

@ -6,6 +6,7 @@ import { loadFromToml } from './configuration/configloader';
import { CamoConfig } from './configuration/camoconfig';
import { PrometheusConfig } from './configuration/prometheusconfig';
import { EmailConfig } from './configuration/emailconfig';
import { CaptchaConfig } from './configuration/captchaconfig';
const LOGGER = require('@calzoneman/jsli')('config');
@ -129,6 +130,7 @@ var cfg = defaults;
let camoConfig = new CamoConfig();
let prometheusConfig = new PrometheusConfig();
let emailConfig = new EmailConfig();
let captchaConfig = new CaptchaConfig();
/**
* Initializes the configuration from the given YAML file
@ -176,6 +178,7 @@ exports.load = function (file) {
loadCamoConfig();
loadPrometheusConfig();
loadEmailConfig();
loadCaptchaConfig();
};
function checkLoadConfig(configClass, filename) {
@ -238,6 +241,18 @@ function loadEmailConfig() {
}
}
function loadCaptchaConfig() {
const conf = checkLoadConfig(Object, 'captcha.toml');
if (conf === null) {
LOGGER.info('No captcha configuration found, defaulting to disabled');
captchaConfig.load();
} else {
captchaConfig.load(conf);
LOGGER.info('Loaded captcha configuration from conf/captcha.toml.');
}
}
// I'm sorry
function preprocessConfig(cfg) {
// Root domain should start with a . for cookies
@ -487,3 +502,7 @@ exports.getPrometheusConfig = function getPrometheusConfig() {
exports.getEmailConfig = function getEmailConfig() {
return emailConfig;
};
exports.getCaptchaConfig = function getCaptchaConfig() {
return captchaConfig;
};

View file

@ -0,0 +1,30 @@
class CaptchaConfig {
constructor() {
this.load();
}
load(config = { hcaptcha: {}, register: { enabled: false } }) {
this.config = config;
const hcaptcha = config.hcaptcha;
this._hcaptcha = {
getSiteKey() {
return hcaptcha['site-key'];
},
getSecret() {
return hcaptcha.secret;
}
};
}
getHcaptcha() {
return this._hcaptcha;
}
isEnabled() {
return this.config.register.enabled;
}
}
export { CaptchaConfig };

102
src/controller/captcha.js Normal file
View file

@ -0,0 +1,102 @@
const https = require('https');
const querystring = require('querystring');
const { Counter } = require('prom-client');
const LOGGER = require('@calzoneman/jsli')('captcha-controller');
const captchaCount = new Counter({
name: 'cytube_captcha_count',
help: 'Count of captcha checks'
});
const captchaFailCount = new Counter({
name: 'cytube_captcha_failed_count',
help: 'Count of rejected captcha responses'
});
class CaptchaController {
constructor(config) {
this.config = config;
}
async verifyToken(token) {
return new Promise((resolve, reject) => {
let params = querystring.stringify({
secret: this.config.getHcaptcha().getSecret(),
response: token
});
let req = https.request(
'https://hcaptcha.com/siteverify',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': params.length
}
}
);
req.setTimeout(10000, () => {
const error = new Error('Request timed out.');
error.code = 'ETIMEDOUT';
reject(error);
});
req.on('error', error => {
reject(error);
});
req.on('response', res => {
if (res.statusCode !== 200) {
req.abort();
reject(new Error(
`HTTP ${res.statusCode} ${res.statusMessage}`
));
return;
}
let buffer = '';
res.setEncoding('utf8');
res.on('data', data => {
buffer += data;
});
res.on('end', () => {
resolve(buffer);
});
});
req.write(params);
req.end();
}).then(body => {
captchaCount.inc(1);
let res = JSON.parse(body);
if (!res.success) {
captchaFailCount.inc(1);
if (res['error-codes'].length > 0) {
switch (res['error-codes'][0]) {
case 'missing-input-secret':
throw new Error('hCaptcha is misconfigured: missing secret');
case 'invalid-input-secret':
throw new Error('hCaptcha is misconfigured: invalid secret');
case 'sitekey-secret-mismatch':
throw new Error('hCaptcha is misconfigured: secret does not match site-key');
case 'invalid-input-response':
case 'invalid-or-already-seen-response':
throw new Error('Invalid captcha response');
default:
LOGGER.error('Unknown hCaptcha error; response: %j', res);
throw new Error('Unknown hCaptcha error: ' + res['error-codes'][0]);
}
} else {
throw new Error('Captcha verification failed');
}
}
});
}
}
export { CaptchaController };

View file

@ -46,6 +46,7 @@ import { LegacyModule } from './legacymodule';
import { PartitionModule } from './partition/partitionmodule';
import { Gauge } from 'prom-client';
import { EmailController } from './controller/email';
import { CaptchaController } from './controller/captcha';
var Server = function () {
var self = this;
@ -102,6 +103,10 @@ var Server = function () {
Config.getEmailConfig()
);
const captchaController = new CaptchaController(
Config.getCaptchaConfig()
);
// webserver init -----------------------------------------------------
const ioConfig = IOConfiguration.fromOldConfig(Config);
const webConfig = WebConfiguration.fromOldConfig(Config);
@ -126,7 +131,9 @@ var Server = function () {
session,
globalMessageBus,
Config.getEmailConfig(),
emailController
emailController,
Config.getCaptchaConfig(),
captchaController
);
// http/https/sio server init -----------------------------------------

View file

@ -150,10 +150,17 @@ function handleLogout(req, res) {
}
}
function getHcaptchaSiteKey(captchaConfig) {
if (captchaConfig.isEnabled())
return captchaConfig.getHcaptcha().getSiteKey();
else
return null;
}
/**
* Handles a GET request for /register
*/
function handleRegisterPage(req, res) {
function handleRegisterPage(captchaConfig, req, res) {
if (res.locals.loggedIn) {
sendPug(res, "register", {});
return;
@ -161,14 +168,15 @@ function handleRegisterPage(req, res) {
sendPug(res, "register", {
registered: false,
registerError: false
registerError: false,
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
}
/**
* Processes a registration request.
*/
function handleRegister(req, res) {
function handleRegister(captchaConfig, captchaController, req, res) {
csrf.verify(req);
var name = req.body.name;
@ -178,15 +186,26 @@ function handleRegister(req, res) {
email = "";
}
var ip = req.realIP;
let captchaToken = req.body['h-captcha-response'];
if (typeof name !== "string" || typeof password !== "string") {
res.sendStatus(400);
return;
}
if (captchaConfig.isEnabled() &&
(typeof captchaToken !== 'string' || captchaToken === '')) {
sendPug(res, "register", {
registerError: "Missing CAPTCHA",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
if (name.length === 0) {
sendPug(res, "register", {
registerError: "Username must not be empty"
registerError: "Username must not be empty",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
@ -198,14 +217,16 @@ function handleRegister(req, res) {
name
);
sendPug(res, "register", {
registerError: "That username is reserved"
registerError: "That username is reserved",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
if (password.length === 0) {
sendPug(res, "register", {
registerError: "Password must not be empty"
registerError: "Password must not be empty",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
@ -214,15 +235,36 @@ function handleRegister(req, res) {
if (email.length > 0 && !$util.isValidEmail(email)) {
sendPug(res, "register", {
registerError: "Invalid email address"
registerError: "Invalid email address",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
if (captchaConfig.isEnabled()) {
let captchaSuccess = true;
captchaController.verifyToken(captchaToken)
.catch(error => {
LOGGER.warn('CAPTCHA failed for registration %s: %s', name, error.message);
captchaSuccess = false;
sendPug(res, "register", {
registerError: 'CAPTCHA verification failed: ' + error.message,
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
}).then(() => {
if (captchaSuccess)
doRegister();
});
} else {
doRegister();
}
function doRegister() {
db.users.register(name, password, email, ip, function (err) {
if (err) {
sendPug(res, "register", {
registerError: err
registerError: err,
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
} else {
Logger.eventlog.log("[register] " + ip + " registered account: " + name +
@ -235,16 +277,21 @@ function handleRegister(req, res) {
}
});
}
}
module.exports = {
/**
* Initializes auth callbacks
*/
init: function (app) {
init: function (app, captchaConfig, captchaController) {
app.get("/login", handleLoginPage);
app.post("/login", handleLogin);
app.post("/logout", handleLogout);
app.get("/register", handleRegisterPage);
app.post("/register", handleRegister);
app.get("/register", (req, res) => {
handleRegisterPage(captchaConfig, req, res);
});
app.post("/register", (req, res) => {
handleRegister(captchaConfig, captchaController, req, res);
});
}
};

View file

@ -142,7 +142,9 @@ module.exports = {
session,
globalMessageBus,
emailConfig,
emailController
emailController,
captchaConfig,
captchaController
) {
patchExpressToHandleAsync();
const chanPath = Config.get('channel-path');
@ -155,10 +157,10 @@ module.exports = {
require('./middleware/x-forwarded-for').initialize(app, webConfig);
app.use(bodyParser.urlencoded({
extended: false,
limit: '1kb' // No POST data should ever exceed this size under normal usage
limit: '8kb' // No POST data should ever exceed this size under normal usage
}));
app.use(bodyParser.json({
limit: '1kb'
limit: '8kb'
}));
if (webConfig.getCookieSecret() === 'change-me') {
LOGGER.warn('The configured cookie secret was left as the ' +
@ -201,8 +203,8 @@ module.exports = {
require('./routes/index')(app, channelIndex, webConfig.getMaxIndexEntries());
require('./routes/socketconfig')(app, clusterClient);
require('./routes/contact')(app, webConfig);
require('./auth').init(app);
require('./account').init(app, globalMessageBus, emailConfig, emailController);
require('./auth').init(app, captchaConfig, captchaController);
require('./account').init(app, globalMessageBus, emailConfig, emailController, captchaConfig);
require('./routes/account/delete-account')(
app,
csrf.verify,

View file

@ -36,6 +36,11 @@ block content
p
| Providing an email address is optional and will allow you to recover your account via email if you forget your password.
strong   If you do not provide an email address, you will not be able to recover a lost account!
if hCaptchaSiteKey
noscript
.text-danger This website requires JavaScript in order to display a CAPTCHA.
.form-group
div.h-captcha(data-sitekey=hCaptchaSiteKey)
button#registerbtn.btn.btn-success.btn-block(type="submit") Register
else
.col-lg-6.col-lg-offset-3.col-md-6.col-md-offset-3
@ -44,6 +49,8 @@ block content
p Thanks for registering, #{registerName}! Now you can <a href="/login">Login</a> to use your account.
append footer
if hCaptchaSiteKey
script(src="https://hcaptcha.com/1/api.js" async defer)
script(type="text/javascript").
function verify() {
var valid = checkUsername();