Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Move PasswordResetTool to CMFPlone #1799

Closed
wants to merge 34 commits into from
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
31c40e2
move PasswordResetTool to CMFPlone
tomgross Oct 20, 2016
95a6577
moved tests
tomgross Oct 20, 2016
40cd8e0
move tests over and change to plone i18n domain
tomgross Oct 21, 2016
649c6d1
replace django_random with plone.uuid generator
tomgross Oct 21, 2016
72aa556
provide a login link after password reset
tomgross Oct 21, 2016
cb9eb13
fix pwreset config page
tomgross Oct 22, 2016
6e8fed6
update metadata version
tomgross Oct 23, 2016
f0e498b
changelog
tomgross Oct 23, 2016
060a5c2
get rid of completly (in whole core) unused plone_scripts
jensens Oct 21, 2016
ad1f6e7
remove unused assignment in content_status_modify.cpy
jensens Oct 21, 2016
1b8b0cc
Fix TinyMCE table styles
Oct 7, 2016
cfe43a5
Fixed adding same resource/bundle to the request multiple times
vangheem Aug 7, 2016
2071f33
Default toolbar menu icon styling.
alecpm Oct 22, 2016
9b9bf52
Add a class to explicitly target the toolbar in its collapsed state
MatthewWilkes Oct 22, 2016
5f44478
Compile logged in resources
MatthewWilkes Oct 22, 2016
eacbae4
Conver to p.a.testing
gforcada Jan 29, 2016
af48621
Remove duplicated entry
gforcada Jan 29, 2016
2d30409
p.a.folder side effect
gforcada Jan 29, 2016
6d868e3
Kupu is no longer around
gforcada Jan 29, 2016
c670b69
Another ZopeTestCase converted to p.a.testing
gforcada Jan 29, 2016
e0c1d83
Another ZopeTestCase to p.a.testing
gforcada Jan 29, 2016
6e0c871
Update CHANGES
gforcada Oct 22, 2016
f95ea8f
Move default toolbar icon to better controlled location.
alecpm Oct 22, 2016
c3ee8a6
Recompile statics
MatthewWilkes Oct 22, 2016
472e176
Fix plone-legacy RequireJS errors in development mode
thet Sep 29, 2016
e9b22ca
remove skins/plone_scripts/formatColumns.py
jensens Oct 22, 2016
6b388c6
remove skins/plone_scripts/getSelectableViews.py
jensens Oct 22, 2016
f80b2c1
remove skins/plone_scripts/hasIndexHtml.py
jensens Oct 22, 2016
3fd8ba5
remove skins/plone_scripts/navigationParent.py
jensens Oct 22, 2016
7d751b3
remove skins/plone_scripts/returnNone.py
jensens Oct 22, 2016
157dbcd
remove Products/CMFPlone/skins/plone_scripts/sortObjects.py
jensens Oct 22, 2016
f28f309
document chnages
jensens Oct 22, 2016
5fa7446
remove reverseList.py with test
jensens Oct 23, 2016
c4656c8
remove skins/plone_scripts/getAllowedTypes.py and skins/plone_scripts…
jensens Oct 23, 2016
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
323 changes: 323 additions & 0 deletions Products/CMFPlone/PasswordResetTool.py
@@ -0,0 +1,323 @@
"""PasswordResetTool.py

Mailback password reset product for CMF.
Author: J Cameron Cooper, Sept 2003
"""
from hashlib import md5

from Products.CMFCore.utils import UniqueObject
from Products.CMFCore.utils import getToolByName
from OFS.SimpleItem import SimpleItem
from App.class_init import InitializeClass
from AccessControl import ClassSecurityInfo
from AccessControl import ModuleSecurityInfo
from plone.uuid.interfaces import IUUIDGenerator
from plone.registry.interfaces import IRegistry
from Products.CMFCore.permissions import ManagePortal
from Products.CMFPlone.interfaces import IPWResetTool
from Products.CMFPlone.interfaces import ISecuritySchema
from Products.CMFPlone.RegistrationTool import get_member_by_login_name

import datetime
import time
from DateTime import DateTime
from zope.component import getUtility
from zope.interface import implementer

module_security = ModuleSecurityInfo('Products.CMFPlone.PasswordResetTool')

module_security.declarePublic('InvalidRequestError')
class InvalidRequestError(Exception):
""" Request reset URL is invalid """
def __init__(self, value=''):
self.value = value

def __str__(self):
return repr(self.value)

module_security.declarePublic('ExpiredRequestError')
class ExpiredRequestError(InvalidRequestError):
""" Request reset URL is expired """


@implementer(IPWResetTool)
class PasswordResetTool (UniqueObject, SimpleItem):
"""Provides a default implementation for a password reset scheme.

From a 'forgotten password' template, you submit your username to
a handler script that does a 'requestReset', and sends an email
with an unguessable unique hash in a url as built by 'constructURL'
to the user.

The user visits that URL (the 'reset form') and enters their username,
"""

id = 'portal_password_reset'
meta_type = 'Password Reset Tool'

security = ClassSecurityInfo()

security.declareProtected(ManagePortal, 'manage_setTimeout')

def manage_setTimeout(self, hours=168, REQUEST=None):
"""ZMI method for setting the expiration timeout in hours."""
self.setExpirationTimeout(int(hours))
return self.manage_overview(manage_tabs_message="Timeout set to %s hours" % hours)

security.declareProtected(ManagePortal, 'manage_toggleUserCheck')

def manage_toggleUserCheck(self, REQUEST=None):
"""ZMI method for toggling the flag for checking user names on return.
"""
self.toggleUserCheck()
m = self.checkUser() and 'on' or 'off'
return self.manage_overview(manage_tabs_message="Returning username check turned %s" % m)

def __init__(self):
self._requests = {}
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're moving the password tool, let's change this to a BTree. We had a customer with a site that had self-registration turned on and was getting spam accounts created, and the database grew rapidly because each new user saved a new copy of this entire dict.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a good idea. I'll look into this


## Internal attributes
_user_check = 1
_timedelta = 168 # misleading name, the number of hours are actually stored as int

## Interface fulfillment ##
security.declareProtected(ManagePortal, 'requestReset')

def requestReset(self, userid):
"""Ask the system to start the password reset procedure for
user 'userid'.

Returns a dictionary with the random string that must be
used to reset the password in 'randomstring', the expiration date
as a DateTime in 'expires', and the userid (for convenience) in
'userid'. Returns None if no such user.
"""
if not self.getValidUser(userid):
return None
randomstring = self.uniqueString(userid)
expiry = self.expirationDate()
self._requests[randomstring] = (userid, expiry)

self.clearExpired(10) # clear out untouched records more than 10 days old
# this is a cheap sort of "automatic" clearing
self._p_changed = 1

retval = {}
retval['randomstring'] = randomstring
retval['expires'] = expiry
retval['userid'] = userid
return retval

security.declarePublic('resetPassword')

def resetPassword(self, userid, randomstring, password):
"""Set the password (in 'password') for the user who maps to
the string in 'randomstring' iff the entered 'userid' is equal
to the mapped userid. (This can be turned off with the
'toggleUserCheck' method.)

Note that this method will *not* check password validity: this
must be done by the caller.

Throws an 'ExpiredRequestError' if request is expired.
Throws an 'InvalidRequestError' if no such record exists,
or 'userid' is not in the record.
"""
if get_member_by_login_name:
found_member = get_member_by_login_name(
self, userid, raise_exceptions=False)
if found_member is not None:
userid = found_member.getId()
try:
stored_user, expiry = self._requests[randomstring]
except KeyError:
raise InvalidRequestError

if self.checkUser() and (userid != stored_user):
raise InvalidRequestError
if self.expired(expiry):
del self._requests[randomstring]
self._p_changed = 1
raise ExpiredRequestError

member = self.getValidUser(stored_user)
if not member:
raise InvalidRequestError

# actually change password
user = member.getUser()
uf = getToolByName(self, 'acl_users')
if getattr(uf, 'userSetPassword', None) is not None:
uf.userSetPassword(user.getUserId(), password) # GRUF 3
else:
try:
user.changePassword(password) # GRUF 2
except AttributeError:
# this sets __ directly (via MemberDataTool) which is the usual
# (and stupid!) way to change a password in Zope
member.setSecurityProfile(password=password)

member.setMemberProperties(dict(must_change_password=0))

# clean out the request
del self._requests[randomstring]
self._p_changed = 1
## Implementation ##

# external

security.declareProtected(ManagePortal, 'setExpirationTimeout')

def setExpirationTimeout(self, timedelta):
"""Set the length of time a reset request will be valid.

Takes a 'datetime.timedelta' object (if available, since it's
new in Python 2.3) or a number of hours, possibly
fractional. Since a negative delta makes no sense, the
timedelta's absolute value will be used."""
self._timedelta = abs(timedelta)

security.declarePublic('getExpirationTimeout')

def getExpirationTimeout(self):
"""Get the length of time a reset request will be valid.

In hours, possibly fractional. Ignores seconds and shorter."""
try:
if isinstance(self._timedelta, datetime.timedelta):
return self._timedelta.days * 24
except NameError:
pass # that's okay, it must be a number of hours...
return self._timedelta

security.declareProtected(ManagePortal, 'toggleUserCheck')

def toggleUserCheck(self):
"""Changes whether or not the tool requires someone to give the uerid
they're trying to change on a 'password reset' page. Highly recommended
to LEAVE THIS ON."""
if not hasattr(self, '_user_check'):
self._user_check = 1

self._user_check = not self._user_check

security.declarePublic('checkUser')

def checkUser(self):
"""Returns a boolean representing the state of 'user check' as described
in 'toggleUserCheck'. True means on, and is the default."""
if not hasattr(self, '_user_check'):
self._user_check = 1

return self._user_check

security.declarePublic('verifyKey')

def verifyKey(self, key):
"""Verify a key. Raises an exception if the key is invalid or expired.
"""
try:
u, expiry = self._requests[key]
except KeyError:
raise InvalidRequestError

if self.expired(expiry):
raise ExpiredRequestError

if not self.getValidUser(u):
raise InvalidRequestError('No such user')

security.declareProtected(ManagePortal, 'getStats')

def getStats(self):
"""Return a dictionary like so:
{"open":3, "expired":0}
about the number of open and expired reset requests.
"""
good = 0
bad = 0
for stored_user, expiry in self._requests.values():
if self.expired(expiry):
bad += 1
else:
good += 1

return {"open": good, "expired": bad}

security.declarePrivate('clearExpired')

def clearExpired(self, days=0):
"""Destroys all expired reset request records.
Parameter controls how many days past expired it must be to disappear.
"""
for key, record in self._requests.items():
stored_user, expiry = record
if self.expired(expiry, DateTime() - days):
del self._requests[key]
self._p_changed = 1
# customization points

security.declarePrivate('uniqueString')

def uniqueString(self, userid):
"""Returns a string that is random and unguessable, or at
least as close as possible.

This is used by 'requestReset' to generate the auth
string. Override if you wish different format.

This implementation ignores userid and simply generates a
UUID. That parameter is for convenience of extenders, and
will be passed properly in the default implementation.
"""
uuid_generator = getUtility(IUUIDGenerator)
return uuid_generator()

security.declarePrivate('expirationDate')

def expirationDate(self):
"""Returns a DateTime for exipiry of a request from the
current time.

This is used by housekeeping methods (like clearEpired)
and stored in reset request records."""
if not hasattr(self, '_timedelta'):
self._timedelta = 168
if isinstance(self._timedelta, datetime.timedelta):
expire = datetime.datetime.utcnow() + self._timedelta
return DateTime(expire.year,
expire.month,
expire.day,
expire.hour,
expire.minute,
expire.second,
'UTC')
expire = time.time() + self._timedelta * 3600 # 60 min/hr * 60 sec/min
return DateTime(expire)

security.declarePrivate('getValidUser')

def getValidUser(self, userid):
"""Returns the member with 'userid' if available and None otherwise."""
if get_member_by_login_name:
registry = getUtility(IRegistry)
settings = registry.forInterface(ISecuritySchema, prefix='plone')

if settings.use_email_as_login:
return get_member_by_login_name(
self, userid, raise_exceptions=False)
membertool = getToolByName(self, 'portal_membership')
return membertool.getMemberById(userid)
# internal

security.declarePrivate('expired')

def expired(self, datetime, now=None):
"""Tells whether a DateTime or timestamp 'datetime' is expired
with regards to either 'now', if provided, or the current
time."""
if not now:
now = DateTime()
return now.greaterThanEqualTo(datetime)

InitializeClass(PasswordResetTool)