Merge lp:~allenap/maas/authenticate-to-api into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 5827
Proposed branch: lp:~allenap/maas/authenticate-to-api
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 384 lines (+253/-2)
7 files modified
docs/version.rst (+8/-0)
src/maasserver/api/version.py (+2/-0)
src/maasserver/middleware.py (+2/-0)
src/maasserver/models/signals/tests/test_nodes.py (+7/-0)
src/maasserver/urls.py (+2/-0)
src/maasserver/views/account.py (+70/-2)
src/maasserver/views/tests/test_account.py (+162/-0)
To merge this branch: bzr merge lp:~allenap/maas/authenticate-to-api
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Review via email: mp+319868@code.launchpad.net

Commit message

New authenticate view to allow client libraries to get an API token from a username+password.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

I've now also added an `authenticate-api` capability so that python-libmaas (and others) can know cleanly if /accounts/authenticate/ is available, rather than interpreting HTTP status codes. Right now MAAS appears to send a 302 redirect to the login page if /accounts/authenticate/ does not exist, but earlier and later versions may behave differently, sending 401, 403, or 404 for example. It's better to have an unambiguous way to know.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

I like it!

I wonder if this could be improved by allowing the user to grab a credential based on the 'comment' field. But that could be done in another branch...

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

Thanks for the review.

> I wonder if this could be improved by allowing the user to grab a
> credential based on the 'comment' field. But that could be done in
> another branch...

This does it already, but I hadn't documented it. I've added some blurb
to the docstring to explain that. I also fixed a couple of slightly
broken tests; I spotted that one test in particular was not testing
quite what it claimed to test.

Revision history for this message
MAAS Lander (maas-lander) wrote :

Attempt to merge into lp:maas failed due to conflicts:

text conflict in src/maasserver/views/account.py

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (32.2 KiB)

The attempt to merge lp:~allenap/maas/authenticate-to-api into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease [102 kB]
Get:4 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Fetched 306 kB in 0s (617 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libnss-wrapper libpq-dev make nodejs-legacy npm postgresql psmisc pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
authbind is already the newest version (2.1.1+nmu1).
avahi-utils is already the newest version (0.6.32~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
git is already the newest version (1:2.7.4-0ubuntu1).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newest version (1.11.3+dfsg-4).
libjs-yui3-full is already the newest version (3.5.1-1ubuntu3).
libjs-yui3-min is already the newest version (3.5.1-1ubuntu3).
make is already the newest version (4.1-6).
postgresql is already the newest version (9.5+173).
psmisc is already the newest version (22.21-2.1build1).
pxelinux is already the newest version (3:6.03+dfsg-11ubuntu1).
python-formencode is already the newest version (1.3.0-0ubuntu5).
python-lxml is already the newest version (3.5.0-1build1).
python-netaddr is already the newest version (0.7.18-1).
python-netifac...

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.8 MiB)

The attempt to merge lp:~allenap/maas/authenticate-to-api into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease [102 kB]
Get:4 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Fetched 306 kB in 0s (661 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libnss-wrapper libpq-dev make nodejs-legacy npm postgresql psmisc pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
authbind is already the newest version (2.1.1+nmu1).
avahi-utils is already the newest version (0.6.32~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
git is already the newest version (1:2.7.4-0ubuntu1).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newest version (1.11.3+dfsg-4).
libjs-yui3-full is already the newest version (3.5.1-1ubuntu3).
libjs-yui3-min is already the newest version (3.5.1-1ubuntu3).
make is already the newest version (4.1-6).
postgresql is already the newest version (9.5+173).
psmisc is already the newest version (22.21-2.1build1).
pxelinux is already the newest version (3:6.03+dfsg-11ubuntu1).
python-formencode is already the newest version (1.3.0-0ubuntu5).
python-lxml is already the newest version (3.5.0-1build1).
python-netaddr is already the newest version (0.7.18-1).
python-netifac...

Revision history for this message
Gavin Panella (allenap) wrote :

Spurious and unrelated test failure in TestNodePreviousStatus.test_changing_status_updates_previous_status. Fixed it though!

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.8 MiB)

The attempt to merge lp:~allenap/maas/authenticate-to-api into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Get:3 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Get:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease [102 kB]
Fetched 306 kB in 0s (623 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libnss-wrapper libpq-dev make nodejs-legacy npm postgresql psmisc pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
authbind is already the newest version (2.1.1+nmu1).
avahi-utils is already the newest version (0.6.32~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
git is already the newest version (1:2.7.4-0ubuntu1).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newest version (1.11.3+dfsg-4).
libjs-yui3-full is already the newest version (3.5.1-1ubuntu3).
libjs-yui3-min is already the newest version (3.5.1-1ubuntu3).
make is already the newest version (4.1-6).
postgresql is already the newest version (9.5+173).
psmisc is already the newest version (22.21-2.1build1).
pxelinux is already the newest version (3:6.03+dfsg-11ubuntu1).
python-formencode is already the newest version (1.3.0-0ubuntu5).
python-lxml is already the newest version (3.5.0-1build1).
python-netaddr is already the newest version (0.7.18-1).
python-netifac...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'docs/version.rst'
2--- docs/version.rst 2016-11-17 11:46:08 +0000
3+++ docs/version.rst 2017-03-20 11:11:54 +0000
4@@ -79,3 +79,11 @@
5 ``bridging-automatic-ubuntu``
6 Deploy nodes, automatically configuring bridges on all interfaces.
7 Available since 2.1 on Ubuntu deployments.
8+
9+.. _cap_authenticate_api:
10+
11+``authenticate-api``
12+ An ``/accounts/authenticate/`` endpoint is available for API clients.
13+ Clients can pass a username and password and, assuming they are valid,
14+ receive in return an API token. The caller is *not* logged-in to MAAS
15+ via a session cookie.
16
17=== modified file 'src/maasserver/api/version.py'
18--- src/maasserver/api/version.py 2016-11-17 11:46:08 +0000
19+++ src/maasserver/api/version.py 2017-03-20 11:11:54 +0000
20@@ -23,6 +23,7 @@
21 CAP_NETWORK_DEPLOYMENT_UBUNTU = 'network-deployment-ubuntu'
22 CAP_BRIDGING_INTERFACE_UBUNTU = 'bridging-interface-ubuntu'
23 CAP_BRIDGING_AUTOMATIC_UBUNTU = 'bridging-automatic-ubuntu'
24+CAP_AUTHENTICATE_API = 'authenticate-api'
25
26 API_CAPABILITIES_LIST = [
27 CAP_NETWORKS_MANAGEMENT,
28@@ -33,6 +34,7 @@
29 CAP_NETWORK_DEPLOYMENT_UBUNTU,
30 CAP_BRIDGING_INTERFACE_UBUNTU,
31 CAP_BRIDGING_AUTOMATIC_UBUNTU,
32+ CAP_AUTHENTICATE_API,
33 ]
34
35
36
37=== modified file 'src/maasserver/middleware.py'
38--- src/maasserver/middleware.py 2016-09-22 02:53:33 +0000
39+++ src/maasserver/middleware.py 2017-03-20 11:11:54 +0000
40@@ -84,6 +84,8 @@
41 public_url_roots = [
42 # Login page: must be visible to anonymous users.
43 reverse('login'),
44+ # Authentication: must be visible to anonymous users.
45+ reverse('authenticate'),
46 # The combo loaders are publicly accessible.
47 reverse('combo-yui'),
48 # Static resources are publicly visible.
49
50=== modified file 'src/maasserver/models/signals/tests/test_nodes.py'
51--- src/maasserver/models/signals/tests/test_nodes.py 2017-03-10 15:41:12 +0000
52+++ src/maasserver/models/signals/tests/test_nodes.py 2017-03-20 11:11:54 +0000
53@@ -17,6 +17,7 @@
54 REGION_SERVICES,
55 Service,
56 )
57+from maasserver.models.signals import power
58 from maasserver.node_status import NODE_TRANSITIONS
59 from maasserver.testing.factory import factory
60 from maasserver.testing.testcase import MAASServerTestCase
61@@ -34,6 +35,12 @@
62 class TestNodePreviousStatus(MAASServerTestCase):
63 """Test that `previous_status` is set when the status is changed."""
64
65+ def setUp(self):
66+ super(TestNodePreviousStatus, self).setUp()
67+ # Disable power signals: some status transitions prompt a power check.
68+ self.addCleanup(power.signals.enable)
69+ power.signals.disable()
70+
71 def test_changing_status_updates_previous_status(self):
72 node = factory.make_Node()
73 old_status = node.status
74
75=== modified file 'src/maasserver/urls.py'
76--- src/maasserver/urls.py 2016-09-22 13:16:12 +0000
77+++ src/maasserver/urls.py 2017-03-20 11:11:54 +0000
78@@ -19,6 +19,7 @@
79 )
80 from maasserver.views import TextTemplateView
81 from maasserver.views.account import (
82+ authenticate,
83 login,
84 logout,
85 )
86@@ -70,6 +71,7 @@
87 urlpatterns += patterns(
88 'maasserver.views',
89 url(r'^accounts/login/$', login, name='login'),
90+ url(r'^accounts/authenticate/$', authenticate, name='authenticate'),
91 url(
92 r'^images-stream/streams/v1/(?P<filename>.*)$',
93 simplestreams_stream_handler, name='simplestreams_stream_handler'),
94
95=== modified file 'src/maasserver/views/account.py'
96--- src/maasserver/views/account.py 2017-03-15 13:55:52 +0000
97+++ src/maasserver/views/account.py 2017-03-20 11:11:54 +0000
98@@ -4,22 +4,35 @@
99 """Account views."""
100
101 __all__ = [
102+ "authenticate",
103 "login",
104 "logout",
105 ]
106
107 from django import forms
108 from django.conf import settings as django_settings
109-from django.contrib.auth import REDIRECT_FIELD_NAME
110+from django.contrib.auth import (
111+ authenticate as dj_authenticate,
112+ REDIRECT_FIELD_NAME,
113+)
114 from django.contrib.auth.views import (
115 login as dj_login,
116 logout as dj_logout,
117 )
118 from django.core.urlresolvers import reverse
119-from django.http import HttpResponseRedirect
120+from django.http import (
121+ HttpResponseForbidden,
122+ HttpResponseNotAllowed,
123+ HttpResponseRedirect,
124+ JsonResponse,
125+)
126 from django.shortcuts import render_to_response
127 from django.template import RequestContext
128 from maasserver.models import UserProfile
129+from maasserver.models.user import (
130+ create_auth_token,
131+ get_auth_tokens,
132+)
133
134
135 def login(request):
136@@ -62,3 +75,58 @@
137 {'form': form},
138 context_instance=RequestContext(request),
139 )
140+
141+
142+def authenticate(request):
143+ """Authenticate a user, but do *not* log them in.
144+
145+ If the correct username and password are given, credentials suitable for
146+ use with MAAS's Web API are returned. This can be used by client libraries
147+ to exchange a username+password for an API token.
148+
149+ Accepts HTTP POST requests with the following parameters:
150+
151+ username: The user's username.
152+ password: The user's password.
153+ consumer: The name to use for the token, which can be used to later
154+ understand which consumer requested and is using a token. Optional.
155+
156+ If `consumer` is provided, existing credentials belonging to the user with
157+ a matching consumer will be returned, if any exist, else new credentials
158+ will be created and labelled with `consumer` before being returned.
159+
160+ If `consumer` is not provided, the earliest created credentials belonging
161+ to the user will be returned. If no preexisting credentials exist, new
162+ credentials will be created and returned.
163+ """
164+ if request.method != "POST":
165+ return HttpResponseNotAllowed(["POST"])
166+
167+ username = request.POST.get("username")
168+ password = request.POST.get("password")
169+ consumer = request.POST.get("consumer")
170+ user = dj_authenticate(username=username, password=password)
171+
172+ if user is None or not user.is_active:
173+ # This is_active check mimics confirm_login_allowed from Django's
174+ # django.contrib.auth.forms.AuthenticationForm.
175+ return HttpResponseForbidden()
176+
177+ # Find an existing token. There might be more than one so take the first.
178+ tokens = get_auth_tokens(user)
179+ if consumer is not None:
180+ tokens = tokens.filter(consumer__name=consumer)
181+ token = tokens.first()
182+
183+ # When no existing token is found, create a new one.
184+ if token is None:
185+ token = create_auth_token(user, consumer)
186+
187+ # Return something with the same shape as that rendered by
188+ # AccountHandler.create_authorisation_token.
189+ return JsonResponse({
190+ "consumer_key": token.consumer.key,
191+ "name": token.consumer.name,
192+ "token_key": token.key,
193+ "token_secret": token.secret,
194+ })
195
196=== modified file 'src/maasserver/views/tests/test_account.py'
197--- src/maasserver/views/tests/test_account.py 2017-03-15 13:55:52 +0000
198+++ src/maasserver/views/tests/test_account.py 2017-03-20 11:11:54 +0000
199@@ -5,6 +5,7 @@
200
201 __all__ = []
202
203+from http import HTTPStatus
204 import http.client
205
206 from django.conf import settings
207@@ -17,12 +18,22 @@
208 fromstring,
209 tostring,
210 )
211+from maasserver.models.user import (
212+ create_auth_token,
213+ get_auth_tokens,
214+)
215 from maasserver.testing import (
216 extract_redirect,
217 get_content_links,
218 )
219 from maasserver.testing.factory import factory
220+from maasserver.testing.matchers import HasStatusCode
221 from maasserver.testing.testcase import MAASServerTestCase
222+from maasserver.utils.converters import json_load_bytes
223+from testtools.matchers import (
224+ ContainsDict,
225+ Equals,
226+)
227
228
229 class TestLoginLegacy(MAASServerTestCase):
230@@ -137,3 +148,154 @@
231 self.client.login(username=user.username, password=password)
232 self.client.post(reverse('logout'))
233 self.assertNotIn(SESSION_KEY, self.client.session)
234+
235+
236+def token_to_dict(token):
237+ return {
238+ "token_key": token.key,
239+ "token_secret": token.secret,
240+ "consumer_key": token.consumer.key,
241+ "name": token.consumer.name,
242+ }
243+
244+
245+class TestAuthenticate(MAASServerTestCase):
246+ """Tests for the `authenticate` view."""
247+
248+ def test__returns_existing_credentials(self):
249+ username = factory.make_name("username")
250+ password = factory.make_name("password")
251+ user = factory.make_User(username, password)
252+ [token] = get_auth_tokens(user)
253+ response = self.client.post(
254+ reverse("authenticate"), data={
255+ "username": username,
256+ "password": password,
257+ })
258+ self.assertThat(response, HasStatusCode(HTTPStatus.OK))
259+ self.assertThat(
260+ json_load_bytes(response.content),
261+ Equals(token_to_dict(token)))
262+
263+ def test__returns_first_of_existing_credentials(self):
264+ username = factory.make_name("username")
265+ password = factory.make_name("password")
266+ user = factory.make_User(username, password)
267+ [token] = get_auth_tokens(user)
268+ for i in range(1, 6):
269+ create_auth_token(user, "Token #%d" % i)
270+ response = self.client.post(
271+ reverse("authenticate"), data={
272+ "username": username,
273+ "password": password,
274+ })
275+ self.assertThat(response, HasStatusCode(HTTPStatus.OK))
276+ self.assertThat(
277+ json_load_bytes(response.content),
278+ Equals(token_to_dict(token)))
279+
280+ def test__returns_existing_named_credentials(self):
281+ username = factory.make_name("username")
282+ password = factory.make_name("password")
283+ consumer = factory.make_name("consumer")
284+ user = factory.make_User(username, password)
285+ token = create_auth_token(user, consumer)
286+ response = self.client.post(
287+ reverse("authenticate"), data={
288+ "username": username,
289+ "password": password,
290+ "consumer": consumer,
291+ })
292+ self.assertThat(response, HasStatusCode(HTTPStatus.OK))
293+ self.assertThat(
294+ json_load_bytes(response.content),
295+ Equals(token_to_dict(token)))
296+
297+ def test__returns_first_of_existing_named_credentials(self):
298+ username = factory.make_name("username")
299+ password = factory.make_name("password")
300+ consumer = factory.make_name("consumer")
301+ user = factory.make_User(username, password)
302+ tokens = [create_auth_token(user, consumer) for _ in range(1, 6)]
303+ response = self.client.post(
304+ reverse("authenticate"), data={
305+ "username": username,
306+ "password": password,
307+ "consumer": consumer,
308+ })
309+ self.assertThat(response, HasStatusCode(HTTPStatus.OK))
310+ self.assertThat(
311+ json_load_bytes(response.content),
312+ Equals(token_to_dict(tokens[0])))
313+
314+ def test__returns_new_credentials(self):
315+ username = factory.make_name("username")
316+ password = factory.make_name("password")
317+ user = factory.make_User(username, password)
318+ get_auth_tokens(user).delete() # Delete all tokens.
319+ response = self.client.post(
320+ reverse("authenticate"), data={
321+ "username": username,
322+ "password": password,
323+ })
324+ self.assertThat(response, HasStatusCode(HTTPStatus.OK))
325+ [token] = get_auth_tokens(user)
326+ self.assertThat(
327+ json_load_bytes(response.content),
328+ Equals(token_to_dict(token)))
329+
330+ def test__returns_new_named_credentials(self):
331+ username = factory.make_name("username")
332+ password = factory.make_name("password")
333+ consumer = factory.make_name("consumer")
334+ user = factory.make_User(username, password)
335+ get_auth_tokens(user).delete() # Delete all tokens.
336+ response = self.client.post(
337+ reverse("authenticate"), data={
338+ "username": username,
339+ "password": password,
340+ "consumer": consumer,
341+ })
342+ self.assertThat(response, HasStatusCode(HTTPStatus.OK))
343+ self.assertThat(
344+ json_load_bytes(response.content),
345+ ContainsDict({"name": Equals(consumer)}))
346+
347+ def test__rejects_unknown_username(self):
348+ username = factory.make_name("username")
349+ password = factory.make_name("password")
350+ response = self.client.post(
351+ reverse("authenticate"), data={
352+ "username": username,
353+ "password": password,
354+ })
355+ self.assertThat(response, HasStatusCode(HTTPStatus.FORBIDDEN))
356+
357+ def test__rejects_incorrect_password(self):
358+ username = factory.make_name("username")
359+ password = factory.make_name("password")
360+ factory.make_User(username, password)
361+ response = self.client.post(
362+ reverse("authenticate"), data={
363+ "username": username,
364+ "password": password + "-garbage",
365+ })
366+ self.assertThat(response, HasStatusCode(HTTPStatus.FORBIDDEN))
367+
368+ def test__rejects_inactive_user(self):
369+ username = factory.make_name("username")
370+ password = factory.make_name("password")
371+ user = factory.make_User(username, password)
372+ user.is_active = False
373+ user.save()
374+ response = self.client.post(
375+ reverse("authenticate"), data={
376+ "username": username,
377+ "password": password,
378+ })
379+ self.assertThat(response, HasStatusCode(HTTPStatus.FORBIDDEN))
380+
381+ def test__rejects_GET(self):
382+ response = self.client.get(reverse("authenticate"))
383+ self.assertThat(response, HasStatusCode(HTTPStatus.METHOD_NOT_ALLOWED))
384+ self.assertThat(response["Allow"], Equals("POST"))