Apply 01_defaults.debian.patch
[mspang/vmailman.git] / Mailman / SecurityManager.py
1 # Copyright (C) 1998-2006 by the Free Software Foundation, Inc.
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
7 #
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software
15 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
16 # USA.
17
18
19 """Handle passwords and sanitize approved messages."""
20
21 # There are current 5 roles defined in Mailman, as codified in Defaults.py:
22 # user, list-creator, list-moderator, list-admin, site-admin.
23 #
24 # Here's how we do cookie based authentication.
25 #
26 # Each role (see above) has an associated password, which is currently the
27 # only way to authenticate a role (in the future, we'll authenticate a
28 # user and assign users to roles).
29 #
30 # Each cookie has the following ingredients: the authorization context's
31 # secret (i.e. the password, and a timestamp.  We generate an SHA1 hex
32 # digest of these ingredients, which we call the `mac'.  We then marshal
33 # up a tuple of the timestamp and the mac, hexlify that and return that as
34 # a cookie keyed off the authcontext.  Note that authenticating the user
35 # also requires the user's email address to be included in the cookie.
36 #
37 # The verification process is done in CheckCookie() below.  It extracts
38 # the cookie, unhexlifies and unmarshals the tuple, extracting the
39 # timestamp.  Using this, and the shared secret, the mac is calculated,
40 # and it must match the mac passed in the cookie.  If so, they're golden,
41 # otherwise, access is denied.
42 #
43 # It is still possible for an adversary to attempt to brute force crack
44 # the password if they obtain the cookie, since they can extract the
45 # timestamp and create macs based on password guesses.  They never get a
46 # cleartext version of the password though, so security rests on the
47 # difficulty and expense of retrying the cgi dialog for each attempt.  It
48 # also relies on the security of SHA1.
49
50 import os
51 import re
52 import sha
53 import time
54 import Cookie
55 import marshal
56 import binascii
57 import urllib
58 from types import StringType, TupleType
59 from urlparse import urlparse
60
61 try:
62     import crypt
63 except ImportError:
64     crypt = None
65 import md5
66
67 from Mailman import mm_cfg
68 from Mailman import Utils
69 from Mailman import Errors
70 from Mailman.Logging.Syslog import syslog
71
72 try:
73     True, False
74 except NameError:
75     True = 1
76     False = 0
77
78
79 \f
80 class SecurityManager:
81     def InitVars(self):
82         # We used to set self.password here, from a crypted_password argument,
83         # but that's been removed when we generalized the mixin architecture.
84         # self.password is really a SecurityManager attribute, but it's set in
85         # MailList.InitVars().
86         self.mod_password = None
87         # Non configurable
88         self.passwords = {}
89
90     def AuthContextInfo(self, authcontext, user=None):
91         # authcontext may be one of AuthUser, AuthListModerator,
92         # AuthListAdmin, AuthSiteAdmin.  Not supported is the AuthCreator
93         # context.
94         #
95         # user is ignored unless authcontext is AuthUser
96         #
97         # Return the authcontext's secret and cookie key.  If the authcontext
98         # doesn't exist, return the tuple (None, None).  If authcontext is
99         # AuthUser, but the user isn't a member of this mailing list, a
100         # NotAMemberError will be raised.  If the user's secret is None, raise
101         # a MMBadUserError.
102         key = self.internal_name() + '+'
103         if authcontext == mm_cfg.AuthUser:
104             if user is None:
105                 # A bad system error
106                 raise TypeError, 'No user supplied for AuthUser context'
107             secret = self.getMemberPassword(user)
108             userdata = urllib.quote(Utils.ObscureEmail(user), safe='')
109             key += 'user+%s' % userdata
110         elif authcontext == mm_cfg.AuthListModerator:
111             secret = self.mod_password
112             key += 'moderator'
113         elif authcontext == mm_cfg.AuthListAdmin:
114             secret = self.password
115             key += 'admin'
116         # BAW: AuthCreator
117         elif authcontext == mm_cfg.AuthSiteAdmin:
118             sitepass = Utils.get_global_password()
119             if mm_cfg.ALLOW_SITE_ADMIN_COOKIES and sitepass:
120                 secret = sitepass
121                 key = 'site'
122             else:
123                 # BAW: this should probably hand out a site password based
124                 # cookie, but that makes me a bit nervous, so just treat site
125                 # admin as a list admin since there is currently no site
126                 # admin-only functionality.
127                 secret = self.password
128                 key += 'admin'
129         else:
130             return None, None
131         return key, secret
132
133     def Authenticate(self, authcontexts, response, user=None):
134         # Given a list of authentication contexts, check to see if the
135         # response matches one of the passwords.  authcontexts must be a
136         # sequence, and if it contains the context AuthUser, then the user
137         # argument must not be None.
138         #
139         # Return the authcontext from the argument sequence that matches the
140         # response, or UnAuthorized.
141         for ac in authcontexts:
142             if ac == mm_cfg.AuthCreator:
143                 ok = Utils.check_global_password(response, siteadmin=0)
144                 if ok:
145                     return mm_cfg.AuthCreator
146             elif ac == mm_cfg.AuthSiteAdmin:
147                 ok = Utils.check_global_password(response)
148                 if ok:
149                     return mm_cfg.AuthSiteAdmin
150             elif ac == mm_cfg.AuthListAdmin:
151                 def cryptmatchp(response, secret):
152                     try:
153                         salt = secret[:2]
154                         if crypt and crypt.crypt(response, salt) == secret:
155                             return True
156                         return False
157                     except TypeError:
158                         # BAW: Hard to say why we can get a TypeError here.
159                         # SF bug report #585776 says crypt.crypt() can raise
160                         # this if salt contains null bytes, although I don't
161                         # know how that can happen (perhaps if a MM2.0 list
162                         # with USE_CRYPT = 0 has been updated?  Doubtful.
163                         return False
164                 # The password for the list admin and list moderator are not
165                 # kept as plain text, but instead as an sha hexdigest.  The
166                 # response being passed in is plain text, so we need to
167                 # digestify it first.  Note however, that for backwards
168                 # compatibility reasons, we'll also check the admin response
169                 # against the crypted and md5'd passwords, and if they match,
170                 # we'll auto-migrate the passwords to sha.
171                 key, secret = self.AuthContextInfo(ac)
172                 if secret is None:
173                     continue
174                 sharesponse = sha.new(response).hexdigest()
175                 upgrade = ok = False
176                 if sharesponse == secret:
177                     ok = True
178                 elif md5.new(response).digest() == secret:
179                     ok = upgrade = True
180                 elif cryptmatchp(response, secret):
181                     ok = upgrade = True
182                 if upgrade:
183                     save_and_unlock = False
184                     if not self.Locked():
185                         self.Lock()
186                         save_and_unlock = True
187                     try:
188                         self.password = sharesponse
189                         if save_and_unlock:
190                             self.Save()
191                     finally:
192                         if save_and_unlock:
193                             self.Unlock()
194                 if ok:
195                     return ac
196             elif ac == mm_cfg.AuthListModerator:
197                 # The list moderator password must be sha'd
198                 key, secret = self.AuthContextInfo(ac)
199                 if secret and sha.new(response).hexdigest() == secret:
200                     return ac
201             elif ac == mm_cfg.AuthUser:
202                 if user is not None:
203                     try:
204                         if self.authenticateMember(user, response):
205                             return ac
206                     except Errors.NotAMemberError:
207                         pass
208             else:
209                 # What is this context???
210                 syslog('error', 'Bad authcontext: %s', ac)
211                 raise ValueError, 'Bad authcontext: %s' % ac
212         return mm_cfg.UnAuthorized
213
214     def WebAuthenticate(self, authcontexts, response, user=None):
215         # Given a list of authentication contexts, check to see if the cookie
216         # contains a matching authorization, falling back to checking whether
217         # the response matches one of the passwords.  authcontexts must be a
218         # sequence, and if it contains the context AuthUser, then the user
219         # argument should not be None.
220         #
221         # Returns a flag indicating whether authentication succeeded or not.
222         for ac in authcontexts:
223             ok = self.CheckCookie(ac, user)
224             if ok:
225                 return True
226         # Check passwords
227         ac = self.Authenticate(authcontexts, response, user)
228         if ac:
229             print self.MakeCookie(ac, user)
230             return True
231         return False
232
233     def MakeCookie(self, authcontext, user=None):
234         key, secret = self.AuthContextInfo(authcontext, user)
235         if key is None or secret is None or not isinstance(secret, StringType):
236             raise ValueError
237         # Timestamp
238         issued = int(time.time())
239         # Get a digest of the secret, plus other information.
240         mac = sha.new(secret + `issued`).hexdigest()
241         # Create the cookie object.
242         c = Cookie.SimpleCookie()
243         c[key] = binascii.hexlify(marshal.dumps((issued, mac)))
244         # The path to all Mailman stuff, minus the scheme and host,
245         # i.e. usually the string `/mailman'
246         path = urlparse(self.web_page_url)[2]
247         c[key]['path'] = path
248         # We use session cookies, so don't set `expires' or `max-age' keys.
249         # Set the RFC 2109 required header.
250         c[key]['version'] = 1
251         return c
252
253     def ZapCookie(self, authcontext, user=None):
254         # We can throw away the secret.
255         key, secret = self.AuthContextInfo(authcontext, user)
256         # Logout of the session by zapping the cookie.  For safety both set
257         # max-age=0 (as per RFC2109) and set the cookie data to the empty
258         # string.
259         c = Cookie.SimpleCookie()
260         c[key] = ''
261         # The path to all Mailman stuff, minus the scheme and host,
262         # i.e. usually the string `/mailman'
263         path = urlparse(self.web_page_url)[2]
264         c[key]['path'] = path
265         c[key]['max-age'] = 0
266         # Don't set expires=0 here otherwise it'll force a persistent cookie
267         c[key]['version'] = 1
268         return c
269
270     def CheckCookie(self, authcontext, user=None):
271         # Two results can occur: we return 1 meaning the cookie authentication
272         # succeeded for the authorization context, we return 0 meaning the
273         # authentication failed.
274         #
275         # Dig out the cookie data, which better be passed on this cgi
276         # environment variable.  If there's no cookie data, we reject the
277         # authentication.
278         cookiedata = os.environ.get('HTTP_COOKIE')
279         if not cookiedata:
280             return False
281         # We can't use the Cookie module here because it isn't liberal in what
282         # it accepts.  Feed it a MM2.0 cookie along with a MM2.1 cookie and
283         # you get a CookieError. :(.  All we care about is accessing the
284         # cookie data via getitem, so we'll use our own parser, which returns
285         # a dictionary.
286         c = parsecookie(cookiedata)
287         # If the user was not supplied, but the authcontext is AuthUser, we
288         # can try to glean the user address from the cookie key.  There may be
289         # more than one matching key (if the user has multiple accounts
290         # subscribed to this list), but any are okay.
291         if authcontext == mm_cfg.AuthUser:
292             if user:
293                 usernames = [user]
294             else:
295                 usernames = []
296                 prefix = self.internal_name() + '+user+'
297                 for k in c.keys():
298                     if k.startswith(prefix):
299                         usernames.append(k[len(prefix):])
300             # If any check out, we're golden.  Note: `@'s are no longer legal
301             # values in cookie keys.
302             for user in [Utils.UnobscureEmail(u) for u in usernames]:
303                 ok = self.__checkone(c, authcontext, user)
304                 if ok:
305                     return True
306             return False
307         else:
308             return self.__checkone(c, authcontext, user)
309
310     def __checkone(self, c, authcontext, user):
311         # Do the guts of the cookie check, for one authcontext/user
312         # combination.
313         try:
314             key, secret = self.AuthContextInfo(authcontext, user)
315         except Errors.NotAMemberError:
316             return False
317         if not c.has_key(key) or not isinstance(secret, StringType):
318             return False
319         # Undo the encoding we performed in MakeCookie() above.  BAW: I
320         # believe this is safe from exploit because marshal can't be forced to
321         # load recursive data structures, and it can't be forced to execute
322         # any unexpected code.  The worst that can happen is that either the
323         # client will have provided us bogus data, in which case we'll get one
324         # of the caught exceptions, or marshal format will have changed, in
325         # which case, the cookie decoding will fail.  In either case, we'll
326         # simply request reauthorization, resulting in a new cookie being
327         # returned to the client.
328         try:
329             data = marshal.loads(binascii.unhexlify(c[key]))
330             issued, received_mac = data
331         except (EOFError, ValueError, TypeError, KeyError):
332             return False
333         # Make sure the issued timestamp makes sense
334         now = time.time()
335         if now < issued:
336             return False
337         # Calculate what the mac ought to be based on the cookie's timestamp
338         # and the shared secret.
339         mac = sha.new(secret + `issued`).hexdigest()
340         if mac <> received_mac:
341             return False
342         # Authenticated!
343         return True
344
345
346 \f
347 splitter = re.compile(';\s*')
348
349 def parsecookie(s):
350     c = {}
351     for line in s.splitlines():
352         for p in splitter.split(line):
353             try:
354                 k, v = p.split('=', 1)
355             except ValueError:
356                 pass
357             else:
358                 c[k] = v
359     return c