diff options
| author | Barry Warsaw | 2011-04-12 18:09:36 -0400 |
|---|---|---|
| committer | Barry Warsaw | 2011-04-12 18:09:36 -0400 |
| commit | 5bb93de8db9b251a53968f0e1cf0b22d472e1a57 (patch) | |
| tree | aac85174fe3cea5e09113b9c9293fc484c773a66 /src | |
| parent | 980e9dff9811466dcb9b44539d694b6eac32a17b (diff) | |
| parent | 7c6633d17617ac60f11ff7de44160a9d804d4777 (diff) | |
| download | mailman-5bb93de8db9b251a53968f0e1cf0b22d472e1a57.tar.gz mailman-5bb93de8db9b251a53968f0e1cf0b22d472e1a57.tar.zst mailman-5bb93de8db9b251a53968f0e1cf0b22d472e1a57.zip | |
Diffstat (limited to 'src')
35 files changed, 904 insertions, 196 deletions
diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py index fcbedc2f5..aaf7f05df 100644 --- a/src/mailman/app/membership.py +++ b/src/mailman/app/membership.py @@ -39,6 +39,7 @@ from mailman.interfaces.member import ( NotAMemberError) from mailman.interfaces.usermanager import IUserManager from mailman.utilities.i18n import make +from mailman.utilities.passwords import encrypt_password @@ -94,9 +95,9 @@ def add_member(mlist, email, realname, password, delivery_mode, language): user = user_manager.create_user() user.real_name = (realname if realname else address.real_name) user.link(address) - # Since created the user, then the member, and set preferences on the - # appropriate object. - user.password = password + # Encrypt the password using the currently selected scheme. The + # scheme is recorded in the hashed password string. + user.password = encrypt_password(password) user.preferences.preferred_language = language member = address.subscribe(mlist, MemberRole.member) member.preferences.delivery_mode = delivery_mode diff --git a/src/mailman/app/tests/test_membership.py b/src/mailman/app/tests/test_membership.py index b0e1bae5d..2b69c7f39 100644 --- a/src/mailman/app/tests/test_membership.py +++ b/src/mailman/app/tests/test_membership.py @@ -31,6 +31,7 @@ from zope.component import getUtility from mailman.app.lifecycle import create_list from mailman.app.membership import add_member +from mailman.config import config from mailman.core.constants import system_preferences from mailman.interfaces.bans import IBanManager from mailman.interfaces.member import DeliveryMode, MembershipIsBannedError @@ -125,7 +126,34 @@ class AddMemberTest(unittest.TestCase): +class AddMemberPasswordTest(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + # The default ssha scheme introduces a random salt, which is + # inappropriate for unit tests. + config.push('password scheme', """ + [passwords] + password_scheme: sha + """) + + def tearDown(self): + config.pop('password scheme') + reset_the_world() + + def test_add_member_password(self): + # Test that the password stored with the new user is encrypted. + member = add_member(self._mlist, 'anne@example.com', + 'Anne Person', 'abc', DeliveryMode.regular, + system_preferences.preferred_language) + self.assertEqual( + member.user.password, '{SHA}qZk-NkcGgWq6PiVxeFDCbJzQ2J0=') + + + def test_suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(AddMemberTest)) + suite.addTest(unittest.makeSuite(AddMemberPasswordTest)) return suite diff --git a/src/mailman/bin/mmsitepass.py b/src/mailman/bin/mmsitepass.py deleted file mode 100644 index c17d87526..000000000 --- a/src/mailman/bin/mmsitepass.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -import sys -import getpass -import optparse - -from mailman import Utils -from mailman import passwords -from mailman.configuration import config -from mailman.core.i18n import _ -from mailman.initialize import initialize -from mailman.version import MAILMAN_VERSION - - - -def parseargs(): - parser = optparse.OptionParser(version=MAILMAN_VERSION, - usage=_("""\ -%prog [options] [password] - -Set the site or list creator password. - -The site password can be used in most if not all places that the list -administrator's password can be used, which in turn can be used in most places -that a list user's password can be used. The list creator password is a -separate password that can be given to non-site administrators to delegate the -ability to create new mailing lists. - -If password is not given on the command line, it will be prompted for. -""")) - parser.add_option('-c', '--listcreator', - default=False, action='store_true', - help=_("""\ -Set the list creator password instead of the site password. The list -creator is authorized to create and remove lists, but does not have -the total power of the site administrator.""")) - parser.add_option('-p', '--password-scheme', - default='', type='string', - help=_("""\ -Specify the RFC 2307 style hashing scheme for passwords included in the -output. Use -P to get a list of supported schemes, which are -case-insensitive.""")) - parser.add_option('-P', '--list-hash-schemes', - default=False, action='store_true', help=_("""\ -List the supported password hashing schemes and exit. The scheme labels are -case-insensitive.""")) - parser.add_option('-C', '--config', - help=_('Alternative configuration file to use')) - opts, args = parser.parse_args() - if len(args) > 1: - parser.error(_('Unexpected arguments')) - if opts.list_hash_schemes: - for label in passwords.Schemes: - print str(label).upper() - sys.exit(0) - return parser, opts, args - - -def check_password_scheme(parser, password_scheme): - # shoule be checked after config is loaded. - if password_scheme == '': - password_scheme = config.PASSWORD_SCHEME - scheme = passwords.lookup_scheme(password_scheme.lower()) - if not scheme: - parser.error(_('Invalid password scheme')) - return scheme - - - -def main(): - parser, opts, args = parseargs() - initialize(opts.config) - opts.password_scheme = check_password_scheme(parser, opts.password_scheme) - if args: - password = args[0] - else: - # Prompt for the password - if opts.listcreator: - prompt_1 = _('New list creator password: ') - else: - prompt_1 = _('New site administrator password: ') - pw1 = getpass.getpass(prompt_1) - pw2 = getpass.getpass(_('Enter password again to confirm: ')) - if pw1 <> pw2: - print _('Passwords do not match; no changes made.') - sys.exit(1) - password = pw1 - Utils.set_global_password(password, - not opts.listcreator, opts.password_scheme) - if Utils.check_global_password(password, not opts.listcreator): - print _('Password changed.') - else: - print _('Password change failed.') - - - -if __name__ == '__main__': - main() diff --git a/src/mailman/commands/cli_members.py b/src/mailman/commands/cli_members.py index cd7fcfbf1..96469c0f1 100644 --- a/src/mailman/commands/cli_members.py +++ b/src/mailman/commands/cli_members.py @@ -40,6 +40,7 @@ from mailman.interfaces.command import ICLISubCommand from mailman.interfaces.listmanager import IListManager from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, DeliveryStatus) +from mailman.utilities.passwords import make_user_friendly_password @@ -196,8 +197,10 @@ class Members: real_name, email = parseaddr(line) real_name = real_name.decode(fp.encoding) email = email.decode(fp.encoding) + # Give the user a default, user-friendly password. + password = make_user_friendly_password() try: - add_member(mlist, email, real_name, None, + add_member(mlist, email, real_name, password, DeliveryMode.regular, mlist.preferred_language.code) except AlreadySubscribedError: diff --git a/src/mailman/commands/docs/info.txt b/src/mailman/commands/docs/info.txt index 12fce3223..9658e93e6 100644 --- a/src/mailman/commands/docs/info.txt +++ b/src/mailman/commands/docs/info.txt @@ -60,7 +60,6 @@ The File System Hierarchy layout is the same every by definition. ... File system paths: BIN_DIR = /sbin - CREATOR_PW_FILE = /var/lib/mailman/data/creator.pw DATA_DIR = /var/lib/mailman/data ETC_DIR = /etc EXT_DIR = /etc/mailman.d @@ -73,7 +72,6 @@ The File System Hierarchy layout is the same every by definition. PRIVATE_ARCHIVE_FILE_DIR = /var/lib/mailman/archives/private PUBLIC_ARCHIVE_FILE_DIR = /var/lib/mailman/archives/public QUEUE_DIR = /var/spool/mailman - SITE_PW_FILE = /var/lib/mailman/data/adm.pw TEMPLATE_DIR = .../mailman/templates VAR_DIR = /var/lib/mailman diff --git a/src/mailman/commands/docs/membership.txt b/src/mailman/commands/docs/membership.txt index 0da7ffadf..851f01514 100644 --- a/src/mailman/commands/docs/membership.txt +++ b/src/mailman/commands/docs/membership.txt @@ -185,7 +185,7 @@ Joining a second list Anne of course, is still registered. >>> print user_manager.get_user('anne@example.com') - <User "Anne Person" at ...> + <User "Anne Person" (...) at ...> But she is not a member of the mailing list. @@ -363,7 +363,7 @@ a user of the system. Now Bart is a user... >>> print user_manager.get_user('bart@example.com') - <User "Bart Person" at ...> + <User "Bart Person" (...) at ...> ...and a member of the mailing list. diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py index 636b9ef9e..9c210b6a2 100644 --- a/src/mailman/config/config.py +++ b/src/mailman/config/config.py @@ -183,10 +183,8 @@ class Configuration: if category.template_dir == ':source:' else category.template_dir), # Files. - creator_pw_file = category.creator_pw_file, lock_file = category.lock_file, pid_file = category.pid_file, - site_pw_file = category.site_pw_file, ) # Now, perform substitutions recursively until there are no more # variables with $-vars in them, or until substitutions are not diff --git a/src/mailman/config/mailman.cfg b/src/mailman/config/mailman.cfg index f6811d7c9..d7bc0fded 100644 --- a/src/mailman/config/mailman.cfg +++ b/src/mailman/config/mailman.cfg @@ -38,8 +38,6 @@ lock_dir: /var/lock/mailman etc_dir: /etc ext_dir: /etc/mailman.d pid_file: /var/run/mailman/master-qrunner.pid -creator_pw_file: $data_dir/creator.pw -site_pw_file: $data_dir/adm.pw [language.en] diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index 09f575459..030e5fc2c 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -115,10 +115,6 @@ template_dir: :source: # # This is where PID file for the master queue runner is stored. pid_file: $var_dir/master-qrunner.pid -# The site administrators password [obsolete]. -site_pw_file: $var_dir/adm.pw -# The site list creator's password [obsolete]. -creator_pw_file: $var_dir/creator.pw # Lock file. lock_file: $lock_dir/master-qrunner.lck @@ -134,10 +130,19 @@ enabled: no # enabled. This way messages can't be accidentally sent to real addresses. recipient: +# This gets set by the testing layers so that the qrunner subprocesses produce +# predictable dates and times. +testing: no + [passwords] -# When Mailman generates them, this is the default length of member passwords. -member_password_length: 8 +# The default scheme to use to encrypt new passwords. Existing passwords +# include the scheme that was used to encrypt them, so it's okay to change +# this after users have been added. +password_scheme: ssha + +# When Mailman generates them, this is the default length of passwords. +password_length: 8 # Specify the type of passwords to use, when Mailman generates the passwords # itself, as would be the case for membership requests where the user did not diff --git a/src/mailman/database/mailman.sql b/src/mailman/database/mailman.sql index 9d2b015b7..7c09fb79f 100644 --- a/src/mailman/database/mailman.sql +++ b/src/mailman/database/mailman.sql @@ -251,11 +251,17 @@ CREATE TABLE preferences ( CREATE TABLE user ( id INTEGER NOT NULL, real_name TEXT, - password TEXT, + password BINARY, + _user_id TEXT, + _created_on TIMESTAMP, preferences_id INTEGER, PRIMARY KEY (id), - CONSTRAINT user_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id) + CONSTRAINT user_preferences_id_fk + FOREIGN KEY(preferences_id) + REFERENCES preferences (id) ); +CREATE INDEX ix_user_user_id ON user (_user_id); + CREATE TABLE version ( id INTEGER NOT NULL, component TEXT, diff --git a/src/mailman/interfaces/address.py b/src/mailman/interfaces/address.py index 391eae849..c051c9b0c 100644 --- a/src/mailman/interfaces/address.py +++ b/src/mailman/interfaces/address.py @@ -40,6 +40,12 @@ from mailman.interfaces.errors import MailmanError class AddressError(MailmanError): """A general address-related error occurred.""" + def __init__(self, address): + self.address = address + + def __str__(self): + return self.address + class ExistingAddressError(AddressError): """The given email address already exists.""" diff --git a/src/mailman/interfaces/member.py b/src/mailman/interfaces/member.py index d20580498..a5e693411 100644 --- a/src/mailman/interfaces/member.py +++ b/src/mailman/interfaces/member.py @@ -127,6 +127,9 @@ class IMember(Interface): address = Attribute( """The email address that's subscribed to the list.""") + user = Attribute( + """The user associated with this member.""") + preferences = Attribute( """This member's preferences.""") diff --git a/src/mailman/interfaces/user.py b/src/mailman/interfaces/user.py index 5e894701f..2c2652413 100644 --- a/src/mailman/interfaces/user.py +++ b/src/mailman/interfaces/user.py @@ -38,6 +38,12 @@ class IUser(Interface): password = Attribute( """This user's password information.""") + user_id = Attribute( + """The user's unique, random, identifier (sha1 hex digest).""") + + created_on = Attribute( + """The date and time at which this user was created.""") + addresses = Attribute( """An iterator over all the `IAddresses` controlled by this user.""") diff --git a/src/mailman/model/docs/addresses.txt b/src/mailman/model/docs/addresses.txt index 0ddacb321..fdcc993b5 100644 --- a/src/mailman/model/docs/addresses.txt +++ b/src/mailman/model/docs/addresses.txt @@ -89,7 +89,7 @@ And now you can find the associated user. >>> print user_manager.get_user('bperson@example.com') None >>> user_manager.get_user('cperson@example.com') - <User "Claire Person" at ...> + <User "Claire Person" (...) at ...> Deleting addresses diff --git a/src/mailman/model/docs/membership.txt b/src/mailman/model/docs/membership.txt index 00e79d733..6f5b82622 100644 --- a/src/mailman/model/docs/membership.txt +++ b/src/mailman/model/docs/membership.txt @@ -44,7 +44,7 @@ in the user database yet. >>> user_manager = getUtility(IUserManager) >>> user_1 = user_manager.create_user('aperson@example.com', 'Anne Person') >>> print user_1 - <User "Anne Person" at ...> + <User "Anne Person" (...) at ...> We can add Anne as an owner of the mailing list, by creating a member role for her. @@ -71,7 +71,7 @@ Bart becomes a moderator of the list. >>> user_2 = user_manager.create_user('bperson@example.com', 'Bart Person') >>> print user_2 - <User "Bart Person" at ...> + <User "Bart Person" (...) at ...> >>> address_2 = list(user_2.addresses)[0] >>> address_2.subscribe(mlist, MemberRole.moderator) <Member: Bart Person <bperson@example.com> @@ -102,10 +102,16 @@ role. >>> user_3 = user_manager.create_user( ... 'cperson@example.com', 'Cris Person') >>> address_3 = list(user_3.addresses)[0] - >>> address_3.subscribe(mlist, MemberRole.member) + >>> member = address_3.subscribe(mlist, MemberRole.member) + >>> member <Member: Cris Person <cperson@example.com> on test@example.com as MemberRole.member> +Cris's user record can also be retrieved from her member record. + + >>> member.user + <User "Cris Person" (3) at ...> + Cris will be a regular delivery member but not a digest member. >>> dump_members(mlist.members.members) diff --git a/src/mailman/model/docs/registration.txt b/src/mailman/model/docs/registration.txt index e92c63f52..d0827d37b 100644 --- a/src/mailman/model/docs/registration.txt +++ b/src/mailman/model/docs/registration.txt @@ -186,7 +186,7 @@ an `IUser` linked to this address. The `IAddress` is verified. <Address: Anne Person <aperson@example.com> [verified] at ...> >>> found_user = user_manager.get_user('aperson@example.com') >>> found_user - <User "Anne Person" at ...> + <User "Anne Person" (...) at ...> >>> found_user.controls(found_address.email) True >>> from datetime import datetime @@ -231,7 +231,7 @@ confirmation step is completed. >>> registrar.confirm(sent_token) True >>> user_manager.get_user('cperson@example.com') - <User "Claire Person" at ...> + <User "Claire Person" (...) at ...> >>> user_manager.get_address('cperson@example.com') <Address: cperson@example.com [verified] at ...> @@ -276,7 +276,7 @@ can be used. >>> dperson = user_manager.create_user( ... 'dperson@example.com', 'Dave Person') >>> dperson - <User "Dave Person" at ...> + <User "Dave Person" (...) at ...> >>> address = user_manager.get_address('dperson@example.com') >>> address.verified_on = datetime.now() @@ -297,7 +297,7 @@ can be used. >>> user is dperson True >>> user - <User "Dave Person" at ...> + <User "Dave Person" (...) at ...> >>> dump_list(repr(address) for address in user.addresses) <Address: Dave Person <dperson@example.com> [verified] at ...> <Address: David Person <david.person@example.com> [verified] at ...> diff --git a/src/mailman/model/docs/requests.txt b/src/mailman/model/docs/requests.txt index 94c81e1dc..2ff173422 100644 --- a/src/mailman/model/docs/requests.txt +++ b/src/mailman/model/docs/requests.txt @@ -692,15 +692,9 @@ Frank Person is now a member of the mailing list. <Language [en] English (USA)> >>> print member.delivery_mode DeliveryMode.regular - - >>> from mailman.interfaces.usermanager import IUserManager - >>> from zope.component import getUtility - >>> user_manager = getUtility(IUserManager) - - >>> user = user_manager.get_user(member.address.email) - >>> print user.real_name + >>> print member.user.real_name Frank Person - >>> print user.password + >>> print member.user.password {NONE}abcxyz @@ -713,6 +707,11 @@ unsubscription holds can send the list's moderators an immediate notification. :: + + >>> from mailman.interfaces.usermanager import IUserManager + >>> from zope.component import getUtility + >>> user_manager = getUtility(IUserManager) + >>> mlist.admin_immed_notify = False >>> from mailman.interfaces.member import MemberRole >>> user_1 = user_manager.create_user('gperson@example.com') diff --git a/src/mailman/model/docs/usermanager.txt b/src/mailman/model/docs/usermanager.txt index 7b333248c..e427eb63a 100644 --- a/src/mailman/model/docs/usermanager.txt +++ b/src/mailman/model/docs/usermanager.txt @@ -44,7 +44,7 @@ A user can be assigned a real name. A user can be assigned a password. - >>> user.password = 'secret' + >>> user.password = b'secret' >>> dump_list(user.password for user in user_manager.users) secret @@ -118,7 +118,7 @@ that the ``.get_user()`` method takes a string email address, not an >>> address = list(user_4.addresses)[0] >>> found_user = user_manager.get_user(address.email) >>> found_user - <User "Dan Person" at ...> + <User "Dan Person" (...) at ...> >>> found_user is user_4 True @@ -130,3 +130,18 @@ with it, you will get ``None`` back. >>> user_4.unlink(address) >>> print user_manager.get_user(address.email) None + +Users can also be found by their unique user id. + + >>> found_user = user_manager.get_user_by_id(user_4.user_id) + >>> user_4 + <User "Dan Person" (...) at ...> + >>> found_user + <User "Dan Person" (...) at ...> + >>> user_4.user_id == found_user.user_id + True + +If a non-existent user id is given, None is returned. + + >>> print user_manager.get_user_by_id('missing') + None diff --git a/src/mailman/model/docs/users.txt b/src/mailman/model/docs/users.txt index bbfef8391..c8244c506 100644 --- a/src/mailman/model/docs/users.txt +++ b/src/mailman/model/docs/users.txt @@ -3,8 +3,8 @@ Users ===== Users are entities that represent people. A user has a real name and a -password. Optionally a user may have some preferences and a set of addresses -they control. A user also knows which mailing lists they are subscribed to. +optional encoded password. A user may also have an optional preferences and a +set of addresses they control. See `usermanager.txt`_ for examples of how to create, delete, and find users. @@ -19,7 +19,7 @@ User data Users may have a real name and a password. >>> user_1 = user_manager.create_user() - >>> user_1.password = 'my password' + >>> user_1.password = b'my password' >>> user_1.real_name = 'Zoe Person' >>> dump_list(user.real_name for user in user_manager.users) Zoe Person @@ -29,13 +29,38 @@ Users may have a real name and a password. The password and real name can be changed at any time. >>> user_1.real_name = 'Zoe X. Person' - >>> user_1.password = 'another password' + >>> user_1.password = b'another password' >>> dump_list(user.real_name for user in user_manager.users) Zoe X. Person >>> dump_list(user.password for user in user_manager.users) another password +Basic user identification +========================= + +Although rarely visible to users, every user has a unique ID in Mailman, which +never changes. This ID is generated randomly at the time the user is +created. + + # The test suite uses a predictable user id. + >>> print user_1.user_id + 1 + +The user id cannot change. + + >>> user_1.user_id = 'foo' + Traceback (most recent call last): + ... + AttributeError: can't set attribute + +User records also have a date on which they where created. + + # The test suite uses a predictable timestamp. + >>> print user_1.created_on + 2005-08-01 07:49:23 + + Users addresses =============== diff --git a/src/mailman/model/member.py b/src/mailman/model/member.py index 5e8619324..d32c586d9 100644 --- a/src/mailman/model/member.py +++ b/src/mailman/model/member.py @@ -35,6 +35,7 @@ from mailman.database.types import Enum from mailman.interfaces.action import Action from mailman.interfaces.listmanager import IListManager from mailman.interfaces.member import IMember, MemberRole +from mailman.interfaces.usermanager import IUserManager @@ -70,6 +71,10 @@ class Member(Model): return '<Member: {0} on {1} as {2}>'.format( self.address, self.mailing_list, self.role) + @property + def user(self): + return getUtility(IUserManager).get_user(self.address.email) + def _lookup(self, preference): pref = getattr(self.preferences, preference) if pref is not None: diff --git a/src/mailman/model/user.py b/src/mailman/model/user.py index f2a7c9d18..f0048c5f4 100644 --- a/src/mailman/model/user.py +++ b/src/mailman/model/user.py @@ -24,7 +24,8 @@ __all__ = [ 'User', ] -from storm.locals import Int, Reference, ReferenceSet, Unicode +from storm.locals import ( + DateTime, Int, RawStr, Reference, ReferenceSet, Unicode) from zope.interface import implements from mailman.config import config @@ -35,6 +36,8 @@ from mailman.interfaces.user import IUser from mailman.model.address import Address from mailman.model.preferences import Preferences from mailman.model.roster import Memberships +from mailman.utilities.datetime import factory as date_factory +from mailman.utilities.uid import factory as uid_factory @@ -45,14 +48,38 @@ class User(Model): id = Int(primary=True) real_name = Unicode() - password = Unicode() + password = RawStr() + _user_id = Unicode() + _created_on = DateTime() addresses = ReferenceSet(id, 'Address.user_id') preferences_id = Int() preferences = Reference(preferences_id, 'Preferences.id') + def __init__(self, real_name=None, preferences=None): + super(User, self).__init__() + self._created_on = date_factory.now() + user_id = uid_factory.new_uid() + assert config.db.store.find(User, _user_id=user_id).count() == 0, ( + 'Duplicate user id {0}'.format(user_id)) + self._user_id = user_id + self.real_name = ('' if real_name is None else real_name) + self.preferences = preferences + config.db.store.add(self) + def __repr__(self): - return '<User "{0}" at {1:#x}>'.format(self.real_name, id(self)) + return '<User "{0.real_name}" ({0.user_id}) at {1:#x}>'.format( + self, id(self)) + + @property + def user_id(self): + """See `IUser`.""" + return self._user_id + + @property + def created_on(self): + """See `IUser`.""" + return self._created_on def link(self, address): """See `IUser`.""" diff --git a/src/mailman/model/usermanager.py b/src/mailman/model/usermanager.py index 067ed7795..d6817021d 100644 --- a/src/mailman/model/usermanager.py +++ b/src/mailman/model/usermanager.py @@ -40,13 +40,10 @@ class UserManager: implements(IUserManager) def create_user(self, email=None, real_name=None): - user = User() - user.real_name = ('' if real_name is None else real_name) + user = User(real_name, Preferences()) if email: address = self.create_address(email, real_name) user.link(address) - user.preferences = Preferences() - config.db.store.add(user) return user def delete_user(self, user): @@ -56,10 +53,13 @@ class UserManager: addresses = config.db.store.find(Address, email=email.lower()) if addresses.count() == 0: return None - elif addresses.count() == 1: - return addresses[0].user - else: - raise AssertionError('Unexpected query count') + return addresses.one().user + + def get_user_by_id(self, user_id): + users = config.db.store.find(User, _user_id=user_id) + if users.count() == 0: + return None + return users.one() @property def users(self): @@ -92,10 +92,7 @@ class UserManager: addresses = config.db.store.find(Address, email=email.lower()) if addresses.count() == 0: return None - elif addresses.count() == 1: - return addresses[0] - else: - raise AssertionError('Unexpected query count') + return addresses.one() @property def addresses(self): diff --git a/src/mailman/mta/docs/decorating.txt b/src/mailman/mta/docs/decorating.txt index 4edac481f..0bc9649c8 100644 --- a/src/mailman/mta/docs/decorating.txt +++ b/src/mailman/mta/docs/decorating.txt @@ -68,17 +68,17 @@ list. >>> user_manager = getUtility(IUserManager) >>> anne = user_manager.create_user('aperson@example.com', 'Anne Person') - >>> anne.password = 'AAA' + >>> anne.password = b'AAA' >>> list(anne.addresses)[0].subscribe(mlist, MemberRole.member) <Member: Anne Person <aperson@example.com> ... >>> bart = user_manager.create_user('bperson@example.com', 'Bart Person') - >>> bart.password = 'BBB' + >>> bart.password = b'BBB' >>> list(bart.addresses)[0].subscribe(mlist, MemberRole.member) <Member: Bart Person <bperson@example.com> ... >>> cris = user_manager.create_user('cperson@example.com', 'Cris Person') - >>> cris.password = 'CCC' + >>> cris.password = b'CCC' >>> list(cris.addresses)[0].subscribe(mlist, MemberRole.member) <Member: Cris Person <cperson@example.com> ... diff --git a/src/mailman/rest/adapters.py b/src/mailman/rest/adapters.py index 30ce99f44..5cbb89bc1 100644 --- a/src/mailman/rest/adapters.py +++ b/src/mailman/rest/adapters.py @@ -36,6 +36,7 @@ from mailman.interfaces.address import InvalidEmailAddressError from mailman.interfaces.listmanager import IListManager, NoSuchListError from mailman.interfaces.member import DeliveryMode from mailman.interfaces.membership import ISubscriptionService +from mailman.utilities.passwords import make_user_friendly_password @@ -84,15 +85,12 @@ class SubscriptionService: raise InvalidEmailAddressError(address) # Because we want to keep the REST API simple, there is no password or # language given to us. We'll use the system's default language for - # the user's default language. We'll set the password to None. XXX - # Is that a good idea? Maybe we should set it to something else, - # except that once we encode the password (as we must do to avoid - # cleartext passwords in the database) we'll never be able to retrieve - # it. - # + # the user's default language. We'll set the password to a system + # default. This will have to get reset since it can't be retrieved. # Note that none of these are used unless the address is completely # new to us. - return add_member(mlist, address, real_name, None, mode, + password = make_user_friendly_password() + return add_member(mlist, address, real_name, password, mode, system_preferences.preferred_language) def leave(self, fqdn_listname, address): diff --git a/src/mailman/rest/docs/membership.txt b/src/mailman/rest/docs/membership.txt index 553f6fde9..1e4e0414e 100644 --- a/src/mailman/rest/docs/membership.txt +++ b/src/mailman/rest/docs/membership.txt @@ -229,7 +229,7 @@ Elly is now a member of the mailing list. >>> elly = user_manager.get_user('eperson@example.com') >>> elly - <User "Elly Person" at ...> + <User "Elly Person" (...) at ...> >>> set(member.mailing_list for member in elly.memberships.members) set([u'alpha@example.com']) diff --git a/src/mailman/rest/docs/users.txt b/src/mailman/rest/docs/users.txt new file mode 100644 index 000000000..adca53ea3 --- /dev/null +++ b/src/mailman/rest/docs/users.txt @@ -0,0 +1,213 @@ +===== +Users +===== + +The REST API can be used to add and remove users, add and remove user +addresses, and change their preferred address, passord, or name. Users are +different than members; the latter represents an email address subscribed to a +specific mailing list. Users are just people that Mailman knows about. + +There are no users yet. + + >>> dump_json('http://localhost:9001/3.0/users') + http_etag: "..." + start: 0 + total_size: 0 + +When there are users in the database, they can be retrieved as a collection. +:: + + >>> from zope.component import getUtility + >>> from mailman.interfaces.usermanager import IUserManager + >>> user_manager = getUtility(IUserManager) + + >>> anne = user_manager.create_user('anne@example.com', 'Anne Person') + >>> transaction.commit() + >>> dump_json('http://localhost:9001/3.0/users') + entry 0: + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: None + real_name: Anne Person + user_id: 1 + http_etag: "..." + start: 0 + total_size: 1 + +The user ids match. + + >>> json = call_http('http://localhost:9001/3.0/users') + >>> json['entries'][0]['user_id'] == anne.user_id + True + + +Creating users via the API +========================== + +New users can be created through the REST API. To do so requires the initial +email address for the user, and optionally the user's full name and password. +:: + + >>> transaction.abort() + >>> dump_json('http://localhost:9001/3.0/users', { + ... 'email': 'bart@example.com', + ... 'real_name': 'Bart Person', + ... 'password': 'bbb', + ... }) + content-length: 0 + date: ... + location: http://localhost:9001/3.0/users/2 + server: ... + status: 201 + +The user exists in the database. +:: + + >>> bart = user_manager.get_user('bart@example.com') + >>> bart + <User "Bart Person" (2) at ...> + +It is also available via the location given in the response. + + >>> dump_json('http://localhost:9001/3.0/users/2') + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: {CLEARTEXT}bbb + real_name: Bart Person + user_id: 2 + +Because email addresses just have an ``@`` sign in then, there's no confusing +them with user ids. Thus, a user can be retrieved via its email address. + + >>> dump_json('http://localhost:9001/3.0/users/bart@example.com') + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: {CLEARTEXT}bbb + real_name: Bart Person + user_id: 2 + +Users can be created without a password. A *user friendly* password will be +assigned to them automatically, but this password will be encrypted and +therefore cannot be retrieved. It can be reset though. +:: + + >>> transaction.abort() + >>> dump_json('http://localhost:9001/3.0/users', { + ... 'email': 'cris@example.com', + ... 'real_name': 'Cris Person', + ... }) + content-length: 0 + date: ... + location: http://localhost:9001/3.0/users/3 + server: ... + status: 201 + + >>> dump_json('http://localhost:9001/3.0/users/3') + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: {CLEARTEXT}... + real_name: Cris Person + user_id: 3 + + +Missing users +============= + +It is of course an error to attempt to access a non-existent user, either by +user id... +:: + + >>> dump_json('http://localhost:9001/3.0/users/99') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 404: 404 Not Found + +...or by email address. +:: + + >>> dump_json('http://localhost:9001/3.0/users/zed@example.org') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 404: 404 Not Found + + +User addresses +============== + +Bart may have any number of email addresses associated with their user +account. We can find out all of these through the API. The addresses are +sorted in lexical order by original (i.e. case-preserved) email address. +:: + + >>> bart.register('bperson@example.com') + <Address: bperson@example.com [not verified] at ...> + >>> bart.register('bart.person@example.com') + <Address: bart.person@example.com [not verified] at ...> + >>> bart.register('Bart.Q.Person@example.com') + <Address: Bart.Q.Person@example.com [not verified] + key: bart.q.person@example.com at ...> + >>> transaction.commit() + + >>> dump_json('http://localhost:9001/3.0/users/2/addresses') + entry 0: + email: bart.q.person@example.com + http_etag: "..." + original_email: Bart.Q.Person@example.com + real_name: + registered_on: None + verified_on: None + entry 1: + email: bart.person@example.com + http_etag: "..." + original_email: bart.person@example.com + real_name: + registered_on: None + verified_on: None + entry 2: + email: bart@example.com + http_etag: "..." + original_email: bart@example.com + real_name: Bart Person + registered_on: None + verified_on: None + entry 3: + email: bperson@example.com + http_etag: "..." + original_email: bperson@example.com + real_name: + registered_on: None + verified_on: None + http_etag: "..." + start: 0 + total_size: 4 + +In fact, any of these addresses can be used to look up Bart's user record. +:: + + >>> dump_json('http://localhost:9001/3.0/users/bart@example.com') + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: {CLEARTEXT}bbb + real_name: Bart Person + user_id: 2 + + >>> dump_json('http://localhost:9001/3.0/users/bart.person@example.com') + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: {CLEARTEXT}bbb + real_name: Bart Person + user_id: 2 + + >>> dump_json('http://localhost:9001/3.0/users/bperson@example.com') + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: {CLEARTEXT}bbb + real_name: Bart Person + user_id: 2 + + >>> dump_json('http://localhost:9001/3.0/users/Bart.Q.Person@example.com') + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: {CLEARTEXT}bbb + real_name: Bart Person + user_id: 2 diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index 9d8c92428..3287a6be2 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -34,6 +34,7 @@ from mailman.rest.domains import ADomain, AllDomains from mailman.rest.helpers import etag, path_to from mailman.rest.lists import AList, AllLists from mailman.rest.members import AllMembers +from mailman.rest.users import AUser, AllUsers @@ -108,3 +109,12 @@ class TopLevel(resource.Resource): if len(segments) == 0: return AllMembers() return http.bad_request() + + @resource.child() + def users(self, request, segments): + """/<api>/users""" + if len(segments) == 0: + return AllUsers() + else: + user_id = segments.pop(0) + return AUser(user_id), segments diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py new file mode 100644 index 000000000..54402096f --- /dev/null +++ b/src/mailman/rest/users.py @@ -0,0 +1,160 @@ +# Copyright (C) 2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""REST for users.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'AUser', + 'AllUsers', + ] + + +from operator import attrgetter +from restish import http, resource +from zope.component import getUtility + +from mailman.interfaces.address import ExistingAddressError +from mailman.interfaces.usermanager import IUserManager +from mailman.rest.helpers import CollectionMixin, etag, path_to +from mailman.rest.validator import Validator +from mailman.utilities.passwords import ( + encrypt_password, make_user_friendly_password) + + + +class _UserBase(resource.Resource, CollectionMixin): + """Shared base class for user representations.""" + + def _resource_as_dict(self, user): + """See `CollectionMixin`.""" + # The canonical URL for a user is their preferred email address, + # although we can always look up a user based on any registered and + # validated email address associated with their account. + return dict( + real_name=user.real_name, + password=user.password, + user_id=user.user_id, + created_on=user.created_on, + ) + + def _get_collection(self, request): + """See `CollectionMixin`.""" + return list(getUtility(IUserManager).users) + + + +class AllUsers(_UserBase): + """The users.""" + + @resource.GET() + def collection(self, request): + """/users""" + resource = self._make_collection(request) + return http.ok([], etag(resource)) + + @resource.POST() + def create(self, request): + """Create a new user.""" + try: + validator = Validator(email=unicode, + real_name=unicode, + password=unicode, + _optional=('real_name', 'password')) + arguments = validator(request) + except ValueError as error: + return http.bad_request([], str(error)) + # We can't pass the 'password' argument to the user creation method, + # so strip that out (if it exists), then create the user, adding the + # password after the fact if successful. + password = arguments.pop('password', None) + try: + user = getUtility(IUserManager).create_user(**arguments) + except ExistingAddressError as error: + return http.bad_request([], b'Address already exists {0}'.format( + error.email)) + if password is None: + # This will have to be reset since it cannot be retrieved. + password = make_user_friendly_password() + user.password = encrypt_password(password) + location = path_to('users/{0}'.format(user.user_id)) + return http.created(location, [], None) + + + +class AUser(_UserBase): + """A user.""" + + def __init__(self, user_identifier): + """Get a user by various type of identifiers. + + :param user_identifier: The identifier used to retrieve the user. The + identifier may either be an integer user-id, or an email address + controlled by the user. The type of identifier is auto-detected + by looking for an `@` symbol, in which case it's taken as an email + address, otherwise it's assumed to be an integer. + :type user_identifier: str + """ + user_manager = getUtility(IUserManager) + if '@' in user_identifier: + self._user = user_manager.get_user(user_identifier) + else: + self._user = user_manager.get_user_by_id(user_identifier) + + @resource.GET() + def user(self, request): + """Return a single user end-point.""" + if self._user is None: + return http.not_found() + return http.ok([], self._resource_as_json(self._user)) + + @resource.child() + def addresses(self, request, segments): + """/users/<uid>/addresses""" + return _AllUserAddresses(self._user) + + + +class _AllUserAddresses(resource.Resource, CollectionMixin): + """All addresses that a user controls.""" + + def __init__(self, user): + self._user = user + super(_AllUserAddresses, self).__init__() + + def _resource_as_dict(self, address): + """See `CollectionMixin`.""" + return dict( + email=address.email, + original_email=address.original_email, + real_name=address.real_name, + registered_on=address.registered_on, + verified_on=address.verified_on, + ) + + def _get_collection(self, request): + """See `CollectionMixin`.""" + return sorted(self._user.addresses, + key=attrgetter('original_email')) + + @resource.GET() + def collection(self, request): + """/addresses""" + resource = self._make_collection(request) + return http.ok([], etag(resource)) diff --git a/src/mailman/testing/__init__.py b/src/mailman/testing/__init__.py index e69de29bb..84182e1f1 100644 --- a/src/mailman/testing/__init__.py +++ b/src/mailman/testing/__init__.py @@ -0,0 +1,39 @@ +# Copyright (C) 2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Set up testing. + +This is used as an interface to buildout.cfg's [test] section. +zope.testrunner supports an initialization variable. It is set to import and +run the following test initialization method. +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + ] + + + +def initialize(root_directory): + """Initialize the test infrastructure.""" + from mailman.testing import layers + layers.MockAndMonkeyLayer.testing_mode = True + layers.ConfigLayer.enable_stderr(); + layers.ConfigLayer.set_root_directory(root_directory) diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py index 353dd9edd..29ab7169a 100644 --- a/src/mailman/testing/layers.py +++ b/src/mailman/testing/layers.py @@ -25,6 +25,7 @@ __all__ = [ 'MockAndMonkeyLayer', 'RESTLayer', 'SMTPLayer', + 'is_testing', ] @@ -48,7 +49,6 @@ from mailman.core.logging import get_handler from mailman.interfaces.domain import IDomainManager from mailman.testing.helpers import TestableMaster, reset_the_world from mailman.testing.mta import ConnectionCountingController -from mailman.utilities.datetime import factory from mailman.utilities.string import expand @@ -60,17 +60,20 @@ NL = '\n' class MockAndMonkeyLayer: """Layer for mocking and monkey patching for testing.""" - @classmethod - def setUp(cls): - factory.testing_mode = True + # Set this to True to enable predictable datetimes, uids, etc. + testing_mode = False - @classmethod - def tearDown(cls): - factory.testing_mode = False + # A registration of all testing factories, for resetting between tests. + _resets = [] @classmethod def testTearDown(cls): - factory.reset() + for reset in cls._resets: + reset() + + @classmethod + def register_reset(cls, reset): + cls._resets.append(reset) @@ -102,8 +105,12 @@ class ConfigLayer(MockAndMonkeyLayer): test_config = dedent(""" [mailman] layout: testing + [passwords] + password_scheme: cleartext [paths.testing] var_dir: %s + [devmode] + testing: yes """ % cls.var_dir) # Read the testing config and push it. test_config += resource_string('mailman.testing', 'testing.cfg') @@ -286,3 +293,13 @@ class RESTLayer(SMTPLayer): assert cls.server is not None, 'Layer not set up' cls.server.stop() cls.server = None + + + +def is_testing(): + """Return a 'testing' flag for use with the predictable factories. + + :return: True when in testing mode. + :rtype: bool + """ + return MockAndMonkeyLayer.testing_mode or config.devmode.testing diff --git a/src/mailman/tests/test_documentation.py b/src/mailman/tests/test_documentation.py index 23cc189d0..2a72a367f 100644 --- a/src/mailman/tests/test_documentation.py +++ b/src/mailman/tests/test_documentation.py @@ -126,7 +126,7 @@ def dump_list(list_of_things, key=str): def call_http(url, data=None, method=None, username=None, password=None): - """'Call' a URL with a given HTTP method and return the resulting object. + """'Call a URL with a given HTTP method and return the resulting object. The object will have been JSON decoded. @@ -142,6 +142,8 @@ def call_http(url, data=None, method=None, username=None, password=None): :param password: The HTTP Basic Auth password. None means use the value from the configuration. :type username: str + :return: The decoded JSON data structure. + :raises HTTPError: when a non-2xx return code is received. """ headers = {} if data is not None: diff --git a/src/mailman/utilities/datetime.py b/src/mailman/utilities/datetime.py index 7e727346d..1ee727da4 100644 --- a/src/mailman/utilities/datetime.py +++ b/src/mailman/utilities/datetime.py @@ -36,25 +36,27 @@ __all__ = [ import datetime +from mailman.testing import layers + class DateFactory: """A factory for today() and now() that works with testing.""" - # Set to True to produce predictable dates and times. - testing_mode = False # The predictable time. predictable_now = None predictable_today = None def now(self, tz=None): + # We can't automatically fast-forward because some tests require us to + # stay on the same day for a while, e.g. autorespond.txt. return (self.predictable_now - if self.testing_mode + if layers.is_testing() else datetime.datetime.now(tz)) def today(self): return (self.predictable_today - if self.testing_mode + if layers.is_testing() else datetime.date.today()) @classmethod @@ -72,3 +74,4 @@ factory = DateFactory() factory.reset() today = factory.today now = factory.now +layers.MockAndMonkeyLayer.register_reset(factory.reset) diff --git a/src/mailman/utilities/passwords.py b/src/mailman/utilities/passwords.py index c14584748..896872436 100644 --- a/src/mailman/utilities/passwords.py +++ b/src/mailman/utilities/passwords.py @@ -26,24 +26,32 @@ __metaclass__ = type __all__ = [ 'Schemes', 'check_response', + 'encrypt_password', 'make_secret', + 'make_user_friendly_password', ] import os import re import hmac +import random import hashlib from array import array from base64 import urlsafe_b64decode as decode from base64 import urlsafe_b64encode as encode from flufl.enum import Enum +from itertools import chain, product +from string import ascii_lowercase +from mailman.config import config from mailman.core import errors SALT_LENGTH = 20 # bytes ITERATIONS = 2000 +EMPTYSTRING = '' +SCHEME_RE = r'{(?P<scheme>[^}]+?)}(?P<rest>.*)' @@ -288,8 +296,7 @@ def check_response(challenge, response): :return: True if the response matches the challenge. :rtype: bool """ - mo = re.match(r'{(?P<scheme>[^}]+?)}(?P<rest>.*)', - challenge, re.IGNORECASE) + mo = re.match(SCHEME_RE, challenge, re.IGNORECASE) if not mo: return False # See above for why we convert here. However because we should have @@ -315,3 +322,71 @@ def lookup_scheme(scheme_name): :rtype: `PasswordScheme` """ return _SCHEMES_BY_TAG.get(scheme_name.lower()) + + +def encrypt_password(password, scheme=None): + """Return an encrypted password. + + If the given password is already encrypted (i.e. it has a scheme prefix), + then the password is return unchanged. Otherwise, it is encrypted with + the given scheme or the default scheme. + + :param password: The plain text or encrypted password. + :type password: string + :param scheme: The scheme enum to use for encryption. If not given, the + system default scheme is used. This can be a `Schemes` enum item, or + the scheme name as a string. + :type scheme: `Schemes` enum, or string. + :return: The encrypted password. + :rtype: bytes + """ + if not isinstance(password, (bytes, unicode)): + raise ValueError('Got {0}, expected unicode or bytes'.format( + type(password))) + if re.match(SCHEME_RE, password, re.IGNORECASE): + # Just ensure we're getting bytes back. + if isinstance(password, unicode): + return password.encode('us-ascii') + assert isinstance(password, bytes), 'Expected bytes' + return password + if scheme is None: + password_scheme = lookup_scheme(config.passwords.password_scheme) + elif scheme in Schemes: + password_scheme = scheme + else: + password_scheme = lookup_scheme(scheme) + if password_scheme is None: + raise ValueError('Bad password scheme: {0}'.format(scheme)) + return make_secret(password, password_scheme) + + + +# Password generation. + +_vowels = tuple('aeiou') +_consonants = tuple(c for c in ascii_lowercase if c not in _vowels) +_syllables = tuple(x + y for (x, y) in + chain(product(_vowels, _consonants), + product(_consonants, _vowels))) + + +def make_user_friendly_password(length=None): + """Make a random *user friendly* password. + + Such passwords are nominally easier to pronounce and thus remember. Their + security in relationship to purely random passwords has not been + determined. + + :param length: Minimum length in characters for the resulting password. + The password will always be an even number of characters. When + omitted, the system default length will be used. + :type length: int + :return: The user friendly password. + :rtype: unicode + """ + if length is None: + length = int(config.passwords.password_length) + syllables = [] + while len(syllables) * 2 < length: + syllables.append(random.choice(_syllables)) + return EMPTYSTRING.join(syllables)[:length] diff --git a/src/mailman/utilities/tests/test_passwords.py b/src/mailman/utilities/tests/test_passwords.py index 60c201a5a..c9b3d2e91 100644 --- a/src/mailman/utilities/tests/test_passwords.py +++ b/src/mailman/utilities/tests/test_passwords.py @@ -27,7 +27,11 @@ __all__ = [ import unittest +from itertools import izip_longest + +from mailman.config import config from mailman.core import errors +from mailman.testing.layers import ConfigLayer from mailman.utilities import passwords @@ -138,6 +142,70 @@ class TestSchemeLookup(unittest.TestCase): +# See itertools doc page examples. +def _grouper(seq): + args = [iter(seq)] * 2 + return list(izip_longest(*args)) + + +class TestPasswordGeneration(unittest.TestCase): + layer = ConfigLayer + + def test_default_user_friendly_password_length(self): + self.assertEqual(len(passwords.make_user_friendly_password()), + int(config.passwords.password_length)) + + def test_provided_user_friendly_password_length(self): + self.assertEqual(len(passwords.make_user_friendly_password(12)), 12) + + def test_provided_odd_user_friendly_password_length(self): + self.assertEqual(len(passwords.make_user_friendly_password(15)), 15) + + def test_user_friendly_password(self): + password = passwords.make_user_friendly_password() + for pair in _grouper(password): + # There will always be one vowel and one non-vowel. + vowel = (pair[0] if pair[0] in 'aeiou' else pair[1]) + consonant = (pair[0] if pair[0] not in 'aeiou' else pair[1]) + self.assertTrue(vowel in 'aeiou', vowel) + self.assertTrue(consonant not in 'aeiou', consonant) + + def test_encrypt_password_plaintext_default_scheme(self): + # Test that a plain text password gets encrypted. + self.assertEqual(passwords.encrypt_password('abc'), + '{CLEARTEXT}abc') + + def test_encrypt_password_plaintext(self): + # Test that a plain text password gets encrypted with the given scheme. + scheme = passwords.Schemes.sha + self.assertEqual(passwords.encrypt_password('abc', scheme), + '{SHA}qZk-NkcGgWq6PiVxeFDCbJzQ2J0=') + + def test_encrypt_password_plaintext_by_scheme_name(self): + # Test that a plain text password gets encrypted with the given + # scheme, which is given by name. + self.assertEqual(passwords.encrypt_password('abc', 'cleartext'), + '{CLEARTEXT}abc') + + def test_encrypt_password_already_encrypted_default_scheme(self): + # Test that a password which is already encrypted is return unchanged. + self.assertEqual(passwords.encrypt_password('{SHA}abc'), '{SHA}abc') + + def test_encrypt_password_already_encrypted(self): + # Test that a password which is already encrypted is return unchanged, + # ignoring any requested scheme. + scheme = passwords.Schemes.cleartext + self.assertEqual(passwords.encrypt_password('{SHA}abc', scheme), + '{SHA}abc') + + def test_encrypt_password_password_value_error(self): + self.assertRaises(ValueError, passwords.encrypt_password, 7) + + def test_encrypt_password_scheme_value_error(self): + self.assertRaises(ValueError, passwords.encrypt_password, 'abc', 'foo') + + + def test_suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(TestBogusPasswords)) @@ -147,4 +215,5 @@ def test_suite(): suite.addTest(unittest.makeSuite(TestSSHAPasswords)) suite.addTest(unittest.makeSuite(TestPBKDF2Passwords)) suite.addTest(unittest.makeSuite(TestSchemeLookup)) + suite.addTest(unittest.makeSuite(TestPasswordGeneration)) return suite diff --git a/src/mailman/utilities/uid.py b/src/mailman/utilities/uid.py new file mode 100644 index 000000000..3d58cace5 --- /dev/null +++ b/src/mailman/utilities/uid.py @@ -0,0 +1,109 @@ +# Copyright (C) 2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Unique ID generation. + +Use these functions to create unique ids rather than inlining calls to hashlib +and whatnot. These are better instrumented for testing purposes. +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'UniqueIDFactory', + 'factory', + ] + + +import os +import time +import errno +import hashlib + +from flufl.lock import Lock + +from mailman.config import config +from mailman.testing import layers +from mailman.utilities.passwords import SALT_LENGTH + + + +class UniqueIDFactory: + """A factory for unique ids.""" + + def __init__(self): + # We can't call reset() when the factory is created below, because + # config.VAR_DIR will not be set at that time. So initialize it at + # the first use. + self._uid_file = None + self._lock_file = None + self._lockobj = None + + @property + def _lock(self): + if self._lockobj is None: + # These will get automatically cleaned up by the test + # infrastructure. + self._uid_file = os.path.join(config.VAR_DIR, '.uid') + self._lock_file = self._uid_file + '.lock' + self._lockobj = Lock(self._lock_file) + return self._lockobj + + def new_uid(self, bytes=None): + if layers.is_testing(): + # When in testing mode we want to produce predictable id, but we + # need to coordinate this among separate processes. We could use + # the database, but I don't want to add schema just to handle this + # case, and besides transactions could get aborted, causing some + # ids to be recycled. So we'll use a data file with a lock. This + # may still not be ideal due to race conditions, but I think the + # tests will be serialized enough (and the ids reset between + # tests) that it will not be a problem. Maybe. + return self._next_uid() + salt = os.urandom(SALT_LENGTH) + h = hashlib.sha1(repr(time.time())) + h.update(salt) + if bytes is not None: + h.update(bytes) + return unicode(h.hexdigest(), 'us-ascii') + + def _next_uid(self): + with self._lock: + try: + with open(self._uid_file) as fp: + uid = fp.read().strip() + next_uid = int(uid) + 1 + with open(self._uid_file, 'w') as fp: + fp.write(str(next_uid)) + except IOError as error: + if error.errno != errno.ENOENT: + raise + with open(self._uid_file, 'w') as fp: + fp.write('2') + return '1' + return unicode(uid, 'us-ascii') + + def reset(self): + with self._lock: + with open(self._uid_file, 'w') as fp: + fp.write('1') + + + +factory = UniqueIDFactory() +layers.MockAndMonkeyLayer.register_reset(factory.reset) |
