diff --git a/conf/example/captcha.toml b/conf/example/captcha.toml new file mode 100644 index 00000000..09a63373 --- /dev/null +++ b/conf/example/captcha.toml @@ -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 diff --git a/package.json b/package.json index 42db1c07..0e8ce0af 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/config.js b/src/config.js index c6f2bdb8..bb2e29e5 100644 --- a/src/config.js +++ b/src/config.js @@ -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; +}; diff --git a/src/configuration/captchaconfig.js b/src/configuration/captchaconfig.js new file mode 100644 index 00000000..8e825713 --- /dev/null +++ b/src/configuration/captchaconfig.js @@ -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 }; diff --git a/src/controller/captcha.js b/src/controller/captcha.js new file mode 100644 index 00000000..f6833568 --- /dev/null +++ b/src/controller/captcha.js @@ -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 }; diff --git a/src/server.js b/src/server.js index a45053d4..b6be7c70 100644 --- a/src/server.js +++ b/src/server.js @@ -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 ----------------------------------------- diff --git a/src/web/auth.js b/src/web/auth.js index 094ca1ca..fe0e34e2 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -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,37 +235,63 @@ 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; } - db.users.register(name, password, email, ip, function (err) { - if (err) { - sendPug(res, "register", { - registerError: err + 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 { - Logger.eventlog.log("[register] " + ip + " registered account: " + name + - (email.length > 0 ? " <" + email + ">" : "")); - sendPug(res, "register", { - registered: true, - registerName: name, - redirect: req.body.redirect - }); - } - }); + } else { + doRegister(); + } + + function doRegister() { + db.users.register(name, password, email, ip, function (err) { + 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 = { /** * 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); + }); } }; diff --git a/src/web/webserver.js b/src/web/webserver.js index cb9ef610..f7be0acb 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -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, diff --git a/templates/register.pug b/templates/register.pug index be0d81d3..56e550ce 100644 --- a/templates/register.pug +++ b/templates/register.pug @@ -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 Login 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();