Mercurial > libervia-backend
view src/plugins/plugin_misc_account.py @ 972:07b817f5a197
core: better plugin initialisation sequence:
- profileConnected are now executed in a deferredList, instead of one after the other
- failure are collected, and a global message show which plugins failed and the failure message
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 02 Apr 2014 12:31:23 +0200 |
parents | 5925c9ebfcd2 |
children | 3a96920c07b7 |
line wrap: on
line source
#!/usr/bin/python # -*- coding: utf-8 -*- # SAT plugin for account creation (experimental) # Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from sat.core.i18n import _, D_ from logging import debug, info, warning, error from sat.core import exceptions from twisted.internet import reactor, defer, protocol from os.path import join, dirname from twisted.python.procutils import which from twisted.python.failure import Failure from email.mime.text import MIMEText from twisted.mail.smtp import sendmail from sat.tools import xml_tools PLUGIN_INFO = { "name": "Account Plugin", "import_name": "MISC-ACCOUNT", "type": "MISC", "protocols": [], "dependencies": [], "recommendations": ['GROUPBLOG'], "main": "MiscAccount", "handler": "no", "description": _(u"""SàT account creation""") } CONFIG_SECTION = "plugin account" # You need do adapt the following consts to your server # all theses values (key=option name, value=default) can (and should) be overriden in sat.conf # in section CONFIG_SECTION default_conf = {"email_from": "NOREPLY@example.net", "email_server": "localhost", "admin_email": "admin@example.net", "new_account_server": "localhost", "new_account_domain": "example.net", "new_account_resource": "libervia", "prosody_path": None, # prosody path (where prosodyctl will be executed from), or None to automaticaly find it "prosodyctl": "prosodyctl", "reserved_list": ['libervia'] # profiles which can't be used } class PasswordsMatchingError(Exception): pass class ProsodyRegisterProtocol(protocol.ProcessProtocol): """ Try to register an account with prosody """ def __init__(self, password=None, deferred=None): """ @param password: new user password @param deferred """ self.password = password self.deferred = deferred self.data = '' def connectionMade(self): if self.password is None: return self.transport.write("%s\n%s" % ((self.password.encode('utf-8'),) * 2)) self.transport.closeStdin() def outReceived(self, data): self.data += data def errReceived(self, data): self.data += data def processEnded(self, reason): if (reason.value.exitCode == 0): info(_('Prosody command succeed')) self.deferred.callback(None) else: error(_(u"Can't complete Prosody command (error code: %(code)d): %(message)s") % {'code': reason.value.exitCode, 'message': self.data}) self.deferred.errback(Failure(exceptions.InternalError)) @classmethod def prosodyctl(cls, plugin, command, password=None, profile=None): """Create a new ProsodyRegisterProtocol and execute the given prosodyctl command. @param plugin: instance of MiscAccount @param command: the command to execute: "adduser", "passwd" or "deluser" @param password: the user new password (leave to None for "deluser" command) @param profile: the user profile @return a Deferred instance """ d = defer.Deferred() prosody_reg = ProsodyRegisterProtocol(password, d) prosody_exe = join(plugin._prosody_path, plugin.getConfig('prosodyctl')) # TODO delete account which are not on the same host reactor.spawnProcess(prosody_reg, prosody_exe, [prosody_exe, command, "%s@%s" % (profile, plugin.getConfig('new_account_domain'))], path=plugin._prosody_path) return d class MiscAccount(object): """Account plugin: create a SàT + Prosody account, used by Libervia""" #XXX: This plugin is a Q&D one used for the demo. Something more generic (and not # only focused on Prosody) is planed def __init__(self, host): info(_(u"Plugin Account initialization")) self.host = host host.bridge.addMethod("registerSatAccount", ".plugin", in_sign='sss', out_sign='', method=self._registerAccount, async=True) host.bridge.addMethod("getNewAccountDomain", ".plugin", in_sign='', out_sign='s', method=self._getNewAccountDomain, async=False) host.bridge.addMethod("getAccountDialogUI", ".plugin", in_sign='s', out_sign='s', method=self._getAccountDialogUI, async=False) self._prosody_path = self.getConfig('prosody_path') if self._prosody_path is None: paths = which(self.getConfig('prosodyctl')) if not paths: error(_("Can't find %s") % (self.getConfig('prosodyctl'), )) else: self._prosody_path = dirname(paths[0]) info(_('Prosody path found: %s') % (self._prosody_path, )) self.__account_cb_id = host.registerCallback(self._accountDialogCb, with_data=True) self.__delete_account_id = host.registerCallback(self.__deleteAccountCb, with_data=True) def deleteBlogCallback(posts, comments): return lambda data, profile: self.__deleteBlogPostsCb(posts, comments, data, profile) self.__delete_posts_comments_id = host.registerCallback(deleteBlogCallback(True, True), with_data=True) self.__delete_posts_id = host.registerCallback(deleteBlogCallback(True, False), with_data=True) self.__delete_comments_id = host.registerCallback(deleteBlogCallback(False, True), with_data=True) def getConfig(self, name): return self.host.memory.getConfig(CONFIG_SECTION, name) or default_conf[name] def _registerAccount(self, email, password, profile): """ #Password Generation #_charset = [chr(i) for i in range(0x21,0x7F)] #XXX: this charset seems to have some issues with openfire _charset = [chr(i) for i in range(0x30,0x3A) + range(0x41,0x5B) + range (0x61,0x7B)] import random random.seed() password = ''.join([random.choice(_charset) for i in range(15)]) """ if not email or not password or not profile: raise exceptions.DataError if profile.lower() in self.getConfig('reserved_list'): return defer.fail(Failure(exceptions.ConflictError)) d = self.host.memory.asyncCreateProfile(profile) d.addCallback(self._profileRegistered, email, password, profile) return d def _profileRegistered(self, result, email, password, profile): #FIXME: values must be in a config file instead of hardcoded self.host.memory.setParam("JabberID", "%s@%s/%s" % (profile, self.getConfig('new_account_domain'), self.getConfig('new_account_resource')), "Connection", profile_key=profile) self.host.memory.setParam("Server", self.getConfig('new_account_server'), "Connection", profile_key=profile) self.host.memory.setParam("Password", password, "Connection", profile_key=profile) #and the account #XXX: we use "prosodyctl adduser" because "register" doesn't check conflict # and just change the password if the account already exists d = ProsodyRegisterProtocol.prosodyctl(self, 'adduser', password, profile) d.addCallback(self._sendEmails, profile, email, password) d.addCallback(lambda ignore: None) return d def _sendEmails(self, result, login, email, password): #time to send the email _email_host = self.getConfig('email_server') _email_from = self.getConfig("email_from") def email_ok(ignore): print ("Account creation email sent to %s" % email) def email_ko(ignore): #TODO: return error code to user error("Failed to send email to %s" % email) body = (u"""Welcome to Libervia, a Salut à Toi project part /!\\ WARNING, THIS IS ONLY A TECHNICAL DEMO, DON'T USE THIS ACCOUNT FOR ANY SERIOUS PURPOSE /!\\ Here are your connection informations: --- login: %(login)s password: %(password)s Your Jabber ID (JID) is: %(jid)s --- To try with some contacts, you can use the directory: subscribe in your parameters, and use it with the Communication/Search directory menu. SàT website: http://www.salut-a-toi.org follow SàT news: http://www.goffi.org Any feedback welcome Cheers SàT team""" % {'login': login, 'password': password, 'jid': "%s@%s" % (login, self.getConfig('new_account_domain'))}).encode('utf-8') msg = MIMEText(body, 'plain', 'UTF-8') msg['Subject'] = 'Libervia account created' msg['From'] = _email_from msg['To'] = email d_user = sendmail(_email_host, _email_from, email, msg.as_string()) d_user.addCallbacks(email_ok, email_ko) #email to the administrator body = (u"""New account created: %(login)s [%(email)s]""" % {'login': login, 'email': email}).encode('utf-8') msg = MIMEText(body, 'plain', 'UTF-8') msg['Subject'] = 'Libervia new account created' msg['From'] = _email_from msg['To'] = self.getConfig('admin_email') d_admin = sendmail(_email_host, _email_from, self.getConfig('admin_email'), msg.as_string()) d_admin.addCallbacks(email_ok, email_ko) return defer.DeferredList([d_user, d_admin]) def _getNewAccountDomain(self): """@return: the domain that will be set to new account""" return self.getConfig('new_account_domain') def _getAccountDialogUI(self, profile): """Get the main dialog to manage your account @param menu_data @param profile: %(doc_profile)s @return XML of the dialog """ form_ui = xml_tools.XMLUI("form", "tabs", title=D_("Manage your XMPP account"), submit_id=self.__account_cb_id) tab_container = form_ui.current_container tab_container.addTab("update", D_("Change your password"), container=xml_tools.PairsContainer) form_ui.addLabel(D_("Current password")) form_ui.addPassword("current_passwd", value="") form_ui.addLabel(D_("New password")) form_ui.addPassword("new_passwd1", value="") form_ui.addLabel(D_("New password (again)")) form_ui.addPassword("new_passwd2", value="") if 'GROUPBLOG' in self.host.plugins: tab_container.addTab("delete_posts", D_("Delete your posts"), container=xml_tools.PairsContainer) form_ui.addLabel(D_("Current password")) form_ui.addPassword("delete_posts_passwd", value="") form_ui.addLabel(D_("Delete all your posts and their comments")) form_ui.addBool("delete_posts_checkbox", "false") form_ui.addLabel(D_("Delete all your comments on other's posts")) form_ui.addBool("delete_comments_checkbox", "false") tab_container.addTab("delete", D_("Delete your account"), container=xml_tools.PairsContainer) form_ui.addLabel(D_("Current password")) form_ui.addPassword("delete_passwd", value="") form_ui.addLabel(D_("Delete your account")) form_ui.addBool("delete_checkbox", "false") return form_ui.toXml() def _accountDialogCb(self, data, profile): """Called when the user submits the main account dialog @param data @param profile """ password = self.host.memory.getParamA("Password", "Connection", profile_key=profile) def error_ui(): error_ui = xml_tools.XMLUI("popup", title=D_("Error")) error_ui.addText(D_("Passwords don't match!")) return defer.succeed({'xmlui': error_ui.toXml()}) # check for account deletion delete_passwd = data[xml_tools.SAT_FORM_PREFIX + 'delete_passwd'] delete_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_checkbox'] if delete_checkbox == 'true': if password == delete_passwd: return self.__deleteAccount(profile) return error_ui() # check for blog posts deletion if 'GROUPBLOG' in self.host.plugins: delete_posts_passwd = data[xml_tools.SAT_FORM_PREFIX + 'delete_posts_passwd'] delete_posts_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_posts_checkbox'] delete_comments_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_comments_checkbox'] posts = delete_posts_checkbox == 'true' comments = delete_comments_checkbox == 'true' if posts or comments: if password == delete_posts_passwd: return self.__deleteBlogPosts(posts, comments, profile) return error_ui() # check for password modification current_passwd = data[xml_tools.SAT_FORM_PREFIX + 'current_passwd'] new_passwd1 = data[xml_tools.SAT_FORM_PREFIX + 'new_passwd1'] new_passwd2 = data[xml_tools.SAT_FORM_PREFIX + 'new_passwd2'] if new_passwd1 or new_passwd2: if password == current_passwd and new_passwd1 == new_passwd2: return self.__changePassword(new_passwd1, profile=profile) return error_ui() return defer.succeed({}) def __changePassword(self, password, profile): """Actually change the user XMPP account and SàT profile password @param password: new password @profile """ def passwordChanged(result): self.host.memory.setParam("Password", password, "Connection", profile_key=profile) confirm_ui = xml_tools.XMLUI("popup", title=D_("Confirmation")) confirm_ui.addText(D_("Your password has been changed.")) return defer.succeed({'xmlui': confirm_ui.toXml()}) def errback(failure): error_ui = xml_tools.XMLUI("popup", title=D_("Error")) error_ui.addText(D_("Your password could not be changed: %s") % failure.getErrorMessage()) return defer.succeed({'xmlui': error_ui.toXml()}) d = ProsodyRegisterProtocol.prosodyctl(self, 'passwd', password, profile=profile) d.addCallbacks(passwordChanged, errback) return d def __deleteAccount(self, profile): """Ask for a confirmation before deleting the XMPP account and SàT profile @param profile """ form_ui = xml_tools.XMLUI("form", title=D_("Delete your account?"), submit_id=self.__delete_account_id) form_ui.addText(D_("If you confirm this dialog, you will be disconnected and then your XMPP account AND your SàT profile will both be DELETED.")) target = D_('contact list, messages history, blog posts and comments' if 'GROUPBLOG' in self.host.plugins else D_('contact list and messages history')) form_ui.addText(D_("All your data stored on %(server)s, including your %(target)s will be erased.") % {'server': self._getNewAccountDomain(), 'target': target}) form_ui.addText(D_("There is no other confirmation dialog, this is the very last one! Are you sure?")) return {'xmlui': form_ui.toXml()} def __deleteAccountCb(self, data, profile): """Actually delete the XMPP account and SàT profile @param data @param profile """ def userDeleted(result): client = self.host.profiles[profile] for jid_ in client.roster._jids: # empty roster client.presence.unsubscribe(jid_) for jid_ in self.host.memory.getWaitingSub(profile): # delete waiting subscriptions self.host.memory.delWaitingSub(jid_) delete_profile = lambda: self.host.memory.asyncDeleteProfile(profile, force=True) if 'GROUPBLOG' in self.host.plugins: d = self.host.plugins['GROUPBLOG'].deleteAllGroupBlogsAndComments(profile_key=profile) d.addCallback(lambda dummy: delete_profile()) else: delete_profile() return defer.succeed({}) def errback(failure): error_ui = xml_tools.XMLUI("popup", title=D_("Error")) error_ui.addText(D_("Your XMPP account could not be deleted: %s") % failure.getErrorMessage()) return defer.succeed({'xmlui': error_ui.toXml()}) d = ProsodyRegisterProtocol.prosodyctl(self, 'deluser', profile=profile) d.addCallbacks(userDeleted, errback) return d def __deleteBlogPosts(self, posts, comments, profile): """Ask for a confirmation before deleting the blog posts @param posts: delete all posts of the user (and their comments) @param comments: delete all the comments of the user on other's posts @param data @param profile """ if posts: if comments: # delete everything form_ui = xml_tools.XMLUI("form", title=D_("Delete all your (micro-)blog posts and comments?"), submit_id=self.__delete_posts_comments_id) form_ui.addText(D_("If you confirm this dialog, all the (micro-)blog data you submitted will be erased.")) form_ui.addText(D_("These are the public and private posts and comments you sent to any group.")) form_ui.addText(D_("There is no other confirmation dialog, this is the very last one! Are you sure?")) else: # delete only the posts form_ui = xml_tools.XMLUI("form", title=D_("Delete all your (micro-)blog posts?"), submit_id=self.__delete_posts_id) form_ui.addText(D_("If you confirm this dialog, all the public and private posts you sent to any group will be erased.")) form_ui.addText(D_("There is no other confirmation dialog, this is the very last one! Are you sure?")) elif comments: # delete only the comments form_ui = xml_tools.XMLUI("form", title=D_("Delete all your (micro-)blog comments?"), submit_id=self.__delete_comments_id) form_ui.addText(D_("If you confirm this dialog, all the public and private comments you made on other people's posts will be erased.")) form_ui.addText(D_("There is no other confirmation dialog, this is the very last one! Are you sure?")) return {'xmlui': form_ui.toXml()} def __deleteBlogPostsCb(self, posts, comments, data, profile): """Actually delete the XMPP account and SàT profile @param posts: delete all posts of the user (and their comments) @param comments: delete all the comments of the user on other's posts @param profile """ if posts: if comments: target = D_('blog posts and comments') d = self.host.plugins['GROUPBLOG'].deleteAllGroupBlogsAndComments(profile_key=profile) else: target = D_('blog posts') d = self.host.plugins['GROUPBLOG'].deleteAllGroupBlogs(profile_key=profile) elif comments: target = D_('comments') d = self.host.plugins['GROUPBLOG'].deleteAllGroupBlogsComments(profile_key=profile) def deleted(result): ui = xml_tools.XMLUI("popup", title=D_("Deletion confirmation")) # TODO: change the message when delete/retract notifications are done with XEP-0060 ui.addText(D_("Your %(target)s have been deleted.") % {'target': target}) ui.addText(D_("Known issue of the demo version: you need to refresh the page to make the deleted posts actually disappear.")) return defer.succeed({'xmlui': ui.toXml()}) def errback(failure): error_ui = xml_tools.XMLUI("popup", title=D_("Error")) error_ui.addText(D_("Your %(target)s could not be deleted: %(message)s") % {'target': target, 'message': failure.getErrorMessage()}) return defer.succeed({'xmlui': error_ui.toXml()}) d.addCallbacks(deleted, errback) return d