From c5c88264f72f759048b96221e745ad0bc43a6541 Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Mon, 22 Oct 2018 21:36:20 -0700 Subject: [PATCH] Implement self-service account deletion --- conf/example/email.toml | 25 ++++ src/bgtask.js | 28 +++- src/configuration/emailconfig.js | 27 ++++ src/controller/email.js | 21 +++ src/database/accounts.js | 72 ++++++++-- src/database/channels.js | 12 ++ src/database/tables.js | 12 ++ src/database/update.js | 15 ++- src/web/routes/account/delete-account.js | 161 +++++++++++++++++++++++ src/web/webserver.js | 9 ++ templates/account-delete.pug | 51 +++++++ templates/account-deleted.pug | 11 ++ templates/nav.pug | 1 + 13 files changed, 426 insertions(+), 19 deletions(-) create mode 100644 src/web/routes/account/delete-account.js create mode 100644 templates/account-delete.pug create mode 100644 templates/account-deleted.pug diff --git a/conf/example/email.toml b/conf/example/email.toml index b6ea66dc..155872dd 100644 --- a/conf/example/email.toml +++ b/conf/example/email.toml @@ -39,3 +39,28 @@ This email address is not monitored for replies. For assistance with password r from = "Example Website " subject = "Password reset request" + +# Email configuration for account deletion request notifications +[delete-account] +enabled = true + +html-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. +""" + +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 " +subject = "Account deletion request" diff --git a/src/bgtask.js b/src/bgtask.js index 0d25acf0..f27b03a1 100644 --- a/src/bgtask.js +++ b/src/bgtask.js @@ -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(); }; diff --git a/src/configuration/emailconfig.js b/src/configuration/emailconfig.js index 7cf0f629..cbe6cd59 100644 --- a/src/configuration/emailconfig.js +++ b/src/configuration/emailconfig.js @@ -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 }; diff --git a/src/controller/email.js b/src/controller/email.js index b3506174..309afe21 100644 --- a/src/controller/email.js +++ b/src/controller/email.js @@ -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 }; diff --git a/src/database/accounts.js b/src/database/accounts.js index d9370d6b..136e4a5f 100644 --- a/src/database/accounts.js +++ b/src/database/accounts.js @@ -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); diff --git a/src/database/channels.js b/src/database/channels.js index c2f13579..0dedced1 100644 --- a/src/database/channels.js +++ b/src/database/channels.js @@ -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 */ diff --git a/src/database/tables.js b/src/database/tables.js index bfa3d513..c73402f8 100644 --- a/src/database/tables.js +++ b/src/database/tables.js @@ -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'); + }); } diff --git a/src/database/update.js b/src/database/update.js index 5b1b914f..3b600966 100644 --- a/src/database/update.js +++ b/src/database/update.js @@ -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(); + } + }); +} diff --git a/src/web/routes/account/delete-account.js b/src/web/routes/account/delete-account.js new file mode 100644 index 00000000..ddb0e715 --- /dev/null +++ b/src/web/routes/account/delete-account.js @@ -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 + ); + } + } +} diff --git a/src/web/webserver.js b/src/web/webserver.js index 0698e677..cb9ef610 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -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); diff --git a/templates/account-delete.pug b/templates/account-delete.pug new file mode 100644 index 00000000..638b5678 --- /dev/null +++ b/templates/account-delete.pug @@ -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  + a(href="/login") log in + |   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  + a(href="/account/channels") delete them + |  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.  + | 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 diff --git a/templates/account-deleted.pug b/templates/account-deleted.pug new file mode 100644 index 00000000..e7c671a9 --- /dev/null +++ b/templates/account-deleted.pug @@ -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. diff --git a/templates/nav.pug b/templates/nav.pug index 03500e8c..0fb212d2 100644 --- a/templates/nav.pug +++ b/templates/nav.pug @@ -19,6 +19,7 @@ mixin navdefaultlinks() li: a(href="/account/channels") Channels li: a(href="/account/profile") Profile li: a(href="/account/edit") Change Password/Email + li: a(href="/account/delete") Delete Account else li: a(href="/login") Login li: a(href="/register") Register