Comparison

plugins/mod_user_account_management.lua @ 13369:13a27043cd0f

mod_user_account_management: Add support for soft-deletion of accounts via IBR When registration_delete_grace_period is set, accounts will be disabled for the specified grace period before they are fully deleted. During the grace period, accounts can be restored with the user:restore() shell command. The primary purpose is to prevent accidental or malicious deletion of a user's account, which is traditionally very easy for any XMPP client to do with a single stanza.
author Matthew Wild <mwild1@gmail.com>
date Thu, 30 Nov 2023 13:48:43 +0000
parent 12977:74b9e05af71e
child 13372:ffbd058bb232
comparison
equal deleted inserted replaced
13368:80a1ce9974e5 13369:13a27043cd0f
6 -- COPYING file in the source package for more information. 6 -- COPYING file in the source package for more information.
7 -- 7 --
8 8
9 9
10 local st = require "prosody.util.stanza"; 10 local st = require "prosody.util.stanza";
11 local usermanager_set_password = require "prosody.core.usermanager".set_password; 11 local usermanager = require "prosody.core.usermanager";
12 local usermanager_delete_user = require "prosody.core.usermanager".delete_user;
13 local nodeprep = require "prosody.util.encodings".stringprep.nodeprep; 12 local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
14 local jid_bare = require "prosody.util.jid".bare; 13 local jid_bare, jid_node = import("prosody.util.jid", "bare", "node");
15 14
16 local compat = module:get_option_boolean("registration_compat", true); 15 local compat = module:get_option_boolean("registration_compat", true);
16 local soft_delete_period = module:get_option_period("registration_delete_grace_period");
17 local deleted_accounts = module:open_store("accounts_cleanup");
17 18
18 module:add_feature("jabber:iq:register"); 19 module:add_feature("jabber:iq:register");
19 20
20 -- Password change and account deletion handler 21 -- Password change and account deletion handler
21 local function handle_registration_stanza(event) 22 local function handle_registration_stanza(event)
32 session.send(reply); 33 session.send(reply);
33 else -- stanza.attr.type == "set" 34 else -- stanza.attr.type == "set"
34 if query.tags[1] and query.tags[1].name == "remove" then 35 if query.tags[1] and query.tags[1].name == "remove" then
35 local username, host = session.username, session.host; 36 local username, host = session.username, session.host;
36 37
38 if host ~= module.host then -- Sanity check for safety
39 module:log("error", "Host mismatch on deletion request (a bug): %s ~= %s", host, module.host);
40 session.send(st.error_reply(stanza, "cancel", "internal-server-error"));
41 return true;
42 end
43
37 -- This one weird trick sends a reply to this stanza before the user is deleted 44 -- This one weird trick sends a reply to this stanza before the user is deleted
38 local old_session_close = session.close; 45 local old_session_close = session.close;
39 session.close = function(self, ...) 46 session.close = function(self, ...)
40 self.send(st.reply(stanza)); 47 self.send(st.reply(stanza));
41 return old_session_close(self, ...); 48 return old_session_close(self, ...);
42 end 49 end
43 50
44 local ok, err = usermanager_delete_user(username, host); 51 if not soft_delete_period then
45 52 local ok, err = usermanager.delete_user(username, host);
46 if not ok then 53
47 log("debug", "Removing user account %s@%s failed: %s", username, host, err); 54 if not ok then
48 session.close = old_session_close; 55 log("debug", "Removing user account %s@%s failed: %s", username, host, err);
49 session.send(st.error_reply(stanza, "cancel", "service-unavailable", err)); 56 session.close = old_session_close;
50 return true; 57 session.send(st.error_reply(stanza, "cancel", "service-unavailable", err));
51 end 58 return true;
52 59 end
53 log("info", "User removed their account: %s@%s", username, host); 60
54 module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session }); 61 log("info", "User removed their account: %s@%s (deleted)", username, host);
62 module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session });
63 else
64 local ok, err = usermanager.disable_user(username, host, {
65 reason = "ibr";
66 comment = "Deletion requested by user";
67 when = os.time();
68 });
69
70 if not ok then
71 log("debug", "Removing (disabling) user account %s@%s failed: %s", username, host, err);
72 session.close = old_session_close;
73 session.send(st.error_reply(stanza, "cancel", "service-unavailable", err));
74 return true;
75 end
76
77 deleted_accounts:set(username, {
78 deleted_at = os.time();
79 pending_until = os.time() + soft_delete_period;
80 client_id = session.client_id;
81 });
82
83 log("info", "User removed their account: %s@%s (disabled, pending deletion)", username, host);
84 end
55 else 85 else
56 local username = query:get_child_text("username"); 86 local username = query:get_child_text("username");
57 local password = query:get_child_text("password"); 87 local password = query:get_child_text("password");
58 if username and password then 88 if username and password then
59 username = nodeprep(username); 89 username = nodeprep(username);
60 if username == session.username then 90 if username == session.username then
61 if usermanager_set_password(username, password, session.host, session.resource) then 91 if usermanager.set_password(username, password, session.host, session.resource) then
62 session.send(st.reply(stanza)); 92 session.send(st.reply(stanza));
63 else 93 else
64 -- TODO unable to write file, file may be locked, etc, what's the correct error? 94 -- TODO unable to write file, file may be locked, etc, what's the correct error?
65 session.send(st.error_reply(stanza, "wait", "internal-server-error")); 95 session.send(st.error_reply(stanza, "wait", "internal-server-error"));
66 end 96 end
83 return handle_registration_stanza(event); 113 return handle_registration_stanza(event);
84 end 114 end
85 end); 115 end);
86 end 116 end
87 117
118 -- This improves UX of soft-deleted accounts by informing the user that the
119 -- account has been deleted, rather than just disabled. They can e.g. contact
120 -- their admin if this was a mistake.
121 module:hook("authentication-failure", function (event)
122 if event.condition ~= "account-disabled" then return; end
123 local session = event.session;
124 local sasl_handler = session and session.sasl_handler;
125 if sasl_handler.username then
126 local status = deleted_accounts:get(sasl_handler.username);
127 if status then
128 event.text = "Account deleted";
129 end
130 end
131 end, -1000);
132
133 function restore_account(username)
134 local pending, pending_err = deleted_accounts:get(username);
135 if not pending then
136 return nil, pending_err or "Account not pending deletion";
137 end
138 local account_info, err = usermanager.get_account_info(username, module.host);
139 if not account_info then
140 return nil, "Couldn't fetch account info: "..err;
141 end
142 local forget_ok, forget_err = deleted_accounts:set(username, nil);
143 if not forget_ok then
144 return nil, "Couldn't remove account from deletion queue: "..forget_err;
145 end
146 local enable_ok, enable_err = usermanager.enable_user(username, module.host);
147 if not enable_ok then
148 return nil, "Removed account from deletion queue, but couldn't enable it: "..enable_err;
149 end
150 return true, "Account restored";
151 end
152
153 local cleanup_time = module:measure("cleanup", "times");
154
155 function cleanup_soft_deleted_accounts()
156 local cleanup_done = cleanup_time();
157 local success, fail, restored, pending = 0, 0, 0, 0;
158
159 for username in deleted_accounts:users() do
160 module:log("debug", "Processing account cleanup for '%s'", username);
161 local account_info, account_info_err = usermanager.get_account_info(username, module.host);
162 if not account_info then
163 module:log("warn", "Unable to process delayed deletion of user '%s': %s", username, account_info_err);
164 fail = fail + 1;
165 else
166 if account_info.enabled == false then
167 local meta = deleted_accounts:get(username);
168 if meta.pending_until <= os.time() then
169 local ok, err = usermanager.delete_user(username, module.host);
170 if not ok then
171 module:log("warn", "Unable to process delayed deletion of user '%s': %s", username, err);
172 fail = fail + 1;
173 else
174 success = success + 1;
175 deleted_accounts:set(username, nil);
176 module:log("debug", "Deleted account '%s' successfully", username);
177 module:fire_event("user-deregistered", { username = username, host = module.host, source = "mod_register" });
178 end
179 else
180 pending = pending + 1;
181 end
182 else
183 module:log("warn", "Account '%s' is not disabled, removing from deletion queue", username);
184 restored = restored + 1;
185 end
186 end
187 end
188
189 module:log("debug", "%d accounts scheduled for future deletion", pending);
190
191 if success > 0 or fail > 0 then
192 module:log("info", "Completed account cleanup - %d accounts deleted (%d failed, %d restored, %d pending)", success, fail, restored, pending);
193 end
194 cleanup_done();
195 end
196
197 module:daily("Remove deleted accounts", cleanup_soft_deleted_accounts);
198
199 --- shell command
200 module:add_item("shell-command", {
201 section = "user";
202 name = "restore";
203 desc = "Restore a user account scheduled for deletion";
204 args = {
205 { name = "jid", type = "string" };
206 };
207 host_selector = "jid";
208 handler = function (self, jid) --luacheck: ignore 212/self
209 return restore_account(jid_node(jid));
210 end;
211 });