summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBarry Warsaw2011-04-12 18:09:36 -0400
committerBarry Warsaw2011-04-12 18:09:36 -0400
commit5bb93de8db9b251a53968f0e1cf0b22d472e1a57 (patch)
treeaac85174fe3cea5e09113b9c9293fc484c773a66
parent980e9dff9811466dcb9b44539d694b6eac32a17b (diff)
parent7c6633d17617ac60f11ff7de44160a9d804d4777 (diff)
downloadmailman-5bb93de8db9b251a53968f0e1cf0b22d472e1a57.tar.gz
mailman-5bb93de8db9b251a53968f0e1cf0b22d472e1a57.tar.zst
mailman-5bb93de8db9b251a53968f0e1cf0b22d472e1a57.zip
-rw-r--r--buildout.cfg5
-rw-r--r--src/mailman/app/membership.py7
-rw-r--r--src/mailman/app/tests/test_membership.py28
-rw-r--r--src/mailman/bin/mmsitepass.py113
-rw-r--r--src/mailman/commands/cli_members.py5
-rw-r--r--src/mailman/commands/docs/info.txt2
-rw-r--r--src/mailman/commands/docs/membership.txt4
-rw-r--r--src/mailman/config/config.py2
-rw-r--r--src/mailman/config/mailman.cfg2
-rw-r--r--src/mailman/config/schema.cfg17
-rw-r--r--src/mailman/database/mailman.sql10
-rw-r--r--src/mailman/interfaces/address.py6
-rw-r--r--src/mailman/interfaces/member.py3
-rw-r--r--src/mailman/interfaces/user.py6
-rw-r--r--src/mailman/model/docs/addresses.txt2
-rw-r--r--src/mailman/model/docs/membership.txt12
-rw-r--r--src/mailman/model/docs/registration.txt8
-rw-r--r--src/mailman/model/docs/requests.txt15
-rw-r--r--src/mailman/model/docs/usermanager.txt19
-rw-r--r--src/mailman/model/docs/users.txt33
-rw-r--r--src/mailman/model/member.py5
-rw-r--r--src/mailman/model/user.py33
-rw-r--r--src/mailman/model/usermanager.py21
-rw-r--r--src/mailman/mta/docs/decorating.txt6
-rw-r--r--src/mailman/rest/adapters.py12
-rw-r--r--src/mailman/rest/docs/membership.txt2
-rw-r--r--src/mailman/rest/docs/users.txt213
-rw-r--r--src/mailman/rest/root.py10
-rw-r--r--src/mailman/rest/users.py160
-rw-r--r--src/mailman/testing/__init__.py39
-rw-r--r--src/mailman/testing/layers.py33
-rw-r--r--src/mailman/tests/test_documentation.py4
-rw-r--r--src/mailman/utilities/datetime.py11
-rw-r--r--src/mailman/utilities/passwords.py79
-rw-r--r--src/mailman/utilities/tests/test_passwords.py69
-rw-r--r--src/mailman/utilities/uid.py109
36 files changed, 906 insertions, 199 deletions
diff --git a/buildout.cfg b/buildout.cfg
index 1dfd76a9e..c083ade7a 100644
--- a/buildout.cfg
+++ b/buildout.cfg
@@ -26,9 +26,8 @@ eggs =
mailman
defaults = '--tests-pattern ^tests --exit-with-status'.split()
# Hack in extra arguments to zope.testrunner.
-initialization = from mailman.testing.layers import ConfigLayer;
- ConfigLayer.enable_stderr();
- ConfigLayer.set_root_directory('${buildout:directory}')
+initialization = from mailman.testing import initialize;
+ initialize('${buildout:directory}')
[docs]
recipe = z3c.recipe.sphinxdoc
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)