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

@ -39,3 +39,28 @@ This email address is not monitored for replies. For assistance with password r
from = "Example Website <website@example.com>" from = "Example Website <website@example.com>"
subject = "Password reset request" subject = "Password reset request"
# Email configuration for account deletion request notifications
[delete-account]
enabled = true
html-template = """
Hi $user$,
<br>
<br>
Account deletion was requested for your account on CHANGE ME. Your account will be automatically deleted in 7 days without any further action from you.
<br>
<br>
This email address is not monitored for replies. For assistance, please <a href="http://example.com/contact">contact an administrator</a>.
"""
text-template = """
Hi $user$,
Account deletion was requested for your account on CHANGE ME. Your account will be automatically deleted in 7 days without any further action from you.
This email address is not monitored for replies. For assistance, please contact an administrator. See http://example.com/contact for contact information.
"""
from = "Example Website <website@example.com>"
subject = "Account deletion request"

View file

@ -14,7 +14,7 @@ const LOGGER = require('@calzoneman/jsli')('bgtask');
var init = null; var init = null;
/* Alias cleanup */ /* Alias cleanup */
function initAliasCleanup(_Server) { function initAliasCleanup() {
var CLEAN_INTERVAL = parseInt(Config.get("aliases.purge-interval")); var CLEAN_INTERVAL = parseInt(Config.get("aliases.purge-interval"));
var CLEAN_EXPIRE = parseInt(Config.get("aliases.max-age")); var CLEAN_EXPIRE = parseInt(Config.get("aliases.max-age"));
@ -28,7 +28,7 @@ function initAliasCleanup(_Server) {
} }
/* Password reset cleanup */ /* Password reset cleanup */
function initPasswordResetCleanup(_Server) { function initPasswordResetCleanup() {
var CLEAN_INTERVAL = 8*60*60*1000; var CLEAN_INTERVAL = 8*60*60*1000;
setInterval(function () { setInterval(function () {
@ -74,6 +74,25 @@ function initChannelDumper(Server) {
}, CHANNEL_SAVE_INTERVAL); }, 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) { module.exports = function (Server) {
if (init === Server) { if (init === Server) {
LOGGER.warn("Attempted to re-init background tasks"); LOGGER.warn("Attempted to re-init background tasks");
@ -81,7 +100,8 @@ module.exports = function (Server) {
} }
init = Server; init = Server;
initAliasCleanup(Server); initAliasCleanup();
initChannelDumper(Server); initChannelDumper(Server);
initPasswordResetCleanup(Server); initPasswordResetCleanup();
initAccountCleanup();
}; };

View file

@ -47,6 +47,29 @@ class EmailConfig {
return reset.subject; 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() { getSmtp() {
@ -56,6 +79,10 @@ class EmailConfig {
getPasswordReset() { getPasswordReset() {
return this._reset; return this._reset;
} }
getDeleteAccount() {
return this._delete;
}
} }
export { EmailConfig }; export { EmailConfig };

View file

@ -26,6 +26,27 @@ class EmailController {
return result; 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 }; export { EmailController };

View file

@ -2,6 +2,7 @@ var $util = require("../utilities");
var bcrypt = require("bcrypt"); var bcrypt = require("bcrypt");
var db = require("../database"); var db = require("../database");
var Config = require("../config"); var Config = require("../config");
import { promisify } from "bluebird";
const LOGGER = require('@calzoneman/jsli')('database/accounts'); const LOGGER = require('@calzoneman/jsli')('database/accounts');
@ -89,7 +90,9 @@ module.exports = {
return; 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) { if (err) {
callback(err, true); callback(err, true);
return; return;
@ -244,7 +247,7 @@ module.exports = {
the hashes match. the hashes match.
*/ */
db.query("SELECT * FROM `users` WHERE name=?", db.query("SELECT * FROM `users` WHERE name=? AND inactive = FALSE",
[name], [name],
function (err, rows) { function (err, rows) {
if (err) { if (err) {
@ -401,7 +404,7 @@ module.exports = {
return; 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) { function (err, rows) {
if (err) { if (err) {
callback(err, null); 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 * 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], db.query("SELECT name,global_rank FROM `users` WHERE `ip`=?", [ip],
callback); 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 * Loads the channel from the database
*/ */

View file

@ -129,4 +129,16 @@ export async function initTables() {
t.index(['ip', 'channel']); t.index(['ip', 'channel']);
t.index(['name', '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 LOGGER = require('@calzoneman/jsli')('database/update');
const DB_VERSION = 11; const DB_VERSION = 12;
var hasUpdates = []; var hasUpdates = [];
module.exports.checkVersion = function () { module.exports.checkVersion = function () {
@ -51,6 +51,8 @@ function update(version, cb) {
addChannelLastLoadedColumn(cb); addChannelLastLoadedColumn(cb);
} else if (version < 11) { } else if (version < 11) {
addChannelOwnerLastSeenColumn(cb); 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('./routes/contact')(app, webConfig);
require('./auth').init(app); require('./auth').init(app);
require('./account').init(app, globalMessageBus, emailConfig, emailController); 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('./acp').init(app, ioConfig);
require('../google2vtt').attach(app); require('../google2vtt').attach(app);
require('./routes/google_drive_userscript')(app); require('./routes/google_drive_userscript')(app);

View file

@ -0,0 +1,51 @@
extends layout.pug
block content
.col-lg-6.col-lg-offset-3.col-md-6.col-md-offset-3
if internalError
h2 Error
p
| Your account deletion request could not be processed due to an internal
| error. Please try again later and ask an administrator for assistance
| if the problem persists.
else if !loggedIn
h2 Authentication Required
p
| You must&nbsp;
a(href="/login") log in
| &nbsp; before requesting deletion of your account.
else if authFailed
h2 Authentication failed
p= reason
else if channelCount > 0
h2 Delete Account
p
| Your account cannot be deleted because you have one or more channels
| registered. In order to delete your account, you must first&nbsp;
a(href="/account/channels") delete them
| &nbsp;or ask an administrator to transfer ownership of these channels
| to another account.
else
h2 Delete Account
p
strong Submitting this form will initiate permanent deletion of your account.&nbsp;
| After 7 days, your account will be permanently deleted and unrecoverable.
| During this time, you will not be able to log in, but you can ask an
| administrator to restore your account if the deletion was requested in error.
| Please confirm your password to continue.
form(action="/account/delete", method="post")
input(type="hidden", name="_csrf", value=csrfToken)
.form-group(class=wrongPassword ? "has-error" : "")
label.control-label(for="password") Password
input#password.form-control(type="password", name="password")
if wrongPassword
p.text-danger.
Password was incorrect
.checkbox
label
input#confirm-delete(type="checkbox", name="confirmed")
| I acknowledge that by submitting this request, my account will be permanently deleted unrecoverably
if missingConfirmation
p.text-danger.
You must check the box to confirm you want to delete your account
button.btn.btn-danger.btn-block(type="submit") Delete Account

View file

@ -0,0 +1,11 @@
extends layout.pug
block content
.col-lg-6.col-lg-offset-3.col-md-6.col-md-offset-3
h2 Account Deleted
p.
Your account has been flagged for deletion. After 7 days, your user data
will be premanently deleted from the database. During this time, you will
not be able to log in, but you can ask an administrator for help if your
deletion request was in error. After 7 days, your account will no longer
be recoverable.

View file

@ -19,6 +19,7 @@ mixin navdefaultlinks()
li: a(href="/account/channels") Channels li: a(href="/account/channels") Channels
li: a(href="/account/profile") Profile li: a(href="/account/profile") Profile
li: a(href="/account/edit") Change Password/Email li: a(href="/account/edit") Change Password/Email
li: a(href="/account/delete") Delete Account
else else
li: a(href="/login") Login li: a(href="/login") Login
li: a(href="/register") Register li: a(href="/register") Register