Back to index

plone3  3.1.7
PasswordResetTool.py
Go to the documentation of this file.
00001 """PasswordResetTool.py
00002 
00003 Mailback password reset product for CMF.
00004 Author: J Cameron Cooper, Sept 2003
00005 """
00006 
00007 from Products.CMFCore.utils import UniqueObject
00008 from Products.CMFCore.utils import getToolByName
00009 from OFS.SimpleItem import SimpleItem
00010 from Globals import InitializeClass, DTMLFile
00011 from AccessControl import ClassSecurityInfo
00012 from Products.CMFCore.permissions import ManagePortal
00013 
00014 from interfaces.portal_password_reset import portal_password_reset as IPWResetTool
00015 
00016 import datetime, time, random, md5, socket
00017 from DateTime import DateTime
00018 
00019 class PasswordResetTool (UniqueObject, SimpleItem):
00020     """Provides a default implementation for a password reset scheme.
00021 
00022     From a 'forgotten password' template, you submit your username to
00023     a handler script that does a 'requestReset', and sends an email
00024     with an unguessable unique hash in a url as built by 'constructURL'
00025     to the user.
00026 
00027     The user visits that URL (the 'reset form') and enters their username,
00028     """
00029     
00030     ## other things needed for this to work
00031     # skins:
00032     #  - handler script for forgotten password form (probably over-riding existing Plone one
00033     #  - email template
00034     #  - password reset form
00035     #  - password reset form handler script
00036 
00037     ## Tool/CMF/Zope machinery
00038 
00039     # The latter will work only with Plone 1.1 => hence, the if
00040     #if hasattr(ActionProviderBase, '__implements__'):
00041     #    __implements__ = (IPWResetTool, ActionProviderBase.__implements__)
00042     __implements__ = (IPWResetTool)
00043 
00044     id = 'portal_password_reset'
00045     meta_type = 'Password Reset Tool'
00046     #_actions = ()
00047 
00048     security = ClassSecurityInfo()
00049 
00050     manage_options=(( { 'label' : 'Overview'
00051                         , 'action' : 'manage_overview'
00052                         },
00053                       ) + SimpleItem.manage_options
00054                     )
00055 
00056     ##   ZMI methods
00057     security.declareProtected(ManagePortal, 'manage_overview')
00058     manage_overview = DTMLFile('dtml/explainPWResetTool', globals() )
00059 
00060     security.declareProtected(ManagePortal, 'manage_setTimeout')
00061     def manage_setTimeout(self, hours=168, REQUEST=None):
00062        """ZMI method for setting the expiration timeout in hours."""
00063        self.setExpirationTimeout(int(hours))
00064         return self.manage_overview(manage_tabs_message="Timeout set to %s hours" % hours)   
00065 
00066     security.declareProtected(ManagePortal, 'manage_toggleUserCheck')
00067     def manage_toggleUserCheck(self, REQUEST=None):
00068        """ZMI method for toggling the flag for checking user names on return."""
00069        self.toggleUserCheck()
00070         m = self.checkUser() and 'on' or 'off'
00071         return self.manage_overview(manage_tabs_message="Returning username check turned %s" % m)
00072 
00073 
00074     def __init__(self):
00075         self._requests = {}
00076 
00077     ## Internal attributes
00078     _user_check = 1
00079     _timedelta = 168
00080 
00081     ## Interface fulfillment ##
00082     security.declareProtected(ManagePortal, 'requestReset')
00083     def requestReset(self, userid):
00084         """Ask the system to start the password reset procedure for
00085         user 'userid'.
00086 
00087         Returns a dictionary with the random string that must be
00088         used to reset the password in 'randomstring', the expiration date
00089         as a DateTime in 'expires', and the userid (for convenience) in
00090         'userid'. Returns None if no such user.
00091         """
00092         if not self.getValidUser(userid):
00093             return None
00094         randomstring = self.uniqueString(userid)
00095         expiry = self.expirationDate()
00096         self._requests[randomstring] = (userid, expiry)
00097         
00098         self.clearExpired(10)   # clear out untouched records more than 10 days old
00099                                 # this is a cheap sort of "automatic" clearing
00100         self._p_changed = 1
00101         
00102         retval = {}
00103         retval['randomstring'] = randomstring
00104         retval['expires'] = expiry
00105         retval['userid'] = userid
00106         return retval
00107 
00108     security.declarePublic('resetPassword')
00109     def resetPassword(self, userid, randomstring, password):
00110         """Set the password (in 'password') for the user who maps to
00111         the string in 'randomstring' iff the entered 'userid' is equal
00112         to the mapped userid. (This can be turned off with the
00113         'toggleUserCheck' method.)
00114 
00115         Note that this method will *not* check password validity: this
00116         must be done by the caller.
00117 
00118         Throws an 'ExpiredRequestError' if request is expired.
00119         Throws an 'InvalidRequestError' if no such record exists,
00120         or 'userid' is not in the record.
00121         """
00122         try:
00123             stored_user, expiry = self._requests[randomstring]
00124         except KeyError:
00125             raise 'InvalidRequestError'
00126         
00127         if self.checkUser() and (userid != stored_user):
00128             raise 'InvalidRequestError'
00129         if self.expired(expiry):
00130             del self._requests[randomstring]
00131             self._p_changed = 1
00132             raise 'ExpiredRequestError'
00133 
00134         member = self.getValidUser(stored_user)
00135         if not member:
00136             raise 'InvalidRequestError'
00137 
00138         # actually change password
00139         user = member.getUser()
00140         uf = getToolByName(self, 'acl_users')
00141         if getattr(uf, 'userSetPassword', None) is not None:
00142             uf.userSetPassword(stored_user, password)  # GRUF 3
00143         else:
00144             try:
00145                 user.changePassword(password)  # GRUF 2
00146             except AttributeError:
00147                 # this sets __ directly (via MemberDataTool) which is the usual
00148                 # (and stupid!) way to change a password in Zope
00149                 member.setSecurityProfile(password=password)
00150 
00151         member.setMemberProperties(dict(must_change_password=0))
00152 
00153         # clean out the request
00154         del self._requests[randomstring]
00155         self._p_changed = 1
00156         
00157 
00158     ## Implementation ##
00159 
00160     # external
00161 
00162     security.declareProtected(ManagePortal, 'setExpirationTimeout')
00163     def setExpirationTimeout(self, timedelta):
00164         """Set the length of time a reset request will be valid.
00165 
00166         Takes a 'datetime.timedelta' object (if available, since it's
00167         new in Python 2.3) or a number of hours, possibly
00168         fractional. Since a negative delta makes no sense, the
00169         timedelta's absolute value will be used."""
00170         self._timedelta = abs(timedelta)
00171 
00172     security.declarePublic('getExpirationTimeout')
00173     def getExpirationTimeout(self):
00174         """Get the length of time a reset request will be valid.
00175 
00176         In hours, possibly fractional. Ignores seconds and shorter."""
00177         try:
00178             if isinstance(self._timedelta,datetime.timedelta):
00179                 return self._timedelta.days / 24
00180         except NameError:
00181             pass  # that's okay, it must be a number of hours...
00182         return self._timedelta
00183 
00184     security.declareProtected(ManagePortal, 'toggleUserCheck')
00185     def toggleUserCheck(self):
00186         """Changes whether or not the tool requires someone to give the uerid
00187         they're trying to change on a 'password reset' page. Highly recommended
00188         to LEAVE THIS ON."""
00189         if not hasattr(self, '_user_check'):
00190             self._user_check = 1
00191 
00192         self._user_check = not self._user_check
00193 
00194     security.declarePublic('checkUser')
00195     def checkUser(self):
00196         """Returns a boolean representing the state of 'user check' as described
00197         in 'toggleUserCheck'. True means on, and is the default."""
00198         if not hasattr(self, '_user_check'):
00199             self._user_check = 1
00200 
00201         return self._user_check
00202 
00203     security.declarePublic('verifyKey')
00204     def verifyKey(self, key):
00205         """Verify a key. Raises an exception if the key is invalid or expired"""
00206 
00207         try:
00208             u, expiry = self._requests[key]
00209         except KeyError:
00210             raise 'InvalidRequestError'
00211         
00212         if self.expired(expiry):
00213             raise 'ExpiredRequestError'
00214 
00215         if not self.getValidUser(u):
00216             raise 'InvalidRequestError', 'No such user'
00217         
00218     security.declareProtected(ManagePortal, 'getStats')
00219     def getStats(self):
00220         """Return a dictionary like so:
00221             {"open":3, "expired":0}
00222         about the number of open and expired reset requests.
00223         """
00224         good = 0
00225         bad = 0
00226         for stored_user, expiry in self._requests.values():
00227             if self.expired(expiry): bad += 1
00228             else: good += 1
00229 
00230         return {"open":good, "expired":bad}
00231     
00232     security.declarePrivate('clearExpired')
00233     def clearExpired(self, days=0):
00234         """Destroys all expired reset request records.
00235         Parameter controls how many days past expired it must be to disappear.
00236         """
00237         for key, record in self._requests.items():
00238             stored_user, expiry = record
00239             if self.expired(expiry, DateTime()-days):
00240                 del self._requests[key]
00241                 self._p_changed = 1
00242 
00243     # customization points
00244 
00245     security.declarePrivate('uniqueString')
00246     def uniqueString(self, userid):
00247         """Returns a string that is random and unguessable, or at
00248         least as close as possible.
00249 
00250         This is used by 'requestReset' to generate the auth
00251         string. Override if you wish different format.
00252 
00253         This implementation ignores userid and simply generates a
00254         UUID. That parameter is for convenience of extenders, and
00255         will be passed properly in the default implementation."""
00256         # this is the informal UUID algorithm of
00257         # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/213761
00258         # by Carl Free Jr
00259         t = long( time.time() * 1000 )
00260         r = long( random.random()*100000000000000000L )
00261         try:
00262             a = socket.gethostbyname( socket.gethostname() )
00263         except:
00264             # if we can't get a network address, just imagine one
00265             a = random.random()*100000000000000000L
00266         data = str(t)+' '+str(r)+' '+str(a)#+' '+str(args)
00267         data = md5.md5(data).hexdigest()
00268         return str(data)
00269 
00270     security.declarePrivate('expirationDate')
00271     def expirationDate(self):
00272         """Returns a DateTime for exipiry of a request from the
00273         current time.
00274 
00275         This is used by housekeeping methods (like clearEpired)
00276         and stored in reset request records."""
00277         if not hasattr(self, '_timedelta'):
00278             self._timedelta = 168
00279         if isinstance(self._timedelta,datetime.timedelta):
00280             expire = datetime.datetime.utcnow() + self._timedelta
00281             return DateTime(expire.year,
00282                             expire.month,
00283                             expire.day,
00284                             expire.hour,
00285                             expire.minute,
00286                             expire.second,
00287                             'UTC')
00288         expire = time.time() + self._timedelta*3600  # 60 min/hr * 60 sec/min
00289         return DateTime(expire)
00290 
00291     security.declarePrivate('getValidUser')
00292     def getValidUser(self, userid):
00293         """Returns the member with 'userid' if available and None otherwise."""
00294         membertool = getToolByName(self, 'portal_membership')
00295         return membertool.getMemberById(userid)
00296     
00297     # internal
00298 
00299     security.declarePrivate('expired')
00300     def expired(self, datetime, now=None):
00301         """Tells whether a DateTime or timestamp 'datetime' is expired
00302         with regards to either 'now', if provided, or the current
00303         time."""
00304         if not now:
00305             now = DateTime()
00306         return now.greaterThanEqualTo(datetime)
00307 
00308 # these are possible customization points I'm not really sure we need.
00309 #
00310 #    def getRequestRecord(self, randomstring):
00311 #        """Returns a tuple (userid,expiration) that maps to a specific
00312 #        reset request, as keyed by the 'randomstring'.
00313 #
00314 #        Expiration is a DateTime."""
00315 #
00316 #    def setRequestRecord(self, randomstring, userid, expiry):
00317 #        """Create a reset request record keyed by 'randomstring'
00318 #        containing 'userid' and 'expiry' (which should be a DateTime)."""
00319 #
00320 #    def removeRequestRecord(self, randomstring):
00321 #        """Destroy the request reset record keyed by 'randomstring'."""
00322 #
00323 #    def getAllRequests(self):
00324 #        """Returns a list of all reset requests in a tuple
00325 #        '(randomstring, userid, expiry)'.
00326 #
00327 #        Used primarily for housekeeping. Expiry is a DateTime."""
00328 
00329     # def # bobo_traverse_override to get URL as above
00330 
00331 InitializeClass(PasswordResetTool)