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,37 +235,63 @@ 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;
} }
db.users.register(name, password, email, ip, function (err) { if (captchaConfig.isEnabled()) {
if (err) { let captchaSuccess = true;
sendPug(res, "register", { captchaController.verifyToken(captchaToken)
registerError: err .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 { } else {
Logger.eventlog.log("[register] " + ip + " registered account: " + name + doRegister();
(email.length > 0 ? " <" + email + ">" : "")); }
sendPug(res, "register", {
registered: true, function doRegister() {
registerName: name, db.users.register(name, password, email, ip, function (err) {
redirect: req.body.redirect if (err) {
}); sendPug(res, "register", {
} registerError: err,
}); hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
} else {
Logger.eventlog.log("[register] " + ip + " registered account: " + name +
(email.length > 0 ? " <" + email + ">" : ""));
sendPug(res, "register", {
registered: true,
registerName: name,
redirect: req.body.redirect
});
}
});
}
} }
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 &nbsp;&nbsp;If you do not provide an email address, you will not be able to recover a lost account! strong &nbsp;&nbsp;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();