feat: #7743, user/approval, user/auth

This commit is contained in:
Barış Soner Uşaklı
2019-07-11 23:43:00 -04:00
parent 2b70e86f21
commit b24ce97602
4 changed files with 234 additions and 392 deletions

View File

@@ -37,7 +37,7 @@ var app;
var viewsDir = nconf.get('views_dir'); var viewsDir = nconf.get('views_dir');
Emailer.getTemplates = function (config, cb) { Emailer.getTemplates = function (config, callback) {
var emailsPath = path.join(viewsDir, 'emails'); var emailsPath = path.join(viewsDir, 'emails');
async.waterfall([ async.waterfall([
function (next) { function (next) {
@@ -71,7 +71,7 @@ Emailer.getTemplates = function (config, cb) {
], next); ], next);
}, next); }, next);
}, },
], cb); ], callback);
}; };
Emailer.listServices = function (callback) { Emailer.listServices = function (callback) {
@@ -407,3 +407,5 @@ function getHostname() {
return parsed.hostname; return parsed.hostname;
} }
require('./promisify')(Emailer, ['transports']);

View File

@@ -320,6 +320,9 @@ function pushToUids(uids, notification, callback) {
Notifications.pushGroup = function (notification, groupName, callback) { Notifications.pushGroup = function (notification, groupName, callback) {
callback = callback || function () {}; callback = callback || function () {};
if (!notification) {
return callback();
}
async.waterfall([ async.waterfall([
function (next) { function (next) {
groups.getMembers(groupName, 0, -1, next); groups.getMembers(groupName, 0, -1, next);
@@ -332,6 +335,9 @@ Notifications.pushGroup = function (notification, groupName, callback) {
Notifications.pushGroups = function (notification, groupNames, callback) { Notifications.pushGroups = function (notification, groupNames, callback) {
callback = callback || function () {}; callback = callback || function () {};
if (!notification) {
return callback();
}
async.waterfall([ async.waterfall([
function (next) { function (next) {
groups.getMembersOfGroups(groupNames, next); groups.getMembersOfGroups(groupNames, next);

View File

@@ -12,228 +12,132 @@ var utils = require('../utils');
var plugins = require('../plugins'); var plugins = require('../plugins');
module.exports = function (User) { module.exports = function (User) {
User.addToApprovalQueue = function (userData, callback) { User.addToApprovalQueue = async function (userData) {
userData.userslug = utils.slugify(userData.username); userData.userslug = utils.slugify(userData.username);
async.waterfall([ await canQueue(userData);
function (next) { const hashedPassword = await User.hashPassword(userData.password);
canQueue(userData, next); var data = {
}, username: userData.username,
function (next) { email: userData.email,
User.hashPassword(userData.password, next); ip: userData.ip,
}, hashedPassword: hashedPassword,
function (hashedPassword, next) { };
var data = { const results = await plugins.fireHook('filter:user.addToApprovalQueue', { data: data, userData: userData });
username: userData.username, await db.setObject('registration:queue:name:' + userData.username, results.data);
email: userData.email, await db.sortedSetAdd('registration:queue', Date.now(), userData.username);
ip: userData.ip, await sendNotificationToAdmins(userData.username);
hashedPassword: hashedPassword,
};
plugins.fireHook('filter:user.addToApprovalQueue', { data: data, userData: userData }, next);
},
function (results, next) {
db.setObject('registration:queue:name:' + userData.username, results.data, next);
},
function (next) {
db.sortedSetAdd('registration:queue', Date.now(), userData.username, next);
},
function (next) {
sendNotificationToAdmins(userData.username, next);
},
], callback);
}; };
function canQueue(userData, callback) { async function canQueue(userData) {
async.waterfall([ await User.isDataValid(userData);
function (next) { const usernames = await db.getSortedSetRange('registration:queue', 0, -1);
User.isDataValid(userData, next); if (usernames.includes(userData.username)) {
}, throw new Error('[[error:username-taken]]');
function (next) { }
db.getSortedSetRange('registration:queue', 0, -1, next); const keys = usernames.filter(Boolean).map(username => 'registration:queue:name:' + username);
}, const data = await db.getObjectsFields(keys, ['email']);
function (usernames, next) { const emails = data.map(data => data && data.email);
if (usernames.includes(userData.username)) { if (emails.includes(userData.email)) {
return next(new Error('[[error:username-taken]]')); throw new Error('[[error:email-taken]]');
} }
const keys = usernames.filter(Boolean).map(username => 'registration:queue:name:' + username);
db.getObjectsFields(keys, ['email'], next);
},
function (data, next) {
const emails = data.map(data => data && data.email);
if (emails.includes(userData.email)) {
return next(new Error('[[error:email-taken]]'));
}
next();
},
], callback);
} }
function sendNotificationToAdmins(username, callback) { async function sendNotificationToAdmins(username) {
async.waterfall([ const notifObj = await notifications.create({
function (next) { type: 'new-register',
notifications.create({ bodyShort: '[[notifications:new_register, ' + username + ']]',
type: 'new-register', nid: 'new_register:' + username,
bodyShort: '[[notifications:new_register, ' + username + ']]', path: '/admin/manage/registration',
nid: 'new_register:' + username, mergeId: 'new_register',
path: '/admin/manage/registration',
mergeId: 'new_register',
}, next);
},
function (notification, next) {
notifications.pushGroup(notification, 'administrators', next);
},
], callback);
}
User.acceptRegistration = function (username, callback) {
var uid;
var userData;
async.waterfall([
function (next) {
db.getObject('registration:queue:name:' + username, next);
},
function (_userData, next) {
if (!_userData) {
return callback(new Error('[[error:invalid-data]]'));
}
userData = _userData;
User.create(userData, next);
},
function (_uid, next) {
uid = _uid;
User.setUserField(uid, 'password', userData.hashedPassword, next);
},
function (next) {
removeFromQueue(username, next);
},
function (next) {
markNotificationRead(username, next);
},
function (next) {
plugins.fireHook('filter:register.complete', { uid: uid }, next);
},
function (result, next) {
var title = meta.config.title || meta.config.browserTitle || 'NodeBB';
var data = {
username: username,
subject: '[[email:welcome-to, ' + title + ']]',
template: 'registration_accepted',
uid: uid,
};
emailer.send('registration_accepted', uid, data, next);
},
function (next) {
next(null, uid);
},
], callback);
};
function markNotificationRead(username, callback) {
var nid = 'new_register:' + username;
async.waterfall([
function (next) {
groups.getMembers('administrators', 0, -1, next);
},
function (uids, next) {
async.each(uids, function (uid, next) {
notifications.markRead(nid, uid, next);
}, next);
},
], callback);
}
User.rejectRegistration = function (username, callback) {
async.waterfall([
function (next) {
removeFromQueue(username, next);
},
function (next) {
markNotificationRead(username, next);
},
], callback);
};
function removeFromQueue(username, callback) {
async.parallel([
async.apply(db.sortedSetRemove, 'registration:queue', username),
async.apply(db.delete, 'registration:queue:name:' + username),
], function (err) {
callback(err);
}); });
await notifications.pushGroup(notifObj, 'administrators');
} }
User.shouldQueueUser = function (ip, callback) { User.acceptRegistration = async function (username) {
const userData = await db.getObject('registration:queue:name:' + username);
if (!userData) {
throw new Error('[[error:invalid-data]]');
}
const uid = await User.create(userData);
await User.setUserField(uid, 'password', userData.hashedPassword);
await removeFromQueue(username);
await markNotificationRead(username);
await plugins.fireHook('filter:register.complete', { uid: uid });
await emailer.send('registration_accepted', uid, {
username: username,
subject: '[[email:welcome-to, ' + (meta.config.title || meta.config.browserTitle || 'NodeBB') + ']]',
template: 'registration_accepted',
uid: uid,
});
return uid;
};
async function markNotificationRead(username) {
const nid = 'new_register:' + username;
const uids = await groups.getMembers('administrators', 0, -1);
const promises = uids.map(uid => notifications.markRead(nid, uid));
await Promise.all(promises);
}
User.rejectRegistration = async function (username) {
await removeFromQueue(username);
await markNotificationRead(username);
};
async function removeFromQueue(username) {
await Promise.all([
db.sortedSetRemove('registration:queue', username),
db.delete('registration:queue:name:' + username),
]);
}
User.shouldQueueUser = async function (ip) {
const registrationApprovalType = meta.config.registrationApprovalType; const registrationApprovalType = meta.config.registrationApprovalType;
if (registrationApprovalType === 'admin-approval') { if (registrationApprovalType === 'admin-approval') {
setImmediate(callback, null, true); return true;
} else if (registrationApprovalType === 'admin-approval-ip') { } else if (registrationApprovalType === 'admin-approval-ip') {
db.sortedSetCard('ip:' + ip + ':uid', function (err, count) { const count = await db.sortedSetCard('ip:' + ip + ':uid');
callback(err, !!count); return !!count;
});
} else {
setImmediate(callback, null, false);
} }
return false;
}; };
User.getRegistrationQueue = function (start, stop, callback) { User.getRegistrationQueue = async function (start, stop) {
var data; const data = await db.getSortedSetRevRangeWithScores('registration:queue', start, stop);
async.waterfall([ const keys = data.filter(Boolean).map(user => 'registration:queue:name:' + user.value);
function (next) { let users = await db.getObjects(keys);
db.getSortedSetRevRangeWithScores('registration:queue', start, stop, next); users = users.filter(Boolean).map(function (user, index) {
}, user.timestampISO = utils.toISOString(data[index].score);
function (_data, next) { user.email = validator.escape(String(user.email));
data = _data; delete user.hashedPassword;
var keys = data.filter(Boolean).map(user => 'registration:queue:name:' + user.value); return user;
db.getObjects(keys, next); });
},
function (users, next) { users = await async.map(users, async function (user) {
users = users.filter(Boolean).map(function (user, index) { // temporary: see http://www.stopforumspam.com/forum/viewtopic.php?id=6392
user.timestampISO = utils.toISOString(data[index].score); // need to keep this for getIPMatchedUsers
user.email = validator.escape(String(user.email)); user.ip = user.ip.replace('::ffff:', '');
delete user.hashedPassword; await getIPMatchedUsers(user);
return user; user.customActions = [].concat(user.customActions);
return user;
/*
// then spam prevention plugins, using the "filter:user.getRegistrationQueue" hook can be like:
user.customActions.push({
title: '[[spam-be-gone:report-user]]',
id: 'report-spam-user-' + user.username,
class: 'btn-warning report-spam-user',
icon: 'fa-flag'
}); });
*/
async.map(users, function (user, next) { });
// temporary: see http://www.stopforumspam.com/forum/viewtopic.php?id=6392 const results = await plugins.fireHook('filter:user.getRegistrationQueue', { users: users });
// need to keep this for getIPMatchedUsers return results.users;
user.ip = user.ip.replace('::ffff:', '');
getIPMatchedUsers(user, function (err) {
next(err, user);
});
user.customActions = [].concat(user.customActions);
/*
// then spam prevention plugins, using the "filter:user.getRegistrationQueue" hook can be like:
user.customActions.push({
title: '[[spam-be-gone:report-user]]',
id: 'report-spam-user-' + user.username,
class: 'btn-warning report-spam-user',
icon: 'fa-flag'
});
*/
}, next);
},
function (users, next) {
plugins.fireHook('filter:user.getRegistrationQueue', { users: users }, next);
},
function (results, next) {
next(null, results.users);
},
], callback);
}; };
function getIPMatchedUsers(user, callback) { async function getIPMatchedUsers(user) {
async.waterfall([ const uids = await User.getUidsFromSet('ip:' + user.ip + ':uid', 0, -1);
function (next) { const data = User.getUsersFields(uids, ['uid', 'username', 'picture']);
User.getUidsFromSet('ip:' + user.ip + ':uid', 0, -1, next); user.ipMatch = data;
},
function (uids, next) {
User.getUsersFields(uids, ['uid', 'username', 'picture'], next);
},
function (data, next) {
user.ipMatch = data;
next();
},
], callback);
} }
}; };

View File

@@ -3,6 +3,8 @@
var async = require('async'); var async = require('async');
var winston = require('winston'); var winston = require('winston');
var validator = require('validator'); var validator = require('validator');
const util = require('util');
const _ = require('lodash');
var db = require('../database'); var db = require('../database');
var meta = require('../meta'); var meta = require('../meta');
var events = require('../events'); var events = require('../events');
@@ -12,207 +14,135 @@ var utils = require('../utils');
module.exports = function (User) { module.exports = function (User) {
User.auth = {}; User.auth = {};
User.auth.logAttempt = function (uid, ip, callback) { User.auth.logAttempt = async function (uid, ip) {
if (!(parseInt(uid, 10) > 0)) { if (!(parseInt(uid, 10) > 0)) {
return setImmediate(callback); return;
} }
async.waterfall([ const exists = await db.exists('lockout:' + uid);
function (next) { if (exists) {
db.exists('lockout:' + uid, next); throw new Error('[[error:account-locked]]');
},
function (exists, next) {
if (exists) {
return callback(new Error('[[error:account-locked]]'));
}
db.increment('loginAttempts:' + uid, next);
},
function (attempts, next) {
if (attempts <= meta.config.loginAttempts) {
return db.pexpire('loginAttempts:' + uid, 1000 * 60 * 60, callback);
}
// Lock out the account
db.set('lockout:' + uid, '', next);
},
function (next) {
var duration = 1000 * 60 * meta.config.lockoutDuration;
db.delete('loginAttempts:' + uid);
db.pexpire('lockout:' + uid, duration);
events.log({
type: 'account-locked',
uid: uid,
ip: ip,
});
next(new Error('[[error:account-locked]]'));
},
], callback);
};
User.auth.getFeedToken = function (uid, callback) {
if (parseInt(uid, 10) <= 0) {
return setImmediate(callback);
} }
var token; const attempts = await db.increment('loginAttempts:' + uid);
async.waterfall([ if (attempts <= meta.config.loginAttempts) {
function (next) { return await db.pexpire('loginAttempts:' + uid, 1000 * 60 * 60);
db.getObjectField('user:' + uid, 'rss_token', next);
},
function (_token, next) {
token = _token || utils.generateUUID();
if (!_token) {
User.setUserField(uid, 'rss_token', token, next);
} else {
next();
}
},
function (next) {
next(null, token);
},
], callback);
};
User.auth.clearLoginAttempts = function (uid) {
db.delete('loginAttempts:' + uid);
};
User.auth.resetLockout = function (uid, callback) {
async.parallel([
async.apply(db.delete, 'loginAttempts:' + uid),
async.apply(db.delete, 'lockout:' + uid),
], callback);
};
User.auth.getSessions = function (uid, curSessionId, callback) {
var _sids;
// curSessionId is optional
if (arguments.length === 2 && typeof curSessionId === 'function') {
callback = curSessionId;
curSessionId = undefined;
} }
// Lock out the account
await db.set('lockout:' + uid, '');
var duration = 1000 * 60 * meta.config.lockoutDuration;
async.waterfall([ await db.delete('loginAttempts:' + uid);
async.apply(db.getSortedSetRevRange, 'uid:' + uid + ':sessions', 0, 19), await db.pexpire('lockout:' + uid, duration);
function (sids, next) { events.log({
_sids = sids; type: 'account-locked',
async.map(sids, db.sessionStore.get.bind(db.sessionStore), next); uid: uid,
}, ip: ip,
function (sessions, next) { });
sessions.forEach(function (sessionObj, idx) { throw new Error('[[error:account-locked]]');
if (sessionObj && sessionObj.meta) {
sessionObj.meta.current = curSessionId === _sids[idx];
}
});
// Revoke any sessions that have expired, return filtered list
var expiredSids = [];
var expired;
sessions = sessions.filter(function (sessionObj, idx) {
expired = !sessionObj || !sessionObj.hasOwnProperty('passport') ||
!sessionObj.passport.hasOwnProperty('user') ||
parseInt(sessionObj.passport.user, 10) !== parseInt(uid, 10);
if (expired) {
expiredSids.push(_sids[idx]);
}
return !expired;
});
async.each(expiredSids, function (sid, next) {
User.auth.revokeSession(sid, uid, next);
}, function (err) {
next(err, sessions);
});
},
function (sessions, next) {
sessions = sessions.map(function (sessObj) {
if (sessObj.meta) {
sessObj.meta.datetimeISO = new Date(sessObj.meta.datetime).toISOString();
sessObj.meta.ip = validator.escape(String(sessObj.meta.ip));
}
return sessObj.meta;
}).filter(Boolean);
next(null, sessions);
},
], callback);
}; };
User.auth.addSession = function (uid, sessionId, callback) { User.auth.getFeedToken = async function (uid) {
callback = callback || function () {};
if (!(parseInt(uid, 10) > 0)) { if (!(parseInt(uid, 10) > 0)) {
return setImmediate(callback); return;
} }
db.sortedSetAdd('uid:' + uid + ':sessions', Date.now(), sessionId, callback); var _token = await db.getObjectField('user:' + uid, 'rss_token');
const token = _token || utils.generateUUID();
if (!_token) {
await User.setUserField(uid, 'rss_token', token);
}
return token;
}; };
User.auth.revokeSession = function (sessionId, uid, callback) { User.auth.clearLoginAttempts = async function (uid) {
await db.delete('loginAttempts:' + uid);
};
User.auth.resetLockout = async function (uid) {
await db.deleteAll([
'loginAttempts:' + uid,
'lockout:' + uid,
]);
};
User.auth.getSessions = async function (uid, curSessionId) {
const sids = await db.getSortedSetRevRange('uid:' + uid + ':sessions', 0, 19);
let sessions = await async.map(sids, db.sessionStore.get.bind(db.sessionStore));
sessions.forEach(function (sessionObj, idx) {
if (sessionObj && sessionObj.meta) {
sessionObj.meta.current = curSessionId === sids[idx];
}
});
// Revoke any sessions that have expired, return filtered list
var expiredSids = [];
var expired;
sessions = sessions.filter(function (sessionObj, idx) {
expired = !sessionObj || !sessionObj.hasOwnProperty('passport') ||
!sessionObj.passport.hasOwnProperty('user') ||
parseInt(sessionObj.passport.user, 10) !== parseInt(uid, 10);
if (expired) {
expiredSids.push(sids[idx]);
}
return !expired;
});
await Promise.all(expiredSids.map(s => User.auth.revokeSession(s, uid)));
sessions = sessions.map(function (sessObj) {
if (sessObj.meta) {
sessObj.meta.datetimeISO = new Date(sessObj.meta.datetime).toISOString();
sessObj.meta.ip = validator.escape(String(sessObj.meta.ip));
}
return sessObj.meta;
}).filter(Boolean);
return sessions;
};
User.auth.addSession = async function (uid, sessionId) {
if (!(parseInt(uid, 10) > 0)) {
return;
}
await db.sortedSetAdd('uid:' + uid + ':sessions', Date.now(), sessionId);
};
const getSessionFromStore = util.promisify(function (sessionId, callback) {
db.sessionStore.get(sessionId, function (err, sessionObj) {
callback(err, sessionObj || null);
});
});
User.auth.revokeSession = async function (sessionId, uid) {
winston.verbose('[user.auth] Revoking session ' + sessionId + ' for user ' + uid); winston.verbose('[user.auth] Revoking session ' + sessionId + ' for user ' + uid);
const sessionObj = await getSessionFromStore(sessionId);
async.waterfall([ if (sessionObj && sessionObj.meta && sessionObj.meta.uuid) {
function (next) { await db.deleteObjectField('uid:' + uid + ':sessionUUID:sessionId', sessionObj.meta.uuid);
db.sessionStore.get(sessionId, function (err, sessionObj) { }
next(err, sessionObj || null); await async.parallel([
}); async.apply(db.sortedSetRemove, 'uid:' + uid + ':sessions', sessionId),
}, async.apply(db.sessionStore.destroy.bind(db.sessionStore), sessionId),
function (sessionObj, next) { ]);
async.parallel([
function (next) {
if (sessionObj && sessionObj.meta && sessionObj.meta.uuid) {
db.deleteObjectField('uid:' + uid + ':sessionUUID:sessionId', sessionObj.meta.uuid, next);
} else {
next();
}
},
async.apply(db.sortedSetRemove, 'uid:' + uid + ':sessions', sessionId),
async.apply(db.sessionStore.destroy.bind(db.sessionStore), sessionId),
], function (err) {
next(err);
});
},
], callback);
}; };
User.auth.revokeAllSessions = function (uid, callback) { User.auth.revokeAllSessions = async function (uid) {
async.waterfall([ const sids = await db.getSortedSetRange('uid:' + uid + ':sessions', 0, -1);
async.apply(db.getSortedSetRange, 'uid:' + uid + ':sessions', 0, -1), const promises = sids.map(s => User.auth.revokeSession(s, uid));
function (sids, next) { await Promise.all(promises);
async.each(sids, function (sid, next) {
User.auth.revokeSession(sid, uid, next);
}, next);
},
], callback);
}; };
User.auth.deleteAllSessions = function (callback) { User.auth.deleteAllSessions = async function () {
var _ = require('lodash'); await batch.processSortedSet('users:joindate', async function (uids) {
batch.processSortedSet('users:joindate', function (uids, next) { const sessionKeys = uids.map(uid => 'uid:' + uid + ':sessions');
var sessionKeys = uids.map(function (uid) { const sessionUUIDKeys = uids.map(uid => 'uid:' + uid + ':sessionUUID:sessionId');
return 'uid:' + uid + ':sessions'; const sids = _.flatten(await db.getSortedSetRange(sessionKeys, 0, -1));
}); await async.parallel([
async.apply(db.deleteAll, sessionKeys.concat(sessionUUIDKeys)),
var sessionUUIDKeys = uids.map(function (uid) {
return 'uid:' + uid + ':sessionUUID:sessionId';
});
async.waterfall([
function (next) { function (next) {
db.getSortedSetRange(sessionKeys, 0, -1, next); async.each(sids, function (sid, next) {
db.sessionStore.destroy(sid, next);
}, next);
}, },
function (sids, next) { ]);
sids = _.flatten(sids); }, { batch: 1000 });
async.parallel([
async.apply(db.deleteAll, sessionUUIDKeys),
async.apply(db.deleteAll, sessionKeys),
function (next) {
async.each(sids, function (sid, next) {
db.sessionStore.destroy(sid, next);
}, next);
},
], next);
},
], next);
}, { batch: 1000 }, callback);
}; };
}; };