diff options
Diffstat (limited to 'src')
41 files changed, 543 insertions, 180 deletions
diff --git a/src/mailman/app/events.py b/src/mailman/app/events.py index 79376cca1..a4f385239 100644 --- a/src/mailman/app/events.py +++ b/src/mailman/app/events.py @@ -28,13 +28,22 @@ __all__ = [ from zope import event from mailman.app import domain, moderator, subscriptions +from mailman.core import i18n, switchboard +from mailman.languages import manager as language_manager +from mailman.styles import manager as style_manager +from mailman.utilities import passwords def initialize(): """Initialize global event subscribers.""" event.subscribers.extend([ + domain.handle_DomainDeletingEvent, moderator.handle_ListDeletingEvent, + passwords.handle_ConfigurationUpdatedEvent, subscriptions.handle_ListDeletedEvent, - domain.handle_DomainDeletingEvent, + switchboard.handle_ConfigurationUpdatedEvent, + i18n.handle_ConfigurationUpdatedEvent, + style_manager.handle_ConfigurationUpdatedEvent, + language_manager.handle_ConfigurationUpdatedEvent, ]) diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py index e31a1695c..c73735b35 100644 --- a/src/mailman/app/membership.py +++ b/src/mailman/app/membership.py @@ -27,7 +27,6 @@ __all__ = [ from email.utils import formataddr -from flufl.password import lookup, make_secret from zope.component import getUtility from mailman.app.notifications import send_goodbye_message @@ -96,10 +95,8 @@ def add_member(mlist, email, display_name, password, delivery_mode, language, user.display_name = ( display_name if display_name else address.display_name) user.link(address) - # Encrypt the password using the currently selected scheme. The - # scheme is recorded in the hashed password string. - scheme = lookup(config.passwords.password_scheme.upper()) - user.password = make_secret(password, scheme) + # Encrypt the password using the currently selected hash scheme. + 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/subscriptions.py b/src/mailman/app/subscriptions.py index ebbe14492..7949f83ee 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -26,8 +26,8 @@ __all__ = [ ] -from flufl.password import generate from operator import attrgetter +from passlib.utils import generate_password as generate from storm.expr import And, Or from uuid import UUID from zope.component import getUtility diff --git a/src/mailman/app/tests/test_membership.py b/src/mailman/app/tests/test_membership.py index 74e15d0e2..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. @@ -134,7 +129,7 @@ class AddMemberTest(unittest.TestCase): self.assertEqual(member.address.email, 'aperson@example.com') self.assertEqual(member.mailing_list, 'test@example.com') self.assertEqual(member.role, MemberRole.moderator) - + def test_add_member_twice(self): # Adding a member with the same role twice causes an # AlreadySubscribedError to be raised. @@ -178,21 +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: 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=') + 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/commands/cli_members.py b/src/mailman/commands/cli_members.py index aef3991d8..7c5d3b8f3 100644 --- a/src/mailman/commands/cli_members.py +++ b/src/mailman/commands/cli_members.py @@ -29,8 +29,8 @@ import sys import codecs from email.utils import formataddr, parseaddr -from flufl.password import generate from operator import attrgetter +from passlib.utils import generate_password as generate from zope.component import getUtility from zope.interface import implementer diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py index fc82a2be7..f6c39fcec 100644 --- a/src/mailman/config/config.py +++ b/src/mailman/config/config.py @@ -32,13 +32,15 @@ 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 from mailman.utilities.modules import call_name @@ -47,11 +49,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 +67,7 @@ class Configuration: self.handlers = {} self.pipelines = {} self.commands = {} + self.password_context = None def _clear(self): """Clear the cached configuration variables.""" @@ -118,26 +116,8 @@ class Configuration: """Perform post-processing after loading the configuration files.""" # Expand and set up all directories. self._expand_paths() - # Set up the switchboards. Import this here to avoid circular imports. - from mailman.core.switchboard import Switchboard - Switchboard.initialize() - # Set up all the languages. - languages = self._config.getByCategory('language', []) - language_manager = getUtility(ILanguageManager) - for language in languages: - if language.enabled: - code = language.name.split('.')[1] - language_manager.add( - code, language.charset, language.description) - # The default language must always be available. - assert self._config.mailman.default_language in language_manager, ( - 'System default language code not defined: %s' % - self._config.mailman.default_language) self.ensure_directories_exist() - getUtility(IStyleManager).populate() - # 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.""" @@ -249,3 +229,9 @@ class Configuration: """Iterate over all the style configuration sections.""" for section in self._config.getByCategory('style', []): yield section + + @property + def language_configs(self): + """Iterate over all the language configuration sections.""" + for section in self._config.getByCategory('language', []): + yield section 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 00b8d9325..e3c6e607c 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -154,10 +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. -password_scheme: ssha +# 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/i18n.py b/src/mailman/core/i18n.py index 2d7c382c7..73453ae65 100644 --- a/src/mailman/core/i18n.py +++ b/src/mailman/core/i18n.py @@ -31,7 +31,7 @@ import time from flufl.i18n import PackageStrategy, registry import mailman.messages - +from mailman.interfaces.configuration import ConfigurationUpdatedEvent _ = None @@ -113,3 +113,9 @@ def ctime(date): wday = daysofweek[wday] mon = months[mon] return _('$wday $mon $day $hh:$mm:$ss $tzname $year') + + + +def handle_ConfigurationUpdatedEvent(event): + if isinstance(event, ConfigurationUpdatedEvent): + _.default = event.config.mailman.default_language diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py index 389a45f3b..f4659e638 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.app.events import initialize as initialize_events + initialize_events() # 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. @@ -152,7 +156,6 @@ def initialize_2(debug=False, propagate_logs=None): # Initialize the rules and chains. Do the imports here so as to avoid # circular imports. from mailman.app.commands import initialize as initialize_commands - from mailman.app.events import initialize as initialize_events from mailman.core.chains import initialize as initialize_chains from mailman.core.pipelines import initialize as initialize_pipelines from mailman.core.rules import initialize as initialize_rules @@ -161,7 +164,6 @@ def initialize_2(debug=False, propagate_logs=None): initialize_chains() initialize_pipelines() initialize_commands() - initialize_events() def initialize_3(): diff --git a/src/mailman/core/switchboard.py b/src/mailman/core/switchboard.py index c65b92fac..1f16cb5fb 100644 --- a/src/mailman/core/switchboard.py +++ b/src/mailman/core/switchboard.py @@ -29,6 +29,7 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ 'Switchboard', + 'handle_ConfigurationUpdatedEvent', ] @@ -44,6 +45,7 @@ from zope.interface import implementer from mailman.config import config from mailman.email.message import Message +from mailman.interfaces.configuration import ConfigurationUpdatedEvent from mailman.interfaces.switchboard import ISwitchboard from mailman.utilities.filesystem import makedirs from mailman.utilities.string import expand @@ -67,18 +69,6 @@ elog = logging.getLogger('mailman.error') class Switchboard: """See `ISwitchboard`.""" - @staticmethod - def initialize(): - """Initialize the global switchboards for input/output.""" - for conf in config.runner_configs: - name = conf.name.split('.')[-1] - assert name not in config.switchboards, ( - 'Duplicate runner name: {0}'.format(name)) - substitutions = config.paths - substitutions['name'] = name - path = expand(conf.path, substitutions) - config.switchboards[name] = Switchboard(name, path) - def __init__(self, name, queue_directory, slice=None, numslices=1, recover=False): """Create a switchboard object. @@ -264,3 +254,19 @@ class Switchboard: self.finish(filebase, preserve=True) else: os.rename(src, dst) + + + +def handle_ConfigurationUpdatedEvent(event): + """Initialize the global switchboards for input/output.""" + if not isinstance(event, ConfigurationUpdatedEvent): + return + config = event.config + for conf in config.runner_configs: + name = conf.name.split('.')[-1] + assert name not in config.switchboards, ( + 'Duplicate runner name: {0}'.format(name)) + substitutions = config.paths + substitutions['name'] = name + path = expand(conf.path, substitutions) + config.switchboards[name] = Switchboard(name, path) diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index fb819ccc4..4a348a691 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -14,6 +14,8 @@ Here is a history of user visible changes to Mailman. Architecture ------------ + * `passlib`_ is now used for all password hashing instead of flufl.password. + The default hash is `sha512_crypt`. (LP: #1015758) * Internally, all datetimes are kept in the UTC timezone, however because of LP: #280708, they are stored in the database in naive format. * `received_time` is now added to the message metadata by the LMTP runner @@ -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/languages/manager.py b/src/mailman/languages/manager.py index 7844bd87c..87b56dbda 100644 --- a/src/mailman/languages/manager.py +++ b/src/mailman/languages/manager.py @@ -24,8 +24,11 @@ __all__ = [ 'LanguageManager', ] + +from zope.component import getUtility from zope.interface import implementer +from mailman.interfaces.configuration import ConfigurationUpdatedEvent from mailman.interfaces.languages import ILanguageManager from mailman.languages.language import Language @@ -72,3 +75,18 @@ class LanguageManager: def clear(self): """See `ILanguageManager`.""" self._languages.clear() + + + +def handle_ConfigurationUpdatedEvent(event): + if not isinstance(event, ConfigurationUpdatedEvent): + return + manager = getUtility(ILanguageManager) + for language in event.config.language_configs: + if language.enabled: + code = language.name.split('.')[1] + manager.add(code, language.charset, language.description) + # the default language must always be available. + assert event.config.mailman.default_language in manager, ( + 'system default language code not defined: {0}'.format( + event.config.mailman.default_language)) diff --git a/src/mailman/model/docs/requests.rst b/src/mailman/model/docs/requests.rst index a20823a91..a51cbc099 100644 --- a/src/mailman/model/docs/requests.rst +++ b/src/mailman/model/docs/requests.rst @@ -696,7 +696,7 @@ Frank Person is now a member of the mailing list. >>> print member.user.display_name Frank Person >>> print member.user.password - {CLEARTEXT}abcxyz + {plaintext}abcxyz Holding unsubscription requests diff --git a/src/mailman/rest/docs/users.rst b/src/mailman/rest/docs/users.rst index a20306e17..cdede10ee 100644 --- a/src/mailman/rest/docs/users.rst +++ b/src/mailman/rest/docs/users.rst @@ -94,7 +94,7 @@ It is also available via the location given in the response. created_on: 2005-08-01T07:49:23 display_name: Bart Person http_etag: "..." - password: {CLEARTEXT}bbb + password: {plaintext}bbb self_link: http://localhost:9001/3.0/users/3 user_id: 3 @@ -105,7 +105,7 @@ them with user ids. Thus, a user can be retrieved via its email address. created_on: 2005-08-01T07:49:23 display_name: Bart Person http_etag: "..." - password: {CLEARTEXT}bbb + password: {plaintext}bbb self_link: http://localhost:9001/3.0/users/3 user_id: 3 @@ -129,7 +129,7 @@ therefore cannot be retrieved. It can be reset though. created_on: 2005-08-01T07:49:23 display_name: Cris Person http_etag: "..." - password: {CLEARTEXT}... + password: {plaintext}... self_link: http://localhost:9001/3.0/users/4 user_id: 4 @@ -227,7 +227,7 @@ In fact, any of these addresses can be used to look up Bart's user record. created_on: 2005-08-01T07:49:23 display_name: Bart Person http_etag: "..." - password: {CLEARTEXT}bbb + password: {plaintext}bbb self_link: http://localhost:9001/3.0/users/3 user_id: 3 @@ -235,7 +235,7 @@ In fact, any of these addresses can be used to look up Bart's user record. created_on: 2005-08-01T07:49:23 display_name: Bart Person http_etag: "..." - password: {CLEARTEXT}bbb + password: {plaintext}bbb self_link: http://localhost:9001/3.0/users/3 user_id: 3 @@ -243,7 +243,7 @@ In fact, any of these addresses can be used to look up Bart's user record. created_on: 2005-08-01T07:49:23 display_name: Bart Person http_etag: "..." - password: {CLEARTEXT}bbb + password: {plaintext}bbb self_link: http://localhost:9001/3.0/users/3 user_id: 3 @@ -251,6 +251,6 @@ In fact, any of these addresses can be used to look up Bart's user record. created_on: 2005-08-01T07:49:23 display_name: Bart Person http_etag: "..." - password: {CLEARTEXT}bbb + password: {plaintext}bbb self_link: http://localhost:9001/3.0/users/3 user_id: 3 diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py index 4e1362120..bce541ae5 100644 --- a/src/mailman/rest/users.py +++ b/src/mailman/rest/users.py @@ -26,7 +26,7 @@ __all__ = [ ] -from flufl.password import lookup, make_secret, generate +from passlib.utils import generate_password as generate from restish import http, resource from uuid import UUID from zope.component import getUtility @@ -102,8 +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)) - scheme = lookup(config.passwords.password_scheme.upper()) - user.password = make_secret(password, scheme) + 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 1f4fc1369..3ff7d21ec 100644 --- a/src/mailman/rules/approved.py +++ b/src/mailman/rules/approved.py @@ -28,9 +28,9 @@ __all__ = [ import re from email.iterators import typed_subpart_iterator -from flufl.password import verify from zope.interface import implementer +from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.rules import IRule @@ -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 9c61a7419..3f3d54455 100644 --- a/src/mailman/rules/docs/approved.rst +++ b/src/mailman/rules/docs/approved.rst @@ -20,9 +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 flufl.password import lookup, make_secret - >>> scheme = lookup(config.passwords.password_scheme.upper()) - >>> mlist.moderator_password = make_secret('super secret', scheme) + >>> 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 d078556ba..a1b8f99ac 100644 --- a/src/mailman/rules/tests/test_approved.py +++ b/src/mailman/rules/tests/test_approved.py @@ -25,17 +25,18 @@ __all__ = [ 'TestApprovedNonASCII', 'TestApprovedPseudoHeader', 'TestApprovedPseudoHeaderMIME', + 'TestPasswordHashMigration', ] +import os import unittest -from flufl.password import lookup, make_secret - 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 @@ -48,8 +49,8 @@ class TestApproved(unittest.TestCase): def setUp(self): self._mlist = create_list('test@example.com') - scheme = lookup(config.passwords.password_scheme.upper()) - self._mlist.moderator_password = make_secret('super secret', scheme) + self._mlist.moderator_password = config.password_context.encrypt( + 'super secret') self._rule = approved.Approved() self._msg = mfs("""\ From: anne@example.com @@ -150,8 +151,8 @@ class TestApprovedPseudoHeader(unittest.TestCase): def setUp(self): self._mlist = create_list('test@example.com') - scheme = lookup(config.passwords.password_scheme.upper()) - self._mlist.moderator_password = make_secret('super secret', scheme) + self._mlist.moderator_password = config.password_context.encrypt( + 'super secret') self._rule = approved.Approved() self._msg = mfs("""\ From: anne@example.com @@ -283,8 +284,8 @@ class TestApprovedPseudoHeaderMIME(unittest.TestCase): def setUp(self): self._mlist = create_list('test@example.com') - scheme = lookup(config.passwords.password_scheme.upper()) - self._mlist.moderator_password = make_secret('super secret', scheme) + self._mlist.moderator_password = config.password_context.encrypt( + 'super secret') self._rule = approved.Approved() self._msg_text_template = """\ From: anne@example.com @@ -415,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/styles/manager.py b/src/mailman/styles/manager.py index 8ec832f18..c2729abfb 100644 --- a/src/mailman/styles/manager.py +++ b/src/mailman/styles/manager.py @@ -22,13 +22,16 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ 'StyleManager', + 'handle_ConfigurationUpdatedEvent', ] from operator import attrgetter +from zope.component import getUtility from zope.interface import implementer from zope.interface.verify import verifyObject +from mailman.interfaces.configuration import ConfigurationUpdatedEvent from mailman.interfaces.styles import ( DuplicateStyleError, IStyle, IStyleManager) from mailman.utilities.modules import call_name @@ -88,3 +91,9 @@ class StyleManager: """See `IStyleManager`.""" # Let KeyErrors percolate up. del self._styles[style.name] + + + +def handle_ConfigurationUpdatedEvent(event): + if isinstance(event, ConfigurationUpdatedEvent): + getUtility(IStyleManager).populate() diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py index 62e3b9d2a..84f215574 100644 --- a/src/mailman/testing/helpers.py +++ b/src/mailman/testing/helpers.py @@ -343,14 +343,14 @@ def call_api(url, data=None, method=None, username=None, password=None): @contextmanager def event_subscribers(*subscribers): - """Temporarily set the Zope event subscribers list. + """Temporarily extend the Zope event subscribers list. :param subscribers: A sequence of event subscribers. :type subscribers: sequence of callables, each receiving one argument, the event. """ old_subscribers = event.subscribers[:] - event.subscribers = list(subscribers) + event.subscribers.extend(subscribers) try: yield finally: @@ -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 0faa1c8e4..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: cleartext [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 new file mode 100644 index 000000000..95c85c47a --- /dev/null +++ b/src/mailman/utilities/passwords.py @@ -0,0 +1,62 @@ +# 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/>. + +"""A wrapper around passlib.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type +__all__ = [ + 'handle_ConfigurationUpdatedEvent', + ] + + + +from passlib.context import CryptContext +from pkg_resources import resource_string + +from mailman.interfaces.configuration import ConfigurationUpdatedEvent + + + +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 handle_ConfigurationUpdatedEvent(event): + if isinstance(event, ConfigurationUpdatedEvent): + # Just reset the password context. + event.config.password_context = PasswordContext(event.config) 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') |
