Merge lp:~allenap/maas/authenticate-to-api into lp:~maas-committers/maas/trunk
- authenticate-to-api
- Merge into trunk
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 |
Related bugs: |
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.
Description of the change
Gavin Panella (allenap) wrote : | # |
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...
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.
MAAS Lander (maas-lander) wrote : | # |
Attempt to merge into lp:maas failed due to conflicts:
text conflict in src/maasserver/
MAAS Lander (maas-lander) wrote : | # |
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://
Get:2 http://
Get:3 http://
Get:4 http://
Fetched 306 kB in 0s (617 kB/s)
Reading package lists...
sudo DEBIAN_
--no-
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~
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubun
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+
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...
MAAS Lander (maas-lander) wrote : | # |
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://
Get:2 http://
Get:3 http://
Get:4 http://
Fetched 306 kB in 0s (661 kB/s)
Reading package lists...
sudo DEBIAN_
--no-
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~
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubun
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+
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...
Gavin Panella (allenap) wrote : | # |
Spurious and unrelated test failure in TestNodePreviou
MAAS Lander (maas-lander) wrote : | # |
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://
Get:2 http://
Get:3 http://
Get:4 http://
Fetched 306 kB in 0s (623 kB/s)
Reading package lists...
sudo DEBIAN_
--no-
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~
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubun
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+
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
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")) |
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.