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
Closed
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 95a6577
moved tests
tomgross 40cd8e0
move tests over and change to plone i18n domain
tomgross 649c6d1
replace django_random with plone.uuid generator
tomgross 72aa556
provide a login link after password reset
tomgross cb9eb13
fix pwreset config page
tomgross 6e8fed6
update metadata version
tomgross f0e498b
changelog
tomgross 060a5c2
get rid of completly (in whole core) unused plone_scripts
jensens ad1f6e7
remove unused assignment in content_status_modify.cpy
jensens 1b8b0cc
Fix TinyMCE table styles
cfe43a5
Fixed adding same resource/bundle to the request multiple times
vangheem 2071f33
Default toolbar menu icon styling.
alecpm 9b9bf52
Add a class to explicitly target the toolbar in its collapsed state
MatthewWilkes 5f44478
Compile logged in resources
MatthewWilkes eacbae4
Conver to p.a.testing
gforcada af48621
Remove duplicated entry
gforcada 2d30409
p.a.folder side effect
gforcada 6d868e3
Kupu is no longer around
gforcada c670b69
Another ZopeTestCase converted to p.a.testing
gforcada e0c1d83
Another ZopeTestCase to p.a.testing
gforcada 6e0c871
Update CHANGES
gforcada f95ea8f
Move default toolbar icon to better controlled location.
alecpm c3ee8a6
Recompile statics
MatthewWilkes 472e176
Fix plone-legacy RequireJS errors in development mode
thet e9b22ca
remove skins/plone_scripts/formatColumns.py
jensens 6b388c6
remove skins/plone_scripts/getSelectableViews.py
jensens f80b2c1
remove skins/plone_scripts/hasIndexHtml.py
jensens 3fd8ba5
remove skins/plone_scripts/navigationParent.py
jensens 7d751b3
remove skins/plone_scripts/returnNone.py
jensens 157dbcd
remove Products/CMFPlone/skins/plone_scripts/sortObjects.py
jensens f28f309
document chnages
jensens 5fa7446
remove reverseList.py with test
jensens c4656c8
remove skins/plone_scripts/getAllowedTypes.py and skins/plone_scripts…
jensens File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = {} | ||
|
||
## 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) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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