# Copyright (C) 1998,1999,2000,2001,2002 by the Free Software Foundation, Inc.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Handle passwords and sanitize approved messages."""
# There are current 5 roles defined in Mailman, as codified in Defaults.py:
# user, list-creator, list-moderator, list-admin, site-admin.
#
# Here's how we do cookie based authentication.
#
# Each role (see above) has an associated password, which is currently the
# only way to authenticate a role (in the future, we'll authenticate a
# user and assign users to roles).
#
# Each cookie has the following ingredients: the authorization context's
# secret (i.e. the password, and a timestamp. We generate an SHA1 hex
# digest of these ingredients, which we call the `mac'. We then marshal
# up a tuple of the timestamp and the mac, hexlify that and return that as
# a cookie keyed off the authcontext. Note that authenticating the user
# also requires the user's email address to be included in the cookie.
#
# The verification process is done in CheckCookie() below. It extracts
# the cookie, unhexlifies and unmarshals the tuple, extracting the
# timestamp. Using this, and the shared secret, the mac is calculated,
# and it must match the mac passed in the cookie. If so, they're golden,
# otherwise, access is denied.
#
# It is still possible for an adversary to attempt to brute force crack
# the password if they obtain the cookie, since they can extract the
# timestamp and create macs based on password guesses. They never get a
# cleartext version of the password though, so security rests on the
# difficulty and expense of retrying the cgi dialog for each attempt. It
# also relies on the security of SHA1.
import os
import time
import sha
import marshal
import binascii
import Cookie
from types import StringType, TupleType
from urlparse import urlparse
try:
import crypt
except ImportError:
crypt = None
import md5
from Mailman import mm_cfg
from Mailman import Utils
from Mailman import Errors
from Mailman.Logging.Syslog import syslog
class SecurityManager:
def InitVars(self):
# We used to set self.password here, from a crypted_password argument,
# but that's been removed when we generalized the mixin architecture.
# self.password is really a SecurityManager attribute, but it's set in
# MailList.InitVars().
self.mod_password = None
# Non configurable
self.passwords = {}
def AuthContextInfo(self, authcontext, user=None):
# authcontext may be one of AuthUser, AuthListModerator,
# AuthListAdmin, AuthSiteAdmin. Not supported is the AuthCreator
# context.
#
# user is ignored unless authcontext is AuthUser
#
# Return the authcontext's secret and cookie key. If the authcontext
# doesn't exist, return the tuple (None, None). If authcontext is
# AuthUser, but the user isn't a member of this mailing list, a
# NotAMemberError will be raised. If the user's secret is None, raise
# a MMBadUserError.
key = self.internal_name() + '+'
if authcontext == mm_cfg.AuthUser:
if user is None:
# A bad system error
raise TypeError, 'No user supplied for AuthUser context'
secret = self.getMemberPassword(user)
key += 'user+%s' % Utils.ObscureEmail(user)
elif authcontext == mm_cfg.AuthListModerator:
secret = self.mod_password
key += 'moderator'
elif authcontext == mm_cfg.AuthListAdmin:
secret = self.password
key += 'admin'
# BAW: AuthCreator
elif authcontext == mm_cfg.AuthSiteAdmin:
sitepass = Utils.get_global_password()
if mm_cfg.ALLOW_SITE_ADMIN_COOKIES and sitepass:
secret = sitepass
key = 'site'
else:
# BAW: this should probably hand out a site password based
# cookie, but that makes me a bit nervous, so just treat site
# admin as a list admin since there is currently no site
# admin-only functionality.
secret = self.password
key += 'admin'
else:
return None, None
return key, secret
def Authenticate(self, authcontexts, response, user=None):
# Given a list of authentication contexts, check to see if the
# response matches one of the passwords. authcontexts must be a
# sequence, and if it contains the context AuthUser, then the user
# argument must not be None.
#
# Return the authcontext from the argument sequence that matches the
# response, or UnAuthorized.
for ac in authcontexts:
if ac == mm_cfg.AuthCreator:
ok = Utils.check_global_password(response, siteadmin=0)
if ok:
return mm_cfg.AuthCreator
elif ac == mm_cfg.AuthSiteAdmin:
ok = Utils.check_global_password(response)
if ok:
return mm_cfg.AuthSiteAdmin
elif ac == mm_cfg.AuthListAdmin:
def cryptmatchp(response, secret):
try:
salt = secret[:2]
if crypt and crypt.crypt(response, salt) == secret:
return 1
return 0
except TypeError:
# BAW: Hard to say why we can get a TypeError here.
# SF bug report #585776 says crypt.crypt() can raise
# this if salt contains null bytes, although I don't
# know how that can happen (perhaps if a MM2.0 list
# with USE_CRYPT = 0 has been updated? Doubtful.
return 0
# The password for the list admin and list moderator are not
# kept as plain text, but instead as an sha hexdigest. The
# response being passed in is plain text, so we need to
# digestify it first. Note however, that for backwards
# compatibility reasons, we'll also check the admin response
# against the crypted and md5'd passwords, and if they match,
# we'll auto-migrate the passwords to sha.
key, secret = self.AuthContextInfo(ac)
if secret is None:
continue
sharesponse = sha.new(response).hexdigest()
upgrade = ok = 0
if sharesponse == secret:
ok = 1
elif md5.new(response).digest() == secret:
ok = 1
upgrade = 1
elif cryptmatchp(response, secret):
ok = 1
upgrade = 1
if upgrade:
save_and_unlock = 0
if not self.Locked():
self.Lock()
save_and_unlock = 1
try:
self.password = sharesponse
if save_and_unlock:
self.Save()
finally:
if save_and_unlock:
self.Unlock()
if ok:
return ac
elif ac == mm_cfg.AuthListModerator:
# The list moderator password must be sha'd
key, secret = self.AuthContextInfo(ac)
if secret and sha.new(response).hexdigest() == secret:
return ac
elif ac == mm_cfg.AuthUser:
if self.authenticateMember(user, response):
return ac
else:
# What is this context???
syslog('error', 'Bad authcontext: %s', ac)
raise ValueError, 'Bad authcontext: %s' % ac
return mm_cfg.UnAuthorized
def WebAuthenticate(self, authcontexts, response, user=None):
# Given a list of authentication contexts, check to see if the cookie
# contains a matching authorization, falling back to checking whether
# the response matches one of the passwords. authcontexts must be a
# sequence, and if it contains the context AuthUser, then the user
# argument must not be None.
#
# Returns a flag indicating whether authentication succeeded or not.
try:
for ac in authcontexts:
ok = self.CheckCookie(ac, user)
if ok:
return 1
# Check passwords
ac = self.Authenticate(authcontexts, response, user)
if ac:
print self.MakeCookie(ac, user)
return 1
except Errors.NotAMemberError:
pass
return 0
def MakeCookie(self, authcontext, user=None):
key, secret = self.AuthContextInfo(authcontext, user)
if key is None or secret is None or not isinstance(secret, StringType):
raise ValueError
# Timestamp
issued = int(time.time())
# Get a digest of the secret, plus other information.
mac = sha.new(secret + `issued`).hexdigest()
# Create the cookie object.
c = Cookie.SimpleCookie()
c[key] = binascii.hexlify(marshal.dumps((issued, mac)))
# The path to all Mailman stuff, minus the scheme and host,
# i.e. usually the string `/mailman'
path = urlparse(self.web_page_url)[2]
c[key]['path'] = path
# We use session cookies, so don't set `expires' or `max-age' keys.
# Set the RFC 2109 required header.
c[key]['version'] = 1
return c
def ZapCookie(self, authcontext, user=None):
# We can throw away the secret.
key, secret = self.AuthContextInfo(authcontext, user)
# Logout of the session by zapping the cookie. For safety both set
# max-age=0 (as per RFC2109) and set the cookie data to the empty
# string.
c = Cookie.SimpleCookie()
c[key] = ''
# The path to all Mailman stuff, minus the scheme and host,
# i.e. usually the string `/mailman'
path = urlparse(self.web_page_url)[2]
c[key]['path'] = path
c[key]['max-age'] = 0
# Don't set expires=0 here otherwise it'll force a persistent cookie
c[key]['version'] = 1
return c
def CheckCookie(self, authcontext, user=None):
# Two results can occur: we return 1 meaning the cookie authentication
# succeeded for the authorization context, we return 0 meaning the
# authentication failed.
#
# Dig out the cookie data, which better be passed on this cgi
# environment variable. If there's no cookie data, we reject the
# authentication.
cookiedata = os.environ.get('HTTP_COOKIE')
if not cookiedata:
return 0
# Treat the cookie data as simple strings, and do application level
# decoding as necessary. By using SimpleCookie, we prevent any kind
# of security breach due to untrusted cookie data being unpickled
# (which is quite unsafe).
try:
c = Cookie.SimpleCookie(cookiedata)
except Cookie.CookieError:
return 0
# If the user was not supplied, but the authcontext is AuthUser, we
# can try to glean the user address from the cookie key. There may be
# more than one matching key (if the user has multiple accounts
# subscribed to this list), but any are okay.
if authcontext == mm_cfg.AuthUser:
if user:
usernames = [user]
else:
usernames = []
prefix = self.internal_name() + '+user+'
for k in c.keys():
if k.startswith(prefix):
usernames.append(k[len(prefix):])
# If any check out, we're golden. Note: `@'s are no longer legal
# values in cookie keys.
for user in [Utils.UnobscureEmail(u) for u in usernames]:
ok = self.__checkone(c, authcontext, user)
if ok:
return 1
return 0
else:
return self.__checkone(c, authcontext, user)
def __checkone(self, c, authcontext, user):
# Do the guts of the cookie check, for one authcontext/user
# combination.
key, secret = self.AuthContextInfo(authcontext, user)
if not c.has_key(key) or not isinstance(secret, StringType):
return 0
# Undo the encoding we performed in MakeCookie() above. BAW: I
# believe this is safe from exploit because marshal can't be forced to
# load recursive data structures, and it can't be forced to execute
# any unexpected code. The worst that can happen is that either the
# client will have provided us bogus data, in which case we'll get one
# of the caught exceptions, or marshal format will have changed, in
# which case, the cookie decoding will fail. In either case, we'll
# simply request reauthorization, resulting in a new cookie being
# returned to the client.
try:
data = marshal.loads(binascii.unhexlify(c[key].value))
issued, received_mac = data
except (EOFError, ValueError, TypeError, KeyError):
return 0
# Make sure the issued timestamp makes sense
now = time.time()
if now < issued:
return 0
# Calculate what the mac ought to be based on the cookie's timestamp
# and the shared secret.
mac = sha.new(secret + `issued`).hexdigest()
if mac <> received_mac:
return 0
# Authenticated!
return 1