|
| 1 | +""" |
| 2 | +For resetting multiple user account passwords (by sending a password reset email |
| 3 | +to their account's configured address) |
| 4 | +""" |
| 5 | +from argparse import ArgumentParser |
| 6 | +import logging |
| 7 | +import os |
| 8 | +from smtplib import SMTPException |
| 9 | +from smtplib import SMTPRecipientsRefused |
| 10 | + |
| 11 | +from AccessControl.SecurityManagement import newSecurityManager |
| 12 | +from chameleon import PageTemplate |
| 13 | +from plone import api |
| 14 | +from plone.registry.interfaces import IRegistry |
| 15 | +from Products.CMFCore.utils import getToolByName |
| 16 | +from Products.CMFPlone.interfaces.siteroot import IPloneSiteRoot |
| 17 | +from tendo import singleton |
| 18 | +from zope.component import getUtility |
| 19 | +from zope.component.hooks import setSite |
| 20 | + |
| 21 | +import transaction |
| 22 | + |
| 23 | +# ripped from https://github.com/plone/Products.CMFPlone/blob/master/Products/CMFPlone/RegistrationTool.py |
| 24 | +try: |
| 25 | + # Products.MailHost has a patch to fix quoted-printable soft line breaks. |
| 26 | + # See https://github.com/zopefoundation/Products.MailHost/issues/35 |
| 27 | + from Products.MailHost.MailHost import message_from_string |
| 28 | +except ImportError: |
| 29 | + # If the patch is ever removed, we fall back to the standard library. |
| 30 | + from email import message_from_string |
| 31 | + |
| 32 | + |
| 33 | +logger = logging.getLogger("Plone") |
| 34 | + |
| 35 | + |
| 36 | +def get_args(): |
| 37 | + parser = ArgumentParser( |
| 38 | + description='Get a report of permissions by role and user') |
| 39 | + parser.add_argument('--site-id', dest='site_id', default='Castle') |
| 40 | + parser.add_argument('--users-file', dest='users_file', default=None) |
| 41 | + parser.add_argument('--ident-field', dest='ident_field', choices=['userid', 'username'], default='username') # noqa |
| 42 | + parser.add_argument('--base-url', dest='base_url', default=None) # noqa |
| 43 | + args, _ = parser.parse_known_args() |
| 44 | + assert args.base_url is not None |
| 45 | + assert args.users_file is not None |
| 46 | + return args |
| 47 | + |
| 48 | + |
| 49 | +mail_password_reset_template_str = """<tal:root |
| 50 | +>From: <span tal:replace="structure encoded_mail_sender" /> |
| 51 | +To: <span tal:replace="python:member.getProperty('email')" /> |
| 52 | +Subject: <span tal:replace="mail_password_subject" /> |
| 53 | +Content-Type: text/html |
| 54 | +Precedence: bulk |
| 55 | +
|
| 56 | +<p> |
| 57 | + The following link will take you to a page where you can reset your password for <span |
| 58 | + tal:omit-tag="" |
| 59 | + tal:content="navigation_root_title" /> site: |
| 60 | +
|
| 61 | + <a href="${base_url}/@@password-reset?code=${reset.randomstring}&userid=${member.id}"> |
| 62 | + ${base_url}/@@password-reset?code=${reset.randomstring}&userid=${member.id} |
| 63 | + </a> |
| 64 | +</p> |
| 65 | +
|
| 66 | +<p> |
| 67 | + (This link is valid for <span tal:content="expiration_timeout" tal:omit-tag="" /> hours) |
| 68 | +</p> |
| 69 | +
|
| 70 | +<p> |
| 71 | + If you didn't expect to receive this email, please ignore it. Your password has not been changed. |
| 72 | +</p> |
| 73 | +
|
| 74 | +</tal:root> |
| 75 | +""" |
| 76 | +mail_password_reset_template = PageTemplate(mail_password_reset_template_str) |
| 77 | + |
| 78 | + |
| 79 | +# basically ripped from https://github.com/plone/Products.CMFPlone/blob/master/Products/CMFPlone/RegistrationTool.py # noqa |
| 80 | +def mail_password(site, req, member, base_url): |
| 81 | + # Rather than have the template try to use the mailhost, we will |
| 82 | + # render the message ourselves and send it from here (where we |
| 83 | + # don't need to worry about 'UseMailHost' permissions). |
| 84 | + reset_tool = getToolByName(site, "portal_password_reset") |
| 85 | + reset = reset_tool.requestReset(member.getId()) |
| 86 | + |
| 87 | + registry = getUtility(IRegistry) |
| 88 | + |
| 89 | + encoding = registry.get("plone.email_charset", "utf-8") |
| 90 | + |
| 91 | + kwargs = { |
| 92 | + "base_url": base_url, |
| 93 | + "reset": reset, |
| 94 | + "password": member.getPassword(), |
| 95 | + "charset": encoding, |
| 96 | + "member": member, |
| 97 | + "navigation_root_title": site.Title(), |
| 98 | + "expiration_timeout": reset_tool.getExpirationTimeout(), |
| 99 | + "encoded_mail_sender": api.portal.get_registry_record('plone.email_from_address', default=''), |
| 100 | + "mail_password_subject": "Password reset request", |
| 101 | + } |
| 102 | + |
| 103 | + mail_text = mail_password_reset_template(**kwargs) |
| 104 | + |
| 105 | + # The mail headers are not properly encoded we need to extract |
| 106 | + # them and let MailHost manage the encoding. |
| 107 | + message_obj = message_from_string(mail_text.strip()) |
| 108 | + subject = message_obj["Subject"] |
| 109 | + m_to = message_obj["To"] |
| 110 | + m_from = message_obj["From"] |
| 111 | + msg_type = message_obj.get("Content-Type", "text/plain") |
| 112 | + host = getToolByName(site, "MailHost") |
| 113 | + try: |
| 114 | + host.send( |
| 115 | + mail_text, |
| 116 | + m_to, |
| 117 | + m_from, |
| 118 | + subject=subject, |
| 119 | + charset=encoding, |
| 120 | + immediate=True, |
| 121 | + msg_type=msg_type, |
| 122 | + ) |
| 123 | + except SMTPRecipientsRefused: |
| 124 | + # Don't disclose email address on failure |
| 125 | + raise SMTPRecipientsRefused("Recipient address rejected by server.") |
| 126 | + except SMTPException as e: |
| 127 | + raise (e) |
| 128 | + |
| 129 | + |
| 130 | +def send_reset_emails(site, users_file, ident_field, req, base_url): |
| 131 | + regtool = api.portal.get_tool('portal_registration') |
| 132 | + acl_users = api.portal.get_tool('acl_users') |
| 133 | + with open(users_file, 'r') as fin: |
| 134 | + for ident in fin.readlines(): |
| 135 | + kwargs = {} |
| 136 | + kwargs[ident_field] = ident.strip() |
| 137 | + member = api.user.get(**kwargs) |
| 138 | + if member is None: |
| 139 | + logger.warn("not found (skipping): {}".format(ident)) |
| 140 | + continue |
| 141 | + transaction.begin() |
| 142 | + pw = regtool.generatePassword() |
| 143 | + roles = member.getRoles() |
| 144 | + domains = member.getDomains() |
| 145 | + acl_users.userFolderEditUser( |
| 146 | + member.getId(), |
| 147 | + pw, |
| 148 | + roles, |
| 149 | + domains) |
| 150 | + req.form['new_password'] = pw |
| 151 | + mail_password(site, req, member, base_url) |
| 152 | + transaction.commit() |
| 153 | + |
| 154 | + |
| 155 | +def run(app): |
| 156 | + singleton.SingleInstance('reportusers') |
| 157 | + |
| 158 | + args = get_args() |
| 159 | + |
| 160 | + user = app.acl_users.getUser('admin') |
| 161 | + newSecurityManager(None, user.__of__(app.acl_users)) |
| 162 | + |
| 163 | + if args.site_id.strip().lower() == "_all_": |
| 164 | + for oid in app.objectIds(): |
| 165 | + obj = app[oid] |
| 166 | + if IPloneSiteRoot.providedBy(obj): |
| 167 | + setSite(obj) |
| 168 | + send_reset_emails(obj, args.users_file, args.ident_field, app.REQUEST, args.base_url) |
| 169 | + else: |
| 170 | + site = app[args.site_id] |
| 171 | + setSite(site) |
| 172 | + send_reset_emails(site, args.users_file, args.ident_field, app.REQUEST, args.base_url) |
| 173 | + |
| 174 | + |
| 175 | +def setup_and_run(): |
| 176 | + conf_path = os.getenv("ZOPE_CONF_PATH", "parts/instance/zope.conf") |
| 177 | + if conf_path is None or not os.path.exists(conf_path): |
| 178 | + raise Exception('Could not find zope.conf at {}'.format(conf_path)) |
| 179 | + |
| 180 | + from Zope2 import configure |
| 181 | + configure(conf_path) |
| 182 | + import Zope2 |
| 183 | + app = Zope2.app() |
| 184 | + from Testing.ZopeTestCase.utils import makerequest |
| 185 | + app = makerequest(app) |
| 186 | + app.REQUEST['PARENTS'] = [app] |
| 187 | + from zope.globalrequest import setRequest |
| 188 | + setRequest(app.REQUEST) |
| 189 | + from AccessControl.SpecialUsers import system as user |
| 190 | + from AccessControl.SecurityManagement import newSecurityManager |
| 191 | + newSecurityManager(None, user) |
| 192 | + |
| 193 | + run(app) |
| 194 | + |
| 195 | + |
| 196 | +if __name__ == '__main__': |
| 197 | + run(app) # noqa: F821 |
0 commit comments