Add registration captcha support
This commit is contained in:
parent
f08cce5aed
commit
df82d2d4f1
9
conf/example/captcha.toml
Normal file
9
conf/example/captcha.toml
Normal 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
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
30
src/configuration/captchaconfig.js
Normal file
30
src/configuration/captchaconfig.js
Normal 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
102
src/controller/captcha.js
Normal 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 };
|
|
@ -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 -----------------------------------------
|
||||||
|
|
|
@ -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 +
|
||||||
|
@ -235,16 +277,21 @@ 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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in a new issue