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", "author": "Calvin Montgomery",
"name": "CyTube", "name": "CyTube",
"description": "Online media synchronizer and chat", "description": "Online media synchronizer and chat",
"version": "3.72.0", "version": "3.73.0",
"repository": { "repository": {
"url": "http://github.com/calzoneman/sync" "url": "http://github.com/calzoneman/sync"
}, },

View file

@ -6,6 +6,7 @@ import { loadFromToml } from './configuration/configloader';
import { CamoConfig } from './configuration/camoconfig'; import { CamoConfig } from './configuration/camoconfig';
import { PrometheusConfig } from './configuration/prometheusconfig'; import { PrometheusConfig } from './configuration/prometheusconfig';
import { EmailConfig } from './configuration/emailconfig'; import { EmailConfig } from './configuration/emailconfig';
import { CaptchaConfig } from './configuration/captchaconfig';
const LOGGER = require('@calzoneman/jsli')('config'); const LOGGER = require('@calzoneman/jsli')('config');
@ -129,6 +130,7 @@ var cfg = defaults;
let camoConfig = new CamoConfig(); let camoConfig = new CamoConfig();
let prometheusConfig = new PrometheusConfig(); let prometheusConfig = new PrometheusConfig();
let emailConfig = new EmailConfig(); let emailConfig = new EmailConfig();
let captchaConfig = new CaptchaConfig();
/** /**
* Initializes the configuration from the given YAML file * Initializes the configuration from the given YAML file
@ -176,6 +178,7 @@ exports.load = function (file) {
loadCamoConfig(); loadCamoConfig();
loadPrometheusConfig(); loadPrometheusConfig();
loadEmailConfig(); loadEmailConfig();
loadCaptchaConfig();
}; };
function checkLoadConfig(configClass, filename) { 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 // I'm sorry
function preprocessConfig(cfg) { function preprocessConfig(cfg) {
// Root domain should start with a . for cookies // Root domain should start with a . for cookies
@ -487,3 +502,7 @@ exports.getPrometheusConfig = function getPrometheusConfig() {
exports.getEmailConfig = function getEmailConfig() { exports.getEmailConfig = function getEmailConfig() {
return emailConfig; 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 { PartitionModule } from './partition/partitionmodule';
import { Gauge } from 'prom-client'; import { Gauge } from 'prom-client';
import { EmailController } from './controller/email'; import { EmailController } from './controller/email';
import { CaptchaController } from './controller/captcha';
var Server = function () { var Server = function () {
var self = this; var self = this;
@ -102,6 +103,10 @@ var Server = function () {
Config.getEmailConfig() Config.getEmailConfig()
); );
const captchaController = new CaptchaController(
Config.getCaptchaConfig()
);
// webserver init ----------------------------------------------------- // webserver init -----------------------------------------------------
const ioConfig = IOConfiguration.fromOldConfig(Config); const ioConfig = IOConfiguration.fromOldConfig(Config);
const webConfig = WebConfiguration.fromOldConfig(Config); const webConfig = WebConfiguration.fromOldConfig(Config);
@ -126,7 +131,9 @@ var Server = function () {
session, session,
globalMessageBus, globalMessageBus,
Config.getEmailConfig(), Config.getEmailConfig(),
emailController emailController,
Config.getCaptchaConfig(),
captchaController
); );
// http/https/sio server init ----------------------------------------- // 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 * Handles a GET request for /register
*/ */
function handleRegisterPage(req, res) { function handleRegisterPage(captchaConfig, req, res) {
if (res.locals.loggedIn) { if (res.locals.loggedIn) {
sendPug(res, "register", {}); sendPug(res, "register", {});
return; return;
@ -161,14 +168,15 @@ function handleRegisterPage(req, res) {
sendPug(res, "register", { sendPug(res, "register", {
registered: false, registered: false,
registerError: false registerError: false,
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
}); });
} }
/** /**
* Processes a registration request. * Processes a registration request.
*/ */
function handleRegister(req, res) { function handleRegister(captchaConfig, captchaController, req, res) {
csrf.verify(req); csrf.verify(req);
var name = req.body.name; var name = req.body.name;
@ -178,15 +186,26 @@ function handleRegister(req, res) {
email = ""; email = "";
} }
var ip = req.realIP; var ip = req.realIP;
let captchaToken = req.body['h-captcha-response'];
if (typeof name !== "string" || typeof password !== "string") { if (typeof name !== "string" || typeof password !== "string") {
res.sendStatus(400); res.sendStatus(400);
return; return;
} }
if (captchaConfig.isEnabled() &&
(typeof captchaToken !== 'string' || captchaToken === '')) {
sendPug(res, "register", {
registerError: "Missing CAPTCHA",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
if (name.length === 0) { if (name.length === 0) {
sendPug(res, "register", { sendPug(res, "register", {
registerError: "Username must not be empty" registerError: "Username must not be empty",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
}); });
return; return;
} }
@ -198,14 +217,16 @@ function handleRegister(req, res) {
name name
); );
sendPug(res, "register", { sendPug(res, "register", {
registerError: "That username is reserved" registerError: "That username is reserved",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
}); });
return; return;
} }
if (password.length === 0) { if (password.length === 0) {
sendPug(res, "register", { sendPug(res, "register", {
registerError: "Password must not be empty" registerError: "Password must not be empty",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
}); });
return; return;
} }
@ -214,15 +235,36 @@ function handleRegister(req, res) {
if (email.length > 0 && !$util.isValidEmail(email)) { if (email.length > 0 && !$util.isValidEmail(email)) {
sendPug(res, "register", { sendPug(res, "register", {
registerError: "Invalid email address" registerError: "Invalid email address",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
}); });
return; 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) { db.users.register(name, password, email, ip, function (err) {
if (err) { if (err) {
sendPug(res, "register", { sendPug(res, "register", {
registerError: err registerError: err,
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
}); });
} else { } else {
Logger.eventlog.log("[register] " + ip + " registered account: " + name + Logger.eventlog.log("[register] " + ip + " registered account: " + name +
@ -234,17 +276,22 @@ function handleRegister(req, res) {
}); });
} }
}); });
}
} }
module.exports = { module.exports = {
/** /**
* Initializes auth callbacks * Initializes auth callbacks
*/ */
init: function (app) { init: function (app, captchaConfig, captchaController) {
app.get("/login", handleLoginPage); app.get("/login", handleLoginPage);
app.post("/login", handleLogin); app.post("/login", handleLogin);
app.post("/logout", handleLogout); app.post("/logout", handleLogout);
app.get("/register", handleRegisterPage); app.get("/register", (req, res) => {
app.post("/register", handleRegister); handleRegisterPage(captchaConfig, req, res);
});
app.post("/register", (req, res) => {
handleRegister(captchaConfig, captchaController, req, res);
});
} }
}; };

View file

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

View file

@ -36,6 +36,11 @@ block content
p p
| Providing an email address is optional and will allow you to recover your account via email if you forget your password. | 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! 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 button#registerbtn.btn.btn-success.btn-block(type="submit") Register
else else
.col-lg-6.col-lg-offset-3.col-md-6.col-md-offset-3 .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. p Thanks for registering, #{registerName}! Now you can <a href="/login">Login</a> to use your account.
append footer append footer
if hCaptchaSiteKey
script(src="https://hcaptcha.com/1/api.js" async defer)
script(type="text/javascript"). script(type="text/javascript").
function verify() { function verify() {
var valid = checkUsername(); var valid = checkUsername();