summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorBarry Warsaw2012-12-26 18:57:33 -0500
committerBarry Warsaw2012-12-26 18:57:33 -0500
commit2450a9c9642d06af1a60df70acb742e67959d77e (patch)
tree6783cab3d27df8e331eba17e98f142981fe4cff1 /src
parent9c5a2e27116c95484dd993c4b7ceed64ca9823e4 (diff)
parent4bfaa225b080fdc406c561476aac98c3f53b16d8 (diff)
downloadmailman-2450a9c9642d06af1a60df70acb742e67959d77e.tar.gz
mailman-2450a9c9642d06af1a60df70acb742e67959d77e.tar.zst
mailman-2450a9c9642d06af1a60df70acb742e67959d77e.zip
Diffstat (limited to 'src')
-rw-r--r--src/mailman/docs/NEWS.rst4
-rw-r--r--src/mailman/rest/docs/users.rst320
-rw-r--r--src/mailman/rest/tests/test_users.py170
-rw-r--r--src/mailman/rest/users.py27
-rw-r--r--src/mailman/testing/passlib.cfg8
-rw-r--r--src/mailman/utilities/passwords.py4
6 files changed, 384 insertions, 149 deletions
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index c0a94ab0d..aad8f37ff 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -53,6 +53,10 @@ REST
address does not change its `.verified_on` date. (LP: #1054730)
* Deleting a user through the REST API also deletes all the user's linked
addresses and memberships. (LP: #1074374)
+ * A user's password can be verified by POSTing to .../user/<id>/login. The
+ data must contain a single parameter `cleartext_password` and if this
+ matches, a 204 (No Content) will be returned, otherwise a 403 (Forbidden)
+ is returned. (LP: #1065447)
Configuration
-------------
diff --git a/src/mailman/rest/docs/users.rst b/src/mailman/rest/docs/users.rst
index efd664f27..36ec28efc 100644
--- a/src/mailman/rest/docs/users.rst
+++ b/src/mailman/rest/docs/users.rst
@@ -3,9 +3,12 @@ Users
=====
The REST API can be used to add and remove users, add and remove user
-addresses, and change their preferred address, password, or name. Users are
-different than members; the latter represents an email address subscribed to a
-specific mailing list. Users are just people that Mailman knows about.
+addresses, and change their preferred address, password, or name. The API can
+also be used to verify a user's password.
+
+Users are different than members; the latter represents an email address
+subscribed to a specific mailing list. Users are just people that Mailman
+knows about.
There are no users yet.
@@ -14,15 +17,18 @@ There are no users yet.
start: 0
total_size: 0
-When there are users in the database, they can be retrieved as a collection.
-::
+Anne is added, with an email address. Her user record gets a `user_id`.
>>> from zope.component import getUtility
>>> from mailman.interfaces.usermanager import IUserManager
>>> user_manager = getUtility(IUserManager)
-
>>> anne = user_manager.create_user('anne@example.com', 'Anne Person')
>>> transaction.commit()
+ >>> int(anne.user_id.int)
+ 1
+
+Anne's user record is returned as an entry into the collection of all users.
+
>>> dump_json('http://localhost:9001/3.0/users')
entry 0:
created_on: 2005-08-01T07:49:23
@@ -34,16 +40,10 @@ When there are users in the database, they can be retrieved as a collection.
start: 0
total_size: 1
-The user ids match.
-
- >>> json = call_http('http://localhost:9001/3.0/users')
- >>> json['entries'][0]['user_id'] == anne.user_id.int
- True
-
A user might not have a display name, in which case, the attribute will not be
returned in the REST API.
- >>> dave = user_manager.create_user('dave@example.com')
+ >>> bart = user_manager.create_user('bart@example.com')
>>> transaction.commit()
>>> dump_json('http://localhost:9001/3.0/users')
entry 0:
@@ -62,19 +62,14 @@ returned in the REST API.
total_size: 2
-Creating users via the API
-==========================
+Creating users
+==============
-New users can be created through the REST API. To do so requires the initial
-email address for the user, a password, and optionally the user's display
-name.
-::
+New users can be created by POSTing to the users collection. At a minimum,
+the user's email address must be provided.
- >>> transaction.abort()
>>> dump_json('http://localhost:9001/3.0/users', {
- ... 'email': 'bart@example.com',
- ... 'display_name': 'Bart Person',
- ... 'password': 'bbb',
+ ... 'email': 'cris@example.com',
... })
content-length: 0
date: ...
@@ -82,43 +77,43 @@ name.
server: ...
status: 201
-The user exists in the database.
-::
+Cris is now a user known to the system, but he has no display name.
- >>> bart = user_manager.get_user('bart@example.com')
- >>> bart
- <User "Bart Person" (3) at ...>
+ >>> user_manager.get_user('cris@example.com')
+ <User "" (3) at ...>
-It is also available via the location given in the response.
+Cris's user record can also be accessed via the REST API, using her user id.
+Note that because no password was given when the record was created, a random
+one was assigned to her.
>>> dump_json('http://localhost:9001/3.0/users/3')
created_on: 2005-08-01T07:49:23
- display_name: Bart Person
http_etag: "..."
- password: {plaintext}bbb
+ password: {plaintext}...
self_link: http://localhost:9001/3.0/users/3
user_id: 3
Because email addresses just have an ``@`` sign in then, there's no confusing
-them with user ids. Thus, a user can be retrieved via its email address.
+them with user ids. Thus, Cris's record can be retrieved via her email
+address.
- >>> dump_json('http://localhost:9001/3.0/users/bart@example.com')
+ >>> dump_json('http://localhost:9001/3.0/users/cris@example.com')
created_on: 2005-08-01T07:49:23
- display_name: Bart Person
http_etag: "..."
- password: {plaintext}bbb
+ password: {plaintext}...
self_link: http://localhost:9001/3.0/users/3
user_id: 3
-Users can be created without a password. A *user friendly* password will be
-assigned to them automatically, but this password will be encrypted and
-therefore cannot be retrieved. It can be reset though.
-::
+
+Providing a display name
+------------------------
+
+When a user is added, a display name can be provided.
>>> transaction.abort()
>>> dump_json('http://localhost:9001/3.0/users', {
- ... 'email': 'cris@example.com',
- ... 'display_name': 'Cris Person',
+ ... 'email': 'dave@example.com',
+ ... 'display_name': 'Dave Person',
... })
content-length: 0
date: ...
@@ -126,40 +121,73 @@ therefore cannot be retrieved. It can be reset though.
server: ...
status: 201
+Dave's user record includes his display name.
+
>>> dump_json('http://localhost:9001/3.0/users/4')
created_on: 2005-08-01T07:49:23
- display_name: Cris Person
+ display_name: Dave Person
http_etag: "..."
password: {plaintext}...
self_link: http://localhost:9001/3.0/users/4
user_id: 4
+Providing passwords
+-------------------
+
+To avoid getting assigned a random, and irretrievable password (but one which
+can be reset), you can provide a password when the user is created. By
+default, the password is provided in plain text, and it is hashed by Mailman
+before being stored.
+
+ >>> transaction.abort()
+ >>> dump_json('http://localhost:9001/3.0/users', {
+ ... 'email': 'elly@example.com',
+ ... 'display_name': 'Elly Person',
+ ... 'password': 'supersekrit',
+ ... })
+ content-length: 0
+ date: ...
+ location: http://localhost:9001/3.0/users/5
+ server: ...
+ status: 201
+
+When we view Elly's user record, we can tell that her password has been hashed
+because it has the hash algorithm prefix (i.e. the *{plaintext}* marker).
+
+ >>> dump_json('http://localhost:9001/3.0/users/5')
+ created_on: 2005-08-01T07:49:23
+ display_name: Elly Person
+ http_etag: "..."
+ password: {plaintext}supersekrit
+ self_link: http://localhost:9001/3.0/users/5
+ user_id: 5
+
+
Updating users
==============
-Users have a password and a display name. The display name can be changed
-through the REST API.
+Dave's display name can be changed through the REST API.
>>> dump_json('http://localhost:9001/3.0/users/4', {
- ... 'display_name': 'Chrissy Person',
+ ... 'display_name': 'David Person',
... }, method='PATCH')
content-length: 0
date: ...
server: ...
status: 204
-Cris's display name has been updated.
+Dave's display name has been updated.
- >>> dump_json('http://localhost:9001/3.0/users/4')
+ >>> dump_json('http://localhost:9001/3.0/users/dave@example.com')
created_on: 2005-08-01T07:49:23
- display_name: Chrissy Person
+ display_name: David Person
http_etag: "..."
password: {plaintext}...
self_link: http://localhost:9001/3.0/users/4
user_id: 4
-You can also change the user's password by passing in the new clear text
+Dave can also be assigned a new password by providing in the new cleartext
password. Mailman will hash this before it is stored internally.
>>> dump_json('http://localhost:9001/3.0/users/4', {
@@ -170,14 +198,14 @@ password. Mailman will hash this before it is stored internally.
server: ...
status: 204
-Even though you see *{plaintext}clockwork angels* below, it has still been
-hashed before storage. The default hashing algorithm for the test suite is a
-plain text hash, but you can see that it works by the addition of the
-algorithm prefix.
+As described above, even though you see *{plaintext}clockwork angels* below,
+it has still been hashed before storage. The default hashing algorithm for
+the test suite is a plain text hash, but you can see that it works by the
+addition of the algorithm prefix.
>>> dump_json('http://localhost:9001/3.0/users/4')
created_on: 2005-08-01T07:49:23
- display_name: Chrissy Person
+ display_name: David Person
http_etag: "..."
password: {plaintext}clockwork angels
self_link: http://localhost:9001/3.0/users/4
@@ -187,7 +215,7 @@ You can change both the display name and the password by PUTing the full
resource.
>>> dump_json('http://localhost:9001/3.0/users/4', {
- ... 'display_name': 'Christopherson Person',
+ ... 'display_name': 'David Personhood',
... 'cleartext_password': 'the garden',
... }, method='PUT')
content-length: 0
@@ -195,9 +223,11 @@ resource.
server: ...
status: 204
- >>> dump_json('http://localhost:9001/3.0/users/4')
+Dave's user record has been updated.
+
+ >>> dump_json('http://localhost:9001/3.0/users/dave@example.com')
created_on: 2005-08-01T07:49:23
- display_name: Christopherson Person
+ display_name: David Personhood
http_etag: "..."
password: {plaintext}the garden
self_link: http://localhost:9001/3.0/users/4
@@ -225,7 +255,7 @@ Cris's resource cannot be retrieved either by email address...
...or user id.
- >>> dump_json('http://localhost:9001/3.0/users/4')
+ >>> dump_json('http://localhost:9001/3.0/users/3')
Traceback (most recent call last):
...
HTTPError: HTTP Error 404: 404 Not Found
@@ -238,123 +268,129 @@ Cris's address records no longer exist either.
HTTPError: HTTP Error 404: 404 Not Found
-Missing users
-=============
-
-It is of course an error to attempt to access a non-existent user, either by
-user id...
-::
-
- >>> dump_json('http://localhost:9001/3.0/users/99')
- Traceback (most recent call last):
- ...
- HTTPError: HTTP Error 404: 404 Not Found
-
-...or by email address.
-::
-
- >>> dump_json('http://localhost:9001/3.0/users/zed@example.org')
- Traceback (most recent call last):
- ...
- HTTPError: HTTP Error 404: 404 Not Found
-
-You also can't update a missing user.
-
- >>> dump_json('http://localhost:9001/3.0/users/zed@example.org', {
- ... 'display_name': 'Is Dead',
- ... }, method='PATCH')
- Traceback (most recent call last):
- ...
- HTTPError: HTTP Error 404: 404 Not Found
-
- >>> dump_json('http://localhost:9001/3.0/users/zed@example.org', {
- ... 'display_name': 'Is Dead',
- ... 'cleartext_password': 'vroom',
- ... }, method='PUT')
- Traceback (most recent call last):
- ...
- HTTPError: HTTP Error 404: 404 Not Found
-
-
User addresses
==============
-Bart may have any number of email addresses associated with their user
-account. We can find out all of these through the API. The addresses are
-sorted in lexical order by original (i.e. case-preserved) email address.
-::
+Fred may have any number of email addresses associated with his user account,
+and we can find them all through the API.
+
+Through some other means, Fred registers a bunch of email addresses and
+associates them with his user account.
- >>> bart.register('bperson@example.com')
- <Address: bperson@example.com [not verified] at ...>
- >>> bart.register('bart.person@example.com')
- <Address: bart.person@example.com [not verified] at ...>
- >>> bart.register('Bart.Q.Person@example.com')
- <Address: Bart.Q.Person@example.com [not verified]
- key: bart.q.person@example.com at ...>
+ >>> fred = user_manager.create_user('fred@example.com', 'Fred Person')
+ >>> fred.register('fperson@example.com')
+ <Address: fperson@example.com [not verified] at ...>
+ >>> fred.register('fred.person@example.com')
+ <Address: fred.person@example.com [not verified] at ...>
+ >>> fred.register('Fred.Q.Person@example.com')
+ <Address: Fred.Q.Person@example.com [not verified]
+ key: fred.q.person@example.com at ...>
>>> transaction.commit()
- >>> dump_json('http://localhost:9001/3.0/users/3/addresses')
+When we access Fred's addresses via the REST API, they are sorted in lexical
+order by original (i.e. case-preserved) email address.
+
+ >>> dump_json('http://localhost:9001/3.0/users/fred@example.com/addresses')
entry 0:
- email: bart.q.person@example.com
+ email: fred.q.person@example.com
http_etag: "..."
- original_email: Bart.Q.Person@example.com
+ original_email: Fred.Q.Person@example.com
registered_on: 2005-08-01T07:49:23
self_link:
- http://localhost:9001/3.0/addresses/bart.q.person@example.com
+ http://localhost:9001/3.0/addresses/fred.q.person@example.com
entry 1:
- email: bart.person@example.com
+ email: fperson@example.com
http_etag: "..."
- original_email: bart.person@example.com
+ original_email: fperson@example.com
registered_on: 2005-08-01T07:49:23
- self_link: http://localhost:9001/3.0/addresses/bart.person@example.com
+ self_link: http://localhost:9001/3.0/addresses/fperson@example.com
entry 2:
- display_name: Bart Person
- email: bart@example.com
+ email: fred.person@example.com
http_etag: "..."
- original_email: bart@example.com
+ original_email: fred.person@example.com
registered_on: 2005-08-01T07:49:23
- self_link: http://localhost:9001/3.0/addresses/bart@example.com
+ self_link: http://localhost:9001/3.0/addresses/fred.person@example.com
entry 3:
- email: bperson@example.com
+ display_name: Fred Person
+ email: fred@example.com
http_etag: "..."
- original_email: bperson@example.com
+ original_email: fred@example.com
registered_on: 2005-08-01T07:49:23
- self_link: http://localhost:9001/3.0/addresses/bperson@example.com
+ self_link: http://localhost:9001/3.0/addresses/fred@example.com
http_etag: "..."
start: 0
total_size: 4
-In fact, any of these addresses can be used to look up Bart's user record.
+In fact, since these are all associated with Fred's user account, any of the
+addresses can be used to look up Fred's user record.
::
- >>> dump_json('http://localhost:9001/3.0/users/bart@example.com')
+ >>> dump_json('http://localhost:9001/3.0/users/fred@example.com')
created_on: 2005-08-01T07:49:23
- display_name: Bart Person
+ display_name: Fred Person
http_etag: "..."
- password: {plaintext}bbb
- self_link: http://localhost:9001/3.0/users/3
- user_id: 3
+ self_link: http://localhost:9001/3.0/users/6
+ user_id: 6
- >>> dump_json('http://localhost:9001/3.0/users/bart.person@example.com')
+ >>> dump_json('http://localhost:9001/3.0/users/fred.person@example.com')
created_on: 2005-08-01T07:49:23
- display_name: Bart Person
+ display_name: Fred Person
http_etag: "..."
- password: {plaintext}bbb
- self_link: http://localhost:9001/3.0/users/3
- user_id: 3
+ self_link: http://localhost:9001/3.0/users/6
+ user_id: 6
- >>> dump_json('http://localhost:9001/3.0/users/bperson@example.com')
+ >>> dump_json('http://localhost:9001/3.0/users/fperson@example.com')
created_on: 2005-08-01T07:49:23
- display_name: Bart Person
+ display_name: Fred Person
http_etag: "..."
- password: {plaintext}bbb
- self_link: http://localhost:9001/3.0/users/3
- user_id: 3
+ self_link: http://localhost:9001/3.0/users/6
+ user_id: 6
- >>> dump_json('http://localhost:9001/3.0/users/Bart.Q.Person@example.com')
+ >>> dump_json('http://localhost:9001/3.0/users/Fred.Q.Person@example.com')
created_on: 2005-08-01T07:49:23
- display_name: Bart Person
+ display_name: Fred Person
http_etag: "..."
- password: {plaintext}bbb
- self_link: http://localhost:9001/3.0/users/3
- user_id: 3
+ self_link: http://localhost:9001/3.0/users/6
+ user_id: 6
+
+
+Verifying passwords
+===================
+
+A user's password is stored internally in hashed form. Logging in a user is
+the process of verifying a provided clear text password against the hashed
+internal password.
+
+When Elly was added as a user, she provided a password in the clear. Now the
+password is hashed and getting her user record returns the hashed password.
+
+ >>> dump_json('http://localhost:9001/3.0/users/5')
+ created_on: 2005-08-01T07:49:23
+ display_name: Elly Person
+ http_etag: "..."
+ password: {plaintext}supersekrit
+ self_link: http://localhost:9001/3.0/users/5
+ user_id: 5
+
+Unless the client can run the hashing algorithm on the login text that Elly
+provided, and do its own comparison, the client should let the REST API handle
+password verification.
+
+This time, Elly successfully logs into Mailman.
+
+ >>> dump_json('http://localhost:9001/3.0/users/5/login', {
+ ... 'cleartext_password': 'supersekrit',
+ ... }, method='POST')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+But this time, she is unsuccessful.
+
+ >>> dump_json('http://localhost:9001/3.0/users/5/login', {
+ ... 'cleartext_password': 'not-the-password',
+ ... }, method='POST')
+ Traceback (most recent call last):
+ ...
+ HTTPError: HTTP Error 403: 403 Forbidden
diff --git a/src/mailman/rest/tests/test_users.py b/src/mailman/rest/tests/test_users.py
index cf83e096c..ae9b8130e 100644
--- a/src/mailman/rest/tests/test_users.py
+++ b/src/mailman/rest/tests/test_users.py
@@ -21,20 +21,23 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
- 'TestUsers',
'TestLP1074374',
+ 'TestLogin',
+ 'TestUsers',
]
+import os
import unittest
from urllib2 import HTTPError
from zope.component import getUtility
from mailman.app.lifecycle import create_list
+from mailman.config import config
from mailman.database.transaction import transaction
from mailman.interfaces.usermanager import IUserManager
-from mailman.testing.helpers import call_api
+from mailman.testing.helpers import call_api, configuration
from mailman.testing.layers import RESTLayer
@@ -46,12 +49,107 @@ class TestUsers(unittest.TestCase):
with transaction():
self._mlist = create_list('test@example.com')
- def test_delete_bogus_user(self):
- # Try to delete a user that does not exist.
+ def test_get_missing_user_by_id(self):
+ # You can't GET a missing user by user id.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/99')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_get_missing_user_by_address(self):
+ # You can't GET a missing user by address.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/missing@example.org')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_patch_missing_user_by_id(self):
+ # You can't PATCH a missing user by user id.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/99', {
+ 'display_name': 'Bob Dobbs',
+ }, method='PATCH')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_patch_missing_user_by_address(self):
+ # You can't PATCH a missing user by user address.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/bob@example.org', {
+ 'display_name': 'Bob Dobbs',
+ }, method='PATCH')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_put_missing_user_by_id(self):
+ # You can't PUT a missing user by user id.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/99', {
+ 'display_name': 'Bob Dobbs',
+ 'cleartext_password': 'abc123',
+ }, method='PUT')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_put_missing_user_by_address(self):
+ # You can't PUT a missing user by user address.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/bob@example.org', {
+ 'display_name': 'Bob Dobbs',
+ 'cleartext_password': 'abc123',
+ }, method='PUT')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_delete_missing_user_by_id(self):
+ # You can't DELETE a missing user by user id.
with self.assertRaises(HTTPError) as cm:
call_api('http://localhost:9001/3.0/users/99', method='DELETE')
self.assertEqual(cm.exception.code, 404)
+ def test_delete_missing_user_by_address(self):
+ # You can't DELETE a missing user by user address.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/bob@example.com',
+ method='DELETE')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_existing_user_error(self):
+ # Creating a user twice results in an error.
+ call_api('http://localhost:9001/3.0/users', {
+ 'email': 'anne@example.com',
+ })
+ # The second try returns an error.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users', {
+ 'email': 'anne@example.com',
+ })
+ self.assertEqual(cm.exception.code, 400)
+ self.assertEqual(cm.exception.reason,
+ 'Address already exists: anne@example.com')
+
+ def test_addresses_of_missing_user_id(self):
+ # Trying to get the /addresses of a missing user id results in error.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/801/addresses')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_addresses_of_missing_user_address(self):
+ # Trying to get the /addresses of a missing user id results in error.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/z@example.net/addresses')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_login_missing_user_by_id(self):
+ # Verify a password for a non-existing user, by id.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/99/login', {
+ 'cleartext_password': 'wrong',
+ })
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_login_missing_user_by_address(self):
+ # Verify a password for a non-existing user, by address.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/z@example.org/login', {
+ 'cleartext_password': 'wrong',
+ })
+ self.assertEqual(cm.exception.code, 404)
+
class TestLP1074374(unittest.TestCase):
@@ -135,3 +233,67 @@ class TestLP1074374(unittest.TestCase):
self.assertEqual(member['delivery_mode'], 'regular')
self.assertEqual(member['list_id'], 'test.example.com')
self.assertEqual(member['role'], 'member')
+
+
+
+class TestLogin(unittest.TestCase):
+ """Test user 'login' (really just password verification)."""
+
+ layer = RESTLayer
+
+ def setUp(self):
+ user_manager = getUtility(IUserManager)
+ with transaction():
+ self.anne = user_manager.create_user(
+ 'anne@example.com', 'Anne Person')
+ self.anne.password = config.password_context.encrypt('abc123')
+
+ def test_wrong_parameter(self):
+ # A bad request because it is mistyped the required attribute.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/1/login', {
+ 'hashed_password': 'bad hash',
+ })
+ self.assertEqual(cm.exception.code, 400)
+
+ def test_not_enough_parameters(self):
+ # A bad request because it is missing the required attribute.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/1/login', {
+ })
+ self.assertEqual(cm.exception.code, 400)
+
+ def test_too_many_parameters(self):
+ # A bad request because it has too many attributes.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/1/login', {
+ 'cleartext_password': 'abc123',
+ 'display_name': 'Annie Personhood',
+ })
+ self.assertEqual(cm.exception.code, 400)
+
+ def test_successful_login_updates_password(self):
+ # Passlib supports updating the hash when the hash algorithm changes.
+ # When a user logs in successfully, the password will be updated if
+ # necessary.
+ #
+ # Start by hashing Anne's password with a different hashing algorithm
+ # than the one that the REST runner uses by default during testing.
+ config_file = os.path.join(config.VAR_DIR, 'passlib-tmp.config')
+ with open(config_file, 'w') as fp:
+ print("""\
+[passlib]
+schemes = hex_md5
+""", file=fp)
+ with configuration('passwords', configuration=config_file):
+ with transaction():
+ self.anne.password = config.password_context.encrypt('abc123')
+ # Just ensure Anne's password is hashed correctly.
+ self.assertEqual(self.anne.password,
+ 'e99a18c428cb38d5f260853678922e03')
+ # Now, Anne logs in with a successful password. This should change it
+ # back to the plaintext hash.
+ call_api('http://localhost:9001/3.0/users/1/login', {
+ 'cleartext_password': 'abc123',
+ })
+ self.assertEqual(self.anne.password, '{plaintext}abc123')
diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py
index 25a49defa..3a245f09b 100644
--- a/src/mailman/rest/users.py
+++ b/src/mailman/rest/users.py
@@ -119,8 +119,8 @@ class AllUsers(_UserBase):
try:
user = getUtility(IUserManager).create_user(**arguments)
except ExistingAddressError as error:
- return http.bad_request([], b'Address already exists {0}'.format(
- error.email))
+ return http.bad_request(
+ [], b'Address already exists: {0}'.format(error.address))
if password is None:
# This will have to be reset since it cannot be retrieved.
password = generate(int(config.passwords.password_length))
@@ -166,6 +166,8 @@ class AUser(_UserBase):
@resource.child()
def addresses(self, request, segments):
"""/users/<uid>/addresses"""
+ if self._user is None:
+ return http.not_found()
return UserAddresses(self._user)
@resource.DELETE()
@@ -226,3 +228,24 @@ class AUser(_UserBase):
except ValueError as error:
return http.bad_request([], str(error))
return no_content()
+
+ @resource.child('login')
+ def login(self, request, segments):
+ """Log the user in, sort of, by verifying a given password."""
+ if self._user is None:
+ return http.not_found()
+ # We do not want to encrypt the plaintext password given in the POST
+ # data. That would hash the password, but we need to have the
+ # plaintext in order to pass into passlib.
+ validator = Validator(cleartext_password=GetterSetter(unicode))
+ try:
+ values = validator(request)
+ except ValueError as error:
+ return http.bad_request([], str(error))
+ is_valid, new_hash = config.password_context.verify(
+ values['cleartext_password'], self._user.password)
+ if is_valid:
+ if new_hash is not None:
+ self._user.password = new_hash
+ return no_content()
+ return http.forbidden()
diff --git a/src/mailman/testing/passlib.cfg b/src/mailman/testing/passlib.cfg
index 225ecd49b..2779ed89d 100644
--- a/src/mailman/testing/passlib.cfg
+++ b/src/mailman/testing/passlib.cfg
@@ -1,4 +1,10 @@
[passlib]
# Use a predictable hashing algorithm with plain text and no salt. This is
# *only* useful for debugging and unit testing.
-schemes = roundup_plaintext
+#
+# We add the hex_md5 scheme for hash migration tests. The old hash will be
+# hex_md5 (which is not salted and thus reproducible), but since this is
+# deprecated here, it will get "ugpraded" to roundup_plaintext when
+# successfully verified.
+schemes = roundup_plaintext, hex_md5
+deprecated = hex_md5
diff --git a/src/mailman/utilities/passwords.py b/src/mailman/utilities/passwords.py
index 44fdbc14f..9abc59402 100644
--- a/src/mailman/utilities/passwords.py
+++ b/src/mailman/utilities/passwords.py
@@ -63,6 +63,10 @@ class PasswordContext:
:type password:
:param hashed: The hash string to compare to.
:type hashed: string
+ :return: 2-tuple where the first element is a flag indicating whether
+ the password verified or not, and the second value whether the
+ existing hash needs to be replaced (a str if so, else None).
+ :rtype: 2-tuple
"""
return self._context.verify_and_update(password, hashed)