Implement self-service account deletion

This commit is contained in:
Calvin Montgomery 2018-10-22 21:36:20 -07:00
parent 37c6fa3f79
commit aa2348656d
13 changed files with 426 additions and 19 deletions

View file

@ -14,7 +14,7 @@ const LOGGER = require('@calzoneman/jsli')('bgtask');
var init = null;
/* Alias cleanup */
function initAliasCleanup(_Server) {
function initAliasCleanup() {
var CLEAN_INTERVAL = parseInt(Config.get("aliases.purge-interval"));
var CLEAN_EXPIRE = parseInt(Config.get("aliases.max-age"));
@ -28,7 +28,7 @@ function initAliasCleanup(_Server) {
}
/* Password reset cleanup */
function initPasswordResetCleanup(_Server) {
function initPasswordResetCleanup() {
var CLEAN_INTERVAL = 8*60*60*1000;
setInterval(function () {
@ -74,6 +74,25 @@ function initChannelDumper(Server) {
}, CHANNEL_SAVE_INTERVAL);
}
function initAccountCleanup() {
setInterval(() => {
(async () => {
let rows = await db.users.findAccountsPendingDeletion();
for (let row of rows) {
try {
await db.users.purgeAccount(row.id);
LOGGER.info('Purged account from request %j', row);
} catch (error) {
LOGGER.error('Error purging account %j: %s', row, error.stack);
}
}
})().catch(error => {
LOGGER.error('Error purging deleted accounts: %s', error.stack);
});
//}, 3600 * 1000);
}, 60 * 1000);
}
module.exports = function (Server) {
if (init === Server) {
LOGGER.warn("Attempted to re-init background tasks");
@ -81,7 +100,8 @@ module.exports = function (Server) {
}
init = Server;
initAliasCleanup(Server);
initAliasCleanup();
initChannelDumper(Server);
initPasswordResetCleanup(Server);
initPasswordResetCleanup();
initAccountCleanup();
};

View file

@ -47,6 +47,29 @@ class EmailConfig {
return reset.subject;
}
};
const deleteAccount = config['delete-account'];
this._delete = {
isEnabled() {
return deleteAccount !== null && deleteAccount.enabled;
},
getHTML() {
return deleteAccount['html-template'];
},
getText() {
return deleteAccount['text-template'];
},
getFrom() {
return deleteAccount.from;
},
getSubject() {
return deleteAccount.subject;
}
};
}
getSmtp() {
@ -56,6 +79,10 @@ class EmailConfig {
getPasswordReset() {
return this._reset;
}
getDeleteAccount() {
return this._delete;
}
}
export { EmailConfig };

View file

@ -26,6 +26,27 @@ class EmailController {
return result;
}
async sendAccountDeletion(params = {}) {
const { address, username } = params;
const deleteConfig = this.config.getDeleteAccount();
const html = deleteConfig.getHTML()
.replace(/\$user\$/g, username);
const text = deleteConfig.getText()
.replace(/\$user\$/g, username);
const result = await this.mailer.sendMail({
from: deleteConfig.getFrom(),
to: `${username} <${address}>`,
subject: deleteConfig.getSubject(),
html,
text
});
return result;
}
}
export { EmailController };

View file

@ -2,6 +2,7 @@ var $util = require("../utilities");
var bcrypt = require("bcrypt");
var db = require("../database");
var Config = require("../config");
import { promisify } from "bluebird";
const LOGGER = require('@calzoneman/jsli')('database/accounts');
@ -89,7 +90,9 @@ module.exports = {
return;
}
db.query("SELECT * FROM `users` WHERE name = ?", [name], function (err, rows) {
db.query("SELECT * FROM `users` WHERE name = ? AND inactive = FALSE",
[name],
function (err, rows) {
if (err) {
callback(err, true);
return;
@ -244,7 +247,7 @@ module.exports = {
the hashes match.
*/
db.query("SELECT * FROM `users` WHERE name=?",
db.query("SELECT * FROM `users` WHERE name=? AND inactive = FALSE",
[name],
function (err, rows) {
if (err) {
@ -401,7 +404,7 @@ module.exports = {
return;
}
db.query("SELECT email FROM `users` WHERE name=?", [name],
db.query("SELECT email FROM `users` WHERE name=? AND inactive = FALSE", [name],
function (err, rows) {
if (err) {
callback(err, null);
@ -519,17 +522,6 @@ module.exports = {
});
},
/**
* Retrieve a list of channels owned by a user
*/
getChannels: function (name, callback) {
if (typeof callback !== "function") {
return;
}
db.query("SELECT * FROM `channels` WHERE owner=?", [name], callback);
},
/**
* Retrieves all names registered from a given IP
*/
@ -540,5 +532,57 @@ module.exports = {
db.query("SELECT name,global_rank FROM `users` WHERE `ip`=?", [ip],
callback);
},
requestAccountDeletion: id => {
return db.getDB().runTransaction(async tx => {
try {
let user = await tx.table('users').where({ id }).first();
await tx.table('user_deletion_requests')
.insert({
user_id: id
});
await tx.table('users')
.where({ id })
.update({ password: '', inactive: true });
// TODO: ideally password reset should be by user_id and not name
// For now, we need to make sure to clear it
await tx.table('password_reset')
.where({ name: user.name })
.delete();
} catch (error) {
// Ignore unique violation -- probably caused by a duplicate request
if (error.code !== 'ER_DUP_ENTRY') {
throw error;
}
}
});
},
findAccountsPendingDeletion: () => {
return db.getDB().runTransaction(tx => {
let lastWeek = new Date(Date.now() - 7 * 24 * 3600 * 1000);
return tx.table('user_deletion_requests')
.where('user_deletion_requests.created_at', '<', lastWeek)
.join('users', 'user_deletion_requests.user_id', '=', 'users.id')
.select('users.id', 'users.name');
});
},
purgeAccount: id => {
return db.getDB().runTransaction(async tx => {
let user = await tx.table('users').where({ id }).first();
if (!user) {
return false;
}
await tx.table('channel_ranks').where({ name: user.name }).delete();
await tx.table('user_playlists').where({ user: user.name }).delete();
await tx.table('users').where({ id }).delete();
return true;
});
}
};
module.exports.verifyLoginAsync = promisify(module.exports.verifyLogin);

View file

@ -230,6 +230,18 @@ module.exports = {
});
},
listUserChannelsAsync: owner => {
return new Promise((resolve, reject) => {
module.exports.listUserChannels(owner, (error, rows) => {
if (error) {
reject(error);
} else {
resolve(rows);
}
});
});
},
/**
* Loads the channel from the database
*/

View file

@ -129,4 +129,16 @@ export async function initTables() {
t.index(['ip', 'channel']);
t.index(['name', 'channel']);
});
await ensureTable('user_deletion_requests', t => {
t.increments('request_id').notNullable().primary();
t.integer('user_id')
.unsigned()
.notNullable()
.references('id').inTable('users')
.onDelete('cascade')
.unique();
t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true);
t.index('created_at');
});
}

View file

@ -3,7 +3,7 @@ import Promise from 'bluebird';
const LOGGER = require('@calzoneman/jsli')('database/update');
const DB_VERSION = 11;
const DB_VERSION = 12;
var hasUpdates = [];
module.exports.checkVersion = function () {
@ -51,6 +51,8 @@ function update(version, cb) {
addChannelLastLoadedColumn(cb);
} else if (version < 11) {
addChannelOwnerLastSeenColumn(cb);
} else if (version < 12) {
addUserInactiveColumn(cb);
}
}
@ -128,3 +130,14 @@ function addChannelOwnerLastSeenColumn(cb) {
});
});
}
function addUserInactiveColumn(cb) {
db.query("ALTER TABLE users ADD COLUMN inactive BOOLEAN DEFAULT FALSE", error => {
if (error) {
LOGGER.error(`Failed to add inactive column: ${error}`);
cb(error);
} else {
cb();
}
});
}

View file

@ -0,0 +1,161 @@
import { sendPug } from '../../pug';
import Config from '../../../config';
import { eventlog } from '../../../logger';
const verifySessionAsync = require('bluebird').promisify(
require('../../../session').verifySession
);
const LOGGER = require('@calzoneman/jsli')('web/routes/account/delete-account');
export default function initialize(
app,
csrfVerify,
channelDb,
userDb,
emailConfig,
emailController
) {
app.get('/account/delete', async (req, res) => {
if (!await authorize(req, res)) {
return;
}
await showDeletePage(res, {});
});
app.post('/account/delete', async (req, res) => {
if (!await authorize(req, res)) {
return;
}
csrfVerify(req);
if (!req.body.confirmed) {
await showDeletePage(res, { missingConfirmation: true });
return;
}
let user;
try {
user = await userDb.verifyLoginAsync(res.locals.loginName, req.body.password);
} catch (error) {
if (error.message === 'Invalid username/password combination') {
res.status(403);
await showDeletePage(res, { wrongPassword: true });
} else if (error.message === 'User does not exist' ||
error.message.match(/Invalid username/)) {
LOGGER.error('User does not exist after authorization');
res.status(503);
await showDeletePage(res, { internalError: true });
} else {
res.status(503);
LOGGER.error('Unknown error in verifyLogin: %s', error.stack);
await showDeletePage(res, { internalError: true });
}
return;
}
try {
let channels = await channelDb.listUserChannelsAsync(user.name);
if (channels.length > 0) {
await showDeletePage(res, { channelCount: channels.length });
return;
}
} catch (error) {
LOGGER.error('Unknown error in listUserChannels: %s', error.stack);
await showDeletePage(res, { internalError: true });
}
try {
await userDb.requestAccountDeletion(user.id);
eventlog.log(`[account] ${req.ip} requested account deletion for ${user.name}`);
} catch (error) {
LOGGER.error('Unknown error in requestAccountDeletion: %s', error.stack);
await showDeletePage(res, { internalError: true });
}
if (emailConfig.getDeleteAccount().isEnabled() && user.email) {
await sendEmail(user);
} else {
LOGGER.warn(
'Skipping account deletion email notification for %s',
user.name
);
}
res.clearCookie('auth', { domain: Config.get('http.root-domain-dotted') });
res.locals.loggedIn = false;
res.locals.loginName = null;
sendPug(
res,
'account-deleted',
{}
);
});
async function showDeletePage(res, flags) {
let locals = Object.assign({ channelCount: 0 }, flags);
if (res.locals.loggedIn) {
let channels = await channelDb.listUserChannelsAsync(
res.locals.loginName
);
locals.channelCount = channels.length;
} else {
res.status(401);
}
sendPug(
res,
'account-delete',
locals
);
}
async function authorize(req, res) {
try {
if (!res.locals.loggedIn) {
res.status(401);
await showDeletePage(res, {});
return;
}
if (!req.signedCookies || !req.signedCookies.auth) {
throw new Error('Missing auth cookie');
}
await verifySessionAsync(req.signedCookies.auth);
return true;
} catch (error) {
res.status(401);
sendPug(
res,
'account-delete',
{ authFailed: true, reason: error.message }
);
return false;
}
}
async function sendEmail(user) {
LOGGER.info(
'Sending email notification for account deletion %s <%s>',
user.name,
user.email
);
try {
await emailController.sendAccountDeletion({
username: user.name,
address: user.email
});
} catch (error) {
LOGGER.error(
'Sending email notification failed for %s <%s>: %s',
user.name,
user.email,
error.stack
);
}
}
}

View file

@ -203,6 +203,15 @@ module.exports = {
require('./routes/contact')(app, webConfig);
require('./auth').init(app);
require('./account').init(app, globalMessageBus, emailConfig, emailController);
require('./routes/account/delete-account')(
app,
csrf.verify,
require('../database/channels'),
require('../database/accounts'),
emailConfig,
emailController
);
require('./acp').init(app, ioConfig);
require('../google2vtt').attach(app);
require('./routes/google_drive_userscript')(app);