Skip to content

Commit bc58488

Browse files
authored
quick script to reset passwords for multiple accounts at once (#627)
1 parent 95ac60a commit bc58488

2 files changed

Lines changed: 198 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def read(*rnames):
131131
upgrade-sites = castle.cms.cron:upgrade_sites
132132
link-report = castle.cms.cron:link_report
133133
report-users = castle.cms._scripts.report_users:setup_and_run
134+
send-pwreset-emails = castle.cms._scripts.send_pwreset_emails:setup_and_run
134135
auto-publish-retract = castle.cms.cron:auto_publish_retract
135136
""",
136137
include_package_data=True,

0 commit comments

Comments
 (0)