summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/app/membership.py4
-rw-r--r--src/mailman/app/tests/test_membership.py19
-rw-r--r--src/mailman/chains/accept.py11
-rw-r--r--src/mailman/chains/base.py12
-rw-r--r--src/mailman/chains/discard.py11
-rw-r--r--src/mailman/chains/docs/moderation.rst14
-rw-r--r--src/mailman/chains/hold.py10
-rw-r--r--src/mailman/chains/owner.py10
-rw-r--r--src/mailman/chains/reject.py12
-rw-r--r--src/mailman/chains/tests/test_hold.py12
-rw-r--r--src/mailman/chains/tests/test_owner.py8
-rw-r--r--src/mailman/config/config.py12
-rw-r--r--src/mailman/config/passlib.cfg10
-rw-r--r--src/mailman/config/schema.cfg12
-rw-r--r--src/mailman/config/tests/__init__.py0
-rw-r--r--src/mailman/config/tests/test_configuration.py53
-rw-r--r--src/mailman/core/initialize.py4
-rw-r--r--src/mailman/docs/NEWS.rst29
-rw-r--r--src/mailman/interfaces/chain.py37
-rw-r--r--src/mailman/interfaces/configuration.py41
-rw-r--r--src/mailman/rest/users.py3
-rw-r--r--src/mailman/rules/approved.py12
-rw-r--r--src/mailman/rules/docs/approved.rst4
-rw-r--r--src/mailman/rules/docs/header-matching.rst10
-rw-r--r--src/mailman/rules/tests/test_approved.py89
-rw-r--r--src/mailman/runners/docs/incoming.rst10
-rw-r--r--src/mailman/testing/helpers.py8
-rw-r--r--src/mailman/testing/layers.py2
-rw-r--r--src/mailman/testing/passlib.cfg4
-rw-r--r--src/mailman/testing/testing.cfg3
-rw-r--r--src/mailman/utilities/passwords.py58
-rw-r--r--src/mailman/utilities/tests/test_passwords.py60
32 files changed, 436 insertions, 148 deletions
diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py
index e11cef44a..c73735b35 100644
--- a/src/mailman/app/membership.py
+++ b/src/mailman/app/membership.py
@@ -30,6 +30,7 @@ from email.utils import formataddr
from zope.component import getUtility
from mailman.app.notifications import send_goodbye_message
+from mailman.config import config
from mailman.core.i18n import _
from mailman.email.message import OwnerNotification
from mailman.interfaces.address import IEmailValidator
@@ -38,7 +39,6 @@ from mailman.interfaces.member import (
MemberRole, MembershipIsBannedError, NotAMemberError)
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.i18n import make
-from mailman.utilities.passwords import encrypt
@@ -96,7 +96,7 @@ def add_member(mlist, email, display_name, password, delivery_mode, language,
display_name if display_name else address.display_name)
user.link(address)
# Encrypt the password using the currently selected hash scheme.
- user.password = encrypt(password)
+ user.password = config.password_context.encrypt(password)
user.preferences.preferred_language = language
member = mlist.subscribe(address, role)
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 c3c310b6c..626be8b08 100644
--- a/src/mailman/app/tests/test_membership.py
+++ b/src/mailman/app/tests/test_membership.py
@@ -30,13 +30,11 @@ 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 (
AlreadySubscribedError, DeliveryMode, MemberRole, MembershipIsBannedError)
from mailman.interfaces.usermanager import IUserManager
-from mailman.testing.helpers import reset_the_world
from mailman.testing.layers import ConfigLayer
@@ -47,9 +45,6 @@ class AddMemberTest(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
- def tearDown(self):
- reset_the_world()
-
def test_add_member_new_user(self):
# Test subscribing a user to a mailing list when the email address has
# not yet been associated with a user.
@@ -178,22 +173,10 @@ class AddMemberPasswordTest(unittest.TestCase):
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: passlib.hash.sha1_crypt
- """)
-
- 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,
- '{sha1_crypt}$sha1$40000$$nY5NBnPWWAD5KI4X8Jjzp7.1YhV6')
+ self.assertEqual(member.user.password, '{plaintext}abc')
diff --git a/src/mailman/chains/accept.py b/src/mailman/chains/accept.py
index 4b326142b..852ca8c06 100644
--- a/src/mailman/chains/accept.py
+++ b/src/mailman/chains/accept.py
@@ -22,7 +22,6 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
'AcceptChain',
- 'AcceptNotification',
]
@@ -30,9 +29,10 @@ import logging
from zope.event import notify
-from mailman.chains.base import ChainNotification, TerminalChainBase
+from mailman.chains.base import TerminalChainBase
from mailman.config import config
from mailman.core.i18n import _
+from mailman.interfaces.chain import AcceptEvent
log = logging.getLogger('mailman.vette')
@@ -40,11 +40,6 @@ SEMISPACE = '; '
-class AcceptNotification(ChainNotification):
- """A notification event signaling that a message is being accepted."""
-
-
-
class AcceptChain(TerminalChainBase):
"""Accept the message for posting."""
@@ -64,4 +59,4 @@ class AcceptChain(TerminalChainBase):
msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses)
config.switchboards['pipeline'].enqueue(msg, msgdata)
log.info('ACCEPT: %s', msg.get('message-id', 'n/a'))
- notify(AcceptNotification(mlist, msg, msgdata, self))
+ notify(AcceptEvent(mlist, msg, msgdata, self))
diff --git a/src/mailman/chains/base.py b/src/mailman/chains/base.py
index b75d66989..d317bf803 100644
--- a/src/mailman/chains/base.py
+++ b/src/mailman/chains/base.py
@@ -22,7 +22,6 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
'Chain',
- 'ChainNotification',
'Link',
'TerminalChainBase',
]
@@ -134,14 +133,3 @@ class ChainIterator:
def __iter__(self):
"""See `IChainIterator`."""
return self._chain.get_iterator()
-
-
-
-class ChainNotification:
- """Base class for chain notification events."""
-
- def __init__(self, mlist, msg, msgdata, chain):
- self.mlist = mlist
- self.msg = msg
- self.msgdata = msgdata
- self.chain = chain
diff --git a/src/mailman/chains/discard.py b/src/mailman/chains/discard.py
index 0a4bfe9ac..a52d25d7a 100644
--- a/src/mailman/chains/discard.py
+++ b/src/mailman/chains/discard.py
@@ -22,26 +22,21 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
'DiscardChain',
- 'DiscardNotification',
]
import logging
from zope.event import notify
-from mailman.chains.base import ChainNotification, TerminalChainBase
+from mailman.chains.base import TerminalChainBase
from mailman.core.i18n import _
+from mailman.interfaces.chain import DiscardEvent
log = logging.getLogger('mailman.vette')
-class DiscardNotification(ChainNotification):
- """A notification event signaling that a message is being discarded."""
-
-
-
class DiscardChain(TerminalChainBase):
"""Discard a message."""
@@ -55,5 +50,5 @@ class DiscardChain(TerminalChainBase):
message away.
"""
log.info('DISCARD: %s', msg.get('message-id', 'n/a'))
- notify(DiscardNotification(mlist, msg, msgdata, self))
+ notify(DiscardEvent(mlist, msg, msgdata, self))
# Nothing more needs to happen.
diff --git a/src/mailman/chains/docs/moderation.rst b/src/mailman/chains/docs/moderation.rst
index fed120147..1e968ee68 100644
--- a/src/mailman/chains/docs/moderation.rst
+++ b/src/mailman/chains/docs/moderation.rst
@@ -45,9 +45,9 @@ In order to find out whether the message is held or accepted, we can subscribe
to Zope events that are triggered on each case.
::
- >>> from mailman.chains.base import ChainNotification
+ >>> from mailman.interfaces.chain import ChainEvent
>>> def on_chain(event):
- ... if isinstance(event, ChainNotification):
+ ... if isinstance(event, ChainEvent):
... print event
... print event.chain
... print 'Subject:', event.msg['subject']
@@ -74,7 +74,7 @@ built-in chain. No rules hit and so the message is accepted.
>>> from mailman.testing.helpers import event_subscribers
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
- <mailman.chains.accept.AcceptNotification ...>
+ <mailman.interfaces.chain.AcceptEvent ...>
<mailman.chains.accept.AcceptChain ...>
Subject: aardvark
Hits:
@@ -109,7 +109,7 @@ moderator approval.
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
- <mailman.chains.hold.HoldNotification ...>
+ <mailman.interfaces.chain.HoldEvent ...>
<mailman.chains.hold.HoldChain ...>
Subject: badger
Hits:
@@ -134,7 +134,7 @@ The list's member moderation action can also be set to `discard`...
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
- <mailman.chains.discard.DiscardNotification ...>
+ <mailman.interfaces.chain.DiscardEvent ...>
<mailman.chains.discard.DiscardChain ...>
Subject: cougar
Hits:
@@ -158,7 +158,7 @@ The list's member moderation action can also be set to `discard`...
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
- <mailman.chains.reject.RejectNotification ...>
+ <mailman.interfaces.chain.RejectEvent ...>
<mailman.chains.reject.RejectChain ...>
Subject: dingo
Hits:
@@ -197,7 +197,7 @@ moderator approval.
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
- <mailman.chains.hold.HoldNotification ...>
+ <mailman.interfaces.chain.HoldEvent ...>
<mailman.chains.hold.HoldChain ...>
Subject: elephant
Hits:
diff --git a/src/mailman/chains/hold.py b/src/mailman/chains/hold.py
index a8b7ec57b..34389f82e 100644
--- a/src/mailman/chains/hold.py
+++ b/src/mailman/chains/hold.py
@@ -22,7 +22,6 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
'HoldChain',
- 'HoldNotification',
]
@@ -37,11 +36,12 @@ from zope.interface import implementer
from mailman.app.moderator import hold_message
from mailman.app.replybot import can_acknowledge
-from mailman.chains.base import ChainNotification, TerminalChainBase
+from mailman.chains.base import TerminalChainBase
from mailman.config import config
from mailman.core.i18n import _
from mailman.email.message import UserNotification
from mailman.interfaces.autorespond import IAutoResponseSet, Response
+from mailman.interfaces.chain import HoldEvent
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.pending import IPendable, IPendings
from mailman.interfaces.usermanager import IUserManager
@@ -59,10 +59,6 @@ class HeldMessagePendable(dict):
PEND_KEY = 'held message'
-class HoldNotification(ChainNotification):
- """A notification event signaling that a message is being held."""
-
-
def autorespond_to_sender(mlist, sender, language=None):
"""Should Mailman automatically respond to this sender?
@@ -249,4 +245,4 @@ also appear in the first line of the body of the reply.""")),
log.info('HOLD: %s post from %s held, message-id=%s: %s',
mlist.fqdn_listname, msg.sender,
msg.get('message-id', 'n/a'), reason)
- notify(HoldNotification(mlist, msg, msgdata, self))
+ notify(HoldEvent(mlist, msg, msgdata, self))
diff --git a/src/mailman/chains/owner.py b/src/mailman/chains/owner.py
index ad0a04cea..22ed4b996 100644
--- a/src/mailman/chains/owner.py
+++ b/src/mailman/chains/owner.py
@@ -29,20 +29,16 @@ import logging
from zope.event import notify
-from mailman.chains.base import ChainNotification, TerminalChainBase
+from mailman.chains.base import TerminalChainBase
from mailman.config import config
from mailman.core.i18n import _
+from mailman.interfaces.chain import AcceptOwnerEvent
log = logging.getLogger('mailman.vette')
-class OwnerNotification(ChainNotification):
- """An event signaling that a message is accepted to the -owner address."""
-
-
-
class BuiltInOwnerChain(TerminalChainBase):
"""Default built-in -owner address chain."""
@@ -53,4 +49,4 @@ class BuiltInOwnerChain(TerminalChainBase):
# At least for now, everything posted to -owners goes through.
config.switchboards['pipeline'].enqueue(msg, msgdata)
log.info('OWNER: %s', msg.get('message-id', 'n/a'))
- notify(OwnerNotification(mlist, msg, msgdata, self))
+ notify(AcceptOwnerEvent(mlist, msg, msgdata, self))
diff --git a/src/mailman/chains/reject.py b/src/mailman/chains/reject.py
index cf1b86fb2..3adc69e3a 100644
--- a/src/mailman/chains/reject.py
+++ b/src/mailman/chains/reject.py
@@ -22,16 +22,17 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
'RejectChain',
- 'RejectNotification',
]
import logging
+
from zope.event import notify
from mailman.app.bounces import bounce_message
-from mailman.chains.base import ChainNotification, TerminalChainBase
+from mailman.chains.base import TerminalChainBase
from mailman.core.i18n import _
+from mailman.interfaces.chain import RejectEvent
log = logging.getLogger('mailman.vette')
@@ -39,11 +40,6 @@ SEMISPACE = '; '
-class RejectNotification(ChainNotification):
- """A notification event signaling that a message is being rejected."""
-
-
-
class RejectChain(TerminalChainBase):
"""Reject/bounce a message."""
@@ -64,4 +60,4 @@ class RejectChain(TerminalChainBase):
# XXX Exception/reason
bounce_message(mlist, msg)
log.info('REJECT: %s', msg.get('message-id', 'n/a'))
- notify(RejectNotification(mlist, msg, msgdata, self))
+ notify(RejectEvent(mlist, msg, msgdata, self))
diff --git a/src/mailman/chains/tests/test_hold.py b/src/mailman/chains/tests/test_hold.py
index f2cd1dabf..515894505 100644
--- a/src/mailman/chains/tests/test_hold.py
+++ b/src/mailman/chains/tests/test_hold.py
@@ -30,10 +30,9 @@ from zope.component import getUtility
from mailman.app.lifecycle import create_list
from mailman.chains.hold import autorespond_to_sender
-from mailman.config import config
from mailman.interfaces.autorespond import IAutoResponseSet, Response
from mailman.interfaces.usermanager import IUserManager
-from mailman.testing.helpers import get_queue_messages
+from mailman.testing.helpers import configuration, get_queue_messages
from mailman.testing.layers import ConfigLayer
@@ -49,15 +48,12 @@ class TestAutorespond(unittest.TestCase):
self.maxDiff = None
self.eq = getattr(self, 'assertMultiLineEqual', self.assertEqual)
+ @configuration('mta', max_autoresponses_per_day=1)
def test_max_autoresponses_per_day(self):
# The last one we sent was the last one we should send today. Instead
# of sending an automatic response, send them the "no more today"
- # message.
- config.push('max-1', """
- [mta]
- max_autoresponses_per_day: 1
- """)
- # Simulate a response having been sent to an address already.
+ # message. Start by simulating a response having been sent to an
+ # address already.
anne = getUtility(IUserManager).create_address('anne@example.com')
response_set = IAutoResponseSet(self._mlist)
response_set.response_sent(anne, Response.hold)
diff --git a/src/mailman/chains/tests/test_owner.py b/src/mailman/chains/tests/test_owner.py
index db85d4967..b50c08e31 100644
--- a/src/mailman/chains/tests/test_owner.py
+++ b/src/mailman/chains/tests/test_owner.py
@@ -28,8 +28,9 @@ __all__ = [
import unittest
from mailman.app.lifecycle import create_list
-from mailman.chains.owner import BuiltInOwnerChain, OwnerNotification
+from mailman.chains.owner import BuiltInOwnerChain
from mailman.core.chains import process
+from mailman.interfaces.chain import AcceptOwnerEvent
from mailman.testing.helpers import (
event_subscribers,
get_queue_messages,
@@ -60,12 +61,13 @@ Message-ID: <ant>
# is processed by the owner chain.
events = []
def catch_event(event):
- events.append(event)
+ if isinstance(event, AcceptOwnerEvent):
+ events.append(event)
with event_subscribers(catch_event):
process(self._mlist, self._msg, {}, 'default-owner-chain')
self.assertEqual(len(events), 1)
event = events[0]
- self.assertTrue(isinstance(event, OwnerNotification))
+ self.assertTrue(isinstance(event, AcceptOwnerEvent))
self.assertEqual(event.mlist, self._mlist)
self.assertEqual(event.msg['message-id'], '<ant>')
self.assertTrue(isinstance(event.chain, BuiltInOwnerChain))
diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py
index fc82a2be7..0cd5e0e32 100644
--- a/src/mailman/config/config.py
+++ b/src/mailman/config/config.py
@@ -32,11 +32,14 @@ from lazr.config import ConfigSchema, as_boolean
from pkg_resources import resource_stream
from string import Template
from zope.component import getUtility
-from zope.interface import Interface, implementer
+from zope.event import notify
+from zope.interface import implementer
import mailman.templates
from mailman import version
+from mailman.interfaces.configuration import (
+ ConfigurationUpdatedEvent, IConfiguration)
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.styles import IStyleManager
from mailman.utilities.filesystem import makedirs
@@ -47,11 +50,6 @@ SPACE = ' '
-class IConfiguration(Interface):
- """Marker interface; used for adaptation in the REST API."""
-
-
-
@implementer(IConfiguration)
class Configuration:
"""The core global configuration object."""
@@ -70,6 +68,7 @@ class Configuration:
self.handlers = {}
self.pipelines = {}
self.commands = {}
+ self.password_context = None
def _clear(self):
"""Clear the cached configuration variables."""
@@ -138,6 +137,7 @@ class Configuration:
# Set the default system language.
from mailman.core.i18n import _
_.default = self.mailman.default_language
+ notify(ConfigurationUpdatedEvent(self))
def _expand_paths(self):
"""Expand all configuration paths."""
diff --git a/src/mailman/config/passlib.cfg b/src/mailman/config/passlib.cfg
new file mode 100644
index 000000000..805f0fb11
--- /dev/null
+++ b/src/mailman/config/passlib.cfg
@@ -0,0 +1,10 @@
+[passlib]
+# This is the output of passlibs.apps.custom_app_context.to_string().
+# See http://packages.python.org/passlib/index.html for details.
+schemes = sha512_crypt, sha256_crypt
+default = sha512_crypt
+all__vary_rounds = 0.1
+sha256_crypt__min_rounds = 80000
+sha512_crypt__min_rounds = 60000
+admin__sha256_crypt__min_rounds = 160000
+admin__sha512_crypt__min_rounds = 120000
diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg
index 8d17d806c..e3c6e607c 100644
--- a/src/mailman/config/schema.cfg
+++ b/src/mailman/config/schema.cfg
@@ -154,11 +154,13 @@ wait: 10s
[passwords]
-# 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. This is the path to a passlib hash
-# algorithm. See http://packages.python.org/passlib/lib/passlib.hash.html
-password_scheme: passlib.hash.pbkdf2_sha512
+# Where can we find the passlib configuration file? The path can be either a
+# file system path or a Python import path. If the value starts with python:
+# then it is a Python import path, otherwise it is a file system path. File
+# system paths must be absolute since no guarantees are made about the current
+# working directory. Python paths should not include the trailing .cfg, which
+# the file must end with.
+path: python:mailman.config.passlib
# When Mailman generates them, this is the default length of passwords.
password_length: 8
diff --git a/src/mailman/config/tests/__init__.py b/src/mailman/config/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/mailman/config/tests/__init__.py
diff --git a/src/mailman/config/tests/test_configuration.py b/src/mailman/config/tests/test_configuration.py
new file mode 100644
index 000000000..88e00cbb9
--- /dev/null
+++ b/src/mailman/config/tests/test_configuration.py
@@ -0,0 +1,53 @@
+# Copyright (C) 2012 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/>.
+
+"""Test the system-wide global configuration."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'TestConfiguration',
+ ]
+
+
+import unittest
+
+from mailman.interfaces.configuration import ConfigurationUpdatedEvent
+from mailman.testing.helpers import configuration, event_subscribers
+from mailman.testing.layers import ConfigLayer
+
+
+
+class TestConfiguration(unittest.TestCase):
+ layer = ConfigLayer
+
+ def test_push_and_pop_trigger_events(self):
+ # Pushing a new configuration onto the stack triggers a
+ # post-processing event.
+ events = []
+ def on_event(event):
+ if isinstance(event, ConfigurationUpdatedEvent):
+ # Record both the event and the top overlay.
+ events.append(event.config.overlays[0].name)
+ with event_subscribers(on_event):
+ with configuration('test', _configname='my test'):
+ pass
+ # There should be two pushed configuration names on the list now, one
+ # for the push leaving 'my test' on the top of the stack, and one for
+ # the pop, leaving the ConfigLayer's 'test config' on top.
+ self.assertEqual(events, ['my test', 'test config'])
diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py
index 389a45f3b..255e22ed9 100644
--- a/src/mailman/core/initialize.py
+++ b/src/mailman/core/initialize.py
@@ -110,6 +110,10 @@ def initialize_1(config_path=None):
# o-rwx although I think in most cases it doesn't hurt if other can read
# or write the files.
os.umask(007)
+ # Initialize configuration event subscribers. This must be done before
+ # setting up the configuration system.
+ from mailman.utilities.passwords import initialize as initialize_passwords
+ initialize_passwords()
# config_path will be set if the command line argument -C is given. That
# case overrides all others. When not given on the command line, the
# configuration file is searched for in the file system.
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index fb819ccc4..c3df9382d 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -14,8 +14,10 @@ Here is a history of user visible changes to Mailman.
Architecture
------------
+ * `passlib`_ is now used for all password hashing instead of flufl.password.
* Internally, all datetimes are kept in the UTC timezone, however because of
- LP: #280708, they are stored in the database in naive format.
+ LP: #280708, they are stored in the database in naive format. The default
+ hash is `sha512_crypt`.
* `received_time` is now added to the message metadata by the LMTP runner
instead of by `Switchboard.enqueue()`. This latter no longer depends on
`received_time` in the metadata.
@@ -35,6 +37,22 @@ Architecture
`RunnerCrashEvent` is triggered, which contains references to the queue
runner, mailing list, message, metadata, and exception. Interested parties
can subscribe to that `zope.event` for notification.
+ * Events renamed and moved:
+ * `mailman.chains.accept.AcceptNotification`
+ * `mailman.chains.base.ChainNotification`
+ * `mailman.chains.discard.DiscardNotification`
+ * `mailman.chains.hold.HoldNotification`
+ * `mailman.chains.owner.OwnerNotification`
+ * `mailman.chains.reject.RejectNotification`
+ changed to (respectively):
+ * `mailman.interfaces.chains.AcceptEvent`
+ * `mailman.interfaces.chains.ChainEvent`
+ * `mailman.interfaces.chains.DiscardEvent`
+ * `mailman.interfaces.chains.HoldEvent`
+ * `mailman.interfaces.chains.AcceptOwnerEvent`
+ * `mailman.interfaces.chains.RejectEvent`
+ * A `ConfigurationUpdatedEvent` is triggered when the system-wide global
+ configuration stack is pushed or popped.
Configuration
-------------
@@ -42,9 +60,12 @@ Configuration
every `[archiver.<name>]` section. These are used to determine under what
circumstances a message destined for a specific archiver should have its
`Date:` header clobbered. (LP: #963612)
+ * With the switch to `passlib`_, `[passwords]password_scheme` has been
+ removed. Instead use `[passwords]path` to specify where to find the
+ `passlib.cfg` file. See the comments in `schema.cfg` for details.
* Configuration schema variable changes:
- [nntp]username -> [nntp]user
- [nntp]port (added)
+ * [nntp]username -> [nntp]user
+ * [nntp]port (added)
* Header check specifications in the `mailman.cfg` file have changed quite
bit. The previous `[spam.header.foo]` sections have been removed.
Instead, there's a new `[antispam]` section that contains a `header_checks`
@@ -66,6 +87,8 @@ Bug fixes
(LP: #953497)
* List-Post should be NO when posting is not allowed. (LP: #987563)
+.. _`passlib`: http://packages.python.org/passlib/index.html
+
3.0 beta 1 -- "The Twilight Zone"
=================================
diff --git a/src/mailman/interfaces/chain.py b/src/mailman/interfaces/chain.py
index 8858f874c..8862026f8 100644
--- a/src/mailman/interfaces/chain.py
+++ b/src/mailman/interfaces/chain.py
@@ -21,11 +21,17 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
+ 'AcceptEvent',
+ 'AcceptOwnerEvent',
+ 'ChainEvent',
+ 'DiscardEvent',
+ 'HoldEvent',
'IChain',
'IChainIterator',
'IChainLink',
'IMutableChain',
'LinkAction',
+ 'RejectEvent',
]
@@ -34,6 +40,37 @@ from zope.interface import Interface, Attribute
+class ChainEvent:
+ """Base class for chain notification events."""
+
+ def __init__(self, mlist, msg, msgdata, chain):
+ self.mlist = mlist
+ self.msg = msg
+ self.msgdata = msgdata
+ self.chain = chain
+
+
+class AcceptEvent(ChainEvent):
+ """A notification event signaling that a message is being accepted."""
+
+
+class AcceptOwnerEvent(ChainEvent):
+ """An event signaling that a message is accepted to the -owner address."""
+
+
+class DiscardEvent(ChainEvent):
+ """A notification event signaling that a message is being discarded."""
+
+
+class HoldEvent(ChainEvent):
+ """A notification event signaling that a message is being held."""
+
+
+class RejectEvent(ChainEvent):
+ """A notification event signaling that a message is being rejected."""
+
+
+
class LinkAction(Enum):
# Jump to another chain.
jump = 0
diff --git a/src/mailman/interfaces/configuration.py b/src/mailman/interfaces/configuration.py
new file mode 100644
index 000000000..8c4fb52a6
--- /dev/null
+++ b/src/mailman/interfaces/configuration.py
@@ -0,0 +1,41 @@
+# Copyright (C) 2012 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/>.
+
+"""Configuration system interface."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'ConfigurationUpdatedEvent',
+ 'IConfiguration',
+ ]
+
+
+from zope.interface import Interface
+
+
+
+class IConfiguration(Interface):
+ """Marker interface; used for adaptation in the REST API."""
+
+
+
+class ConfigurationUpdatedEvent:
+ """The system-wide global configuration was updated."""
+ def __init__(self, config):
+ self.config = config
diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py
index 6bab7c789..bce541ae5 100644
--- a/src/mailman/rest/users.py
+++ b/src/mailman/rest/users.py
@@ -38,7 +38,6 @@ from mailman.rest.addresses import UserAddresses
from mailman.rest.helpers import CollectionMixin, etag, no_content, path_to
from mailman.rest.preferences import Preferences
from mailman.rest.validator import Validator
-from mailman.utilities.passwords import encrypt
@@ -103,7 +102,7 @@ class AllUsers(_UserBase):
if password is None:
# This will have to be reset since it cannot be retrieved.
password = generate(int(config.passwords.password_length))
- user.password = encrypt(password)
+ user.password = config.password_context.encrypt(password)
location = path_to('users/{0}'.format(user.user_id.int))
return http.created(location, [], None)
diff --git a/src/mailman/rules/approved.py b/src/mailman/rules/approved.py
index dadc25322..3ff7d21ec 100644
--- a/src/mailman/rules/approved.py
+++ b/src/mailman/rules/approved.py
@@ -30,9 +30,9 @@ import re
from email.iterators import typed_subpart_iterator
from zope.interface import implementer
+from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.rules import IRule
-from mailman.utilities.passwords import verify
EMPTYSTRING = ''
@@ -119,8 +119,14 @@ class Approved:
else:
for header in HEADERS:
del msg[header]
- return (password is not missing and
- verify(mlist.moderator_password, password))
+ if password is missing:
+ return False
+ is_valid, new_hash = config.password_context.verify(
+ mlist.moderator_password, password)
+ if is_valid and new_hash:
+ # Hash algorithm migration.
+ mlist.moderator_password = new_hash
+ return is_valid
diff --git a/src/mailman/rules/docs/approved.rst b/src/mailman/rules/docs/approved.rst
index 7f0714e17..3f3d54455 100644
--- a/src/mailman/rules/docs/approved.rst
+++ b/src/mailman/rules/docs/approved.rst
@@ -20,8 +20,8 @@ which is shared among all the administrators.
This password will not be stored in clear text, so it must be hashed using the
configured hash protocol.
- >>> from mailman.utilities.passwords import encrypt
- >>> mlist.moderator_password = encrypt('super secret')
+ >>> mlist.moderator_password = config.password_context.encrypt(
+ ... 'super secret')
The ``approved`` rule determines whether the message contains the proper
approval or not.
diff --git a/src/mailman/rules/docs/header-matching.rst b/src/mailman/rules/docs/header-matching.rst
index 021974e69..20e55fadd 100644
--- a/src/mailman/rules/docs/header-matching.rst
+++ b/src/mailman/rules/docs/header-matching.rst
@@ -74,17 +74,19 @@ The header may exist and match the pattern. By default, when the header
matches, it gets held for moderator approval.
::
+ >>> from mailman.interfaces.chain import ChainEvent
>>> from mailman.testing.helpers import event_subscribers
>>> def handler(event):
- ... print event.__class__.__name__, \
- ... event.chain.name, event.msg['message-id']
+ ... if isinstance(event, ChainEvent):
+ ... print event.__class__.__name__, \
+ ... event.chain.name, event.msg['message-id']
>>> del msg['x-spam-score']
>>> msg['X-Spam-Score'] = '*****'
>>> msgdata = {}
>>> with event_subscribers(handler):
... process(mlist, msg, msgdata, 'header-match')
- HoldNotification hold <ant>
+ HoldEvent hold <ant>
>>> hits_and_misses(msgdata)
Rule hits:
@@ -100,7 +102,7 @@ discard such messages.
>>> with event_subscribers(handler):
... with configuration('antispam', jump_chain='discard'):
... process(mlist, msg, msgdata, 'header-match')
- DiscardNotification discard <ant>
+ DiscardEvent discard <ant>
These programmatically added headers can be removed by flushing the chain.
Now, nothing with match this message.
diff --git a/src/mailman/rules/tests/test_approved.py b/src/mailman/rules/tests/test_approved.py
index de409f654..a1b8f99ac 100644
--- a/src/mailman/rules/tests/test_approved.py
+++ b/src/mailman/rules/tests/test_approved.py
@@ -25,17 +25,20 @@ __all__ = [
'TestApprovedNonASCII',
'TestApprovedPseudoHeader',
'TestApprovedPseudoHeaderMIME',
+ 'TestPasswordHashMigration',
]
+import os
import unittest
from mailman.app.lifecycle import create_list
+from mailman.config import config
from mailman.rules import approved
from mailman.testing.helpers import (
+ configuration,
specialized_message_from_string as mfs)
from mailman.testing.layers import ConfigLayer
-from mailman.utilities.passwords import encrypt
@@ -46,7 +49,8 @@ class TestApproved(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
- self._mlist.moderator_password = encrypt('super secret')
+ self._mlist.moderator_password = config.password_context.encrypt(
+ 'super secret')
self._rule = approved.Approved()
self._msg = mfs("""\
From: anne@example.com
@@ -147,7 +151,8 @@ class TestApprovedPseudoHeader(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
- self._mlist.moderator_password = encrypt('super secret')
+ self._mlist.moderator_password = config.password_context.encrypt(
+ 'super secret')
self._rule = approved.Approved()
self._msg = mfs("""\
From: anne@example.com
@@ -279,7 +284,8 @@ class TestApprovedPseudoHeaderMIME(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
- self._mlist.moderator_password = encrypt('super secret')
+ self._mlist.moderator_password = config.password_context.encrypt(
+ 'super secret')
self._rule = approved.Approved()
self._msg_text_template = """\
From: anne@example.com
@@ -410,3 +416,78 @@ This is a message body with a non-ascii character =E4
# unicode errors. LP: #949924.
result = self._rule.check(self._mlist, self._msg, {})
self.assertFalse(result)
+
+
+class TestPasswordHashMigration(unittest.TestCase):
+ """Test that password hashing migrations work."""
+ # http://packages.python.org/passlib/lib/passlib.context-tutorial.html#integrating-hash-migration
+
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._mlist = create_list('test@example.com')
+ # The default testing hash algorithm is "roundup_plaintext" which
+ # yields hashed passwords of the form: {plaintext}abc
+ #
+ # Migration is automatically supported when a more modern password
+ # hash is chosen after the original password is set. As long as the
+ # old password still validates, the migration happens automatically.
+ self._mlist.moderator_password = config.password_context.encrypt(
+ b'super secret')
+ self._rule = approved.Approved()
+ self._msg = mfs("""\
+From: anne@example.com
+To: test@example.com
+Subject: A Message with non-ascii body
+Message-ID: <ant>
+MIME-Version: 1.0
+
+A message body.
+""")
+
+ def test_valid_password_migrates(self):
+ # Now that the moderator password is set, change the default password
+ # hashing algorithm. When the old password is validated, it will be
+ # automatically migrated to the new hash.
+ self.assertEqual(self._mlist.moderator_password,
+ b'{plaintext}super secret')
+ config_file = os.path.join(config.VAR_DIR, 'passlib.config')
+ # XXX passlib seems to choose the default hashing scheme even if it is
+ # deprecated. The default scheme is either specified explicitly, or
+ # is the first in this list. This seems like a bug.
+ with open(config_file, 'w') as fp:
+ print("""\
+[passlib]
+schemes = roundup_plaintext, plaintext
+default = plaintext
+deprecated = roundup_plaintext
+""", file=fp)
+ with configuration('passwords', path=config_file):
+ self._msg['Approved'] = 'super secret'
+ result = self._rule.check(self._mlist, self._msg, {})
+ self.assertTrue(result)
+ self.assertEqual(self._mlist.moderator_password, b'super secret')
+
+ def test_invalid_password_does_not_migrate(self):
+ # Now that the moderator password is set, change the default password
+ # hashing algorithm. When the old password is invalid, it will not be
+ # automatically migrated to the new hash.
+ self.assertEqual(self._mlist.moderator_password,
+ b'{plaintext}super secret')
+ config_file = os.path.join(config.VAR_DIR, 'passlib.config')
+ # XXX passlib seems to choose the default hashing scheme even if it is
+ # deprecated. The default scheme is either specified explicitly, or
+ # is the first in this list. This seems like a bug.
+ with open(config_file, 'w') as fp:
+ print("""\
+[passlib]
+schemes = roundup_plaintext, plaintext
+default = plaintext
+deprecated = roundup_plaintext
+""", file=fp)
+ with configuration('passwords', path=config_file):
+ self._msg['Approved'] = 'not the password'
+ result = self._rule.check(self._mlist, self._msg, {})
+ self.assertFalse(result)
+ self.assertEqual(self._mlist.moderator_password,
+ b'{plaintext}super secret')
diff --git a/src/mailman/runners/docs/incoming.rst b/src/mailman/runners/docs/incoming.rst
index 4a9db778e..8830031e6 100644
--- a/src/mailman/runners/docs/incoming.rst
+++ b/src/mailman/runners/docs/incoming.rst
@@ -143,9 +143,9 @@ chain will now hold all posted messages, so nothing will show up in the
pipeline queue.
::
- >>> from mailman.chains.base import ChainNotification
+ >>> from mailman.interfaces.chain import ChainEvent
>>> def on_chain(event):
- ... if isinstance(event, ChainNotification):
+ ... if isinstance(event, ChainEvent):
... print event
... print event.chain
... print 'From: {0}\nTo: {1}\nMessage-ID: {2}'.format(
@@ -158,7 +158,7 @@ pipeline queue.
>>> with event_subscribers(on_chain):
... inject_message(mlist, msg)
... incoming.run()
- <mailman.chains.hold.HoldNotification ...>
+ <mailman.interfaces.chain.HoldEvent ...>
<mailman.chains.hold.HoldChain ...>
From: aperson@example.com
To: test@example.com
@@ -193,7 +193,7 @@ new chain and set it as the mailing list's start chain.
>>> with event_subscribers(on_chain):
... inject_message(mlist, msg)
... incoming.run()
- <mailman.chains.discard.DiscardNotification ...>
+ <mailman.interfaces.chain.DiscardEvent ...>
<mailman.chains.discard.DiscardChain ...>
From: aperson@example.com
To: test@example.com
@@ -222,7 +222,7 @@ just create a new chain that does.
>>> with event_subscribers(on_chain):
... inject_message(mlist, msg)
... incoming.run()
- <mailman.chains.reject.RejectNotification ...>
+ <mailman.interfaces.chain.RejectEvent ...>
<mailman.chains.reject.RejectChain ...>
From: aperson@example.com
To: test@example.com
diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py
index 62e3b9d2a..5252c5334 100644
--- a/src/mailman/testing/helpers.py
+++ b/src/mailman/testing/helpers.py
@@ -363,8 +363,14 @@ class configuration:
def __init__(self, section, **kws):
self._section = section
+ # Most tests don't care about the name given to the temporary
+ # configuration. Usually we'll just craft a random one, but some
+ # tests do care, so give them a hook to set it.
+ if '_configname' in kws:
+ self._uuid = kws.pop('_configname')
+ else:
+ self._uuid = uuid.uuid4().hex
self._values = kws.copy()
- self._uuid = uuid.uuid4().hex
def _apply(self):
lines = ['[{0}]'.format(self._section)]
diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py
index 7a019f53a..bbef6d5f4 100644
--- a/src/mailman/testing/layers.py
+++ b/src/mailman/testing/layers.py
@@ -117,8 +117,6 @@ class ConfigLayer(MockAndMonkeyLayer):
test_config = dedent("""
[mailman]
layout: testing
- [passwords]
- password_scheme: passlib.hash.plaintext
[paths.testing]
var_dir: %s
[devmode]
diff --git a/src/mailman/testing/passlib.cfg b/src/mailman/testing/passlib.cfg
new file mode 100644
index 000000000..225ecd49b
--- /dev/null
+++ b/src/mailman/testing/passlib.cfg
@@ -0,0 +1,4 @@
+[passlib]
+# Use a predictable hashing algorithm with plain text and no salt. This is
+# *only* useful for debugging and unit testing.
+schemes = roundup_plaintext
diff --git a/src/mailman/testing/testing.cfg b/src/mailman/testing/testing.cfg
index 91613cc8d..5f19dca14 100644
--- a/src/mailman/testing/testing.cfg
+++ b/src/mailman/testing/testing.cfg
@@ -30,6 +30,9 @@ smtp_port: 9025
lmtp_port: 9024
incoming: mailman.testing.mta.FakeMTA
+[passwords]
+path: python:mailman.testing.passlib
+
[webservice]
port: 9001
diff --git a/src/mailman/utilities/passwords.py b/src/mailman/utilities/passwords.py
index b9981f057..65c4ff291 100644
--- a/src/mailman/utilities/passwords.py
+++ b/src/mailman/utilities/passwords.py
@@ -21,37 +21,49 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
- 'encrypt',
- 'verify',
+ 'initialize',
]
-import re
-from passlib.registry import get_crypt_handler
+from passlib.context import CryptContext
+from pkg_resources import resource_string
+from zope import event
-from mailman.config import config
-from mailman.testing import layers
-from mailman.utilities.modules import find_name
+from mailman.interfaces.configuration import ConfigurationUpdatedEvent
-SCHEME_RE = r'{(?P<scheme>[^}]+?)}(?P<rest>.*)'.encode()
+
+
+class PasswordContext:
+ def __init__(self, config):
+ # Is the context coming from a file system or Python path?
+ if config.passwords.path.startswith('python:'):
+ resource_path = config.passwords.path[7:]
+ package, dot, resource = resource_path.rpartition('.')
+ config_string = resource_string(package, resource + '.cfg')
+ else:
+ with open(config.passwords.path, 'rb') as fp:
+ config_string = fp.read()
+ self._context = CryptContext.from_string(config_string)
+
+ def encrypt(self, secret):
+ return self._context.encrypt(secret)
+
+ def verify(self, hashed, password):
+ # Support hash algorithm migration. Yes, the order of arguments is
+ # reversed, for backward compatibility with flufl.password. XXX fix
+ # this eventually.
+ return self._context.verify_and_update(password, hashed)
-def encrypt(secret):
- hasher = find_name(config.passwords.password_scheme)
- # For reproducibility, don't use any salt in the test suite.
- kws = {}
- if layers.is_testing and 'salt' in hasher.setting_kwds:
- kws['salt'] = b''
- hashed = hasher.encrypt(secret, **kws)
- return b'{{{0}}}{1}'.format(hasher.name, hashed)
+# Create and register a post-processing handler for the configuration file.
+
+def _update_context(event):
+ if isinstance(event, ConfigurationUpdatedEvent):
+ # Just reset the password context.
+ event.config.password_context = PasswordContext(event.config)
-def verify(hashed, password):
- mo = re.match(SCHEME_RE, hashed, re.IGNORECASE)
- if not mo:
- return False
- scheme, secret = mo.groups(('scheme', 'rest'))
- hasher = get_crypt_handler(scheme)
- return hasher.verify(password, secret)
+def initialize():
+ event.subscribers.append(_update_context)
diff --git a/src/mailman/utilities/tests/test_passwords.py b/src/mailman/utilities/tests/test_passwords.py
new file mode 100644
index 000000000..7b2931855
--- /dev/null
+++ b/src/mailman/utilities/tests/test_passwords.py
@@ -0,0 +1,60 @@
+# Copyright (C) 2012 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/>.
+
+"""Testing the password utility."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'TestPasswords',
+ ]
+
+
+import os
+import unittest
+
+from mailman.config import config
+from mailman.testing.helpers import configuration
+from mailman.testing.layers import ConfigLayer
+
+
+
+class TestPasswords(unittest.TestCase):
+ layer = ConfigLayer
+
+ def test_default_passlib(self):
+ # By default, testing uses the roundup_plaintext hash algorithm, which
+ # is just plaintext with a prefix.
+ self.assertEqual(config.password_context.encrypt('my password'),
+ '{plaintext}my password')
+
+ def test_passlib_from_file_path(self):
+ # Set up this test to use a passlib configuration file specified with
+ # a file system path. We prove we're using the new configuration
+ # because a non-prefixed, i.e. non-roundup, plaintext hash algorithm
+ # will be used. When a file system path is used, the file can end in
+ # any suffix.
+ config_file = os.path.join(config.VAR_DIR, 'passlib.config')
+ with open(config_file, 'w') as fp:
+ print("""\
+[passlib]
+schemes = plaintext
+""", file=fp)
+ with configuration('passwords', path=config_file):
+ self.assertEqual(config.password_context.encrypt('my password'),
+ 'my password')