Merge ~blake-rouse/maas:login-return-json into maas:master

Proposed by Blake Rouse
Status: Merged
Approved by: Blake Rouse
Approved revision: a021cd495513dcc4f509a056c6748b18ca0b76da
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~blake-rouse/maas:login-return-json
Merge into: maas:master
Diff against target: 252 lines (+116/-7)
6 files modified
debian/control (+1/-0)
required-packages/base (+1/-0)
snap/snapcraft.yaml (+1/-0)
src/maasserver/views/account.py (+50/-5)
src/maasserver/views/tests/test_account.py (+62/-2)
utilities/check-imports (+1/-0)
Reviewer Review Type Date Requested Status
Alberto Donato (community) Approve
MAAS Lander Approve
Review via email: mp+374088@code.launchpad.net

Commit message

Return JSON for login and logout when the HTTP includes application/json header.

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b login-return-json lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci-jenkins.internal:8080/job/maas/job/branch-tester/6724/console
COMMIT: 21c758992b5895df44258a07e5d4f72ec971a6eb

review: Needs Fixing
~blake-rouse/maas:login-return-json updated
52d63b2... by Blake Rouse

Fix check-imports to allow mimeparse as its already a dependency of piston.

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

UNIT TESTS
-b login-return-json lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 52d63b2ea1de204d7c4b8910ec9e9ce5807a232d

review: Approve
Revision history for this message
Alberto Donato (ack) wrote :

LGTM with a couple of comments inline

review: Approve
~blake-rouse/maas:login-return-json updated
a021cd4... by Blake Rouse

Add python3-mimeparse as a direct dependency. Fix LoginJSONView class doc.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/debian/control b/debian/control
2index 394235a..a1e0e0f 100644
3--- a/debian/control
4+++ b/debian/control
5@@ -85,6 +85,7 @@ Depends: bind9 (>= 1:9.10.3.dfsg.P2-5~),
6 python3-djorm-ext-pgarray,
7 python3-maas-provisioningserver (= ${binary:Version}),
8 python3-macaroonbakery (>= 1.1.3),
9+ python3-mimeparse,
10 python3-petname,
11 python3-requests,
12 ubuntu-keyring,
13diff --git a/required-packages/base b/required-packages/base
14index 1675b14..db44002 100644
15--- a/required-packages/base
16+++ b/required-packages/base
17@@ -38,6 +38,7 @@ python3-jsonschema
18 python3-lxml
19 python3-macaroonbakery
20 python3-markupsafe
21+python3-mimeparse
22 python3-novaclient
23 python3-netaddr
24 python3-netifaces
25diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
26index 7a61edc..4045ed6 100644
27--- a/snap/snapcraft.yaml
28+++ b/snap/snapcraft.yaml
29@@ -113,6 +113,7 @@ parts:
30 - python3-httplib2
31 - python3-jsonschema
32 - python3-lxml
33+ - python3-mimeparse
34 - python3-netaddr
35 - python3-netifaces
36 - python3-paramiko
37diff --git a/src/maasserver/views/account.py b/src/maasserver/views/account.py
38index dc90f83..2bce574 100644
39--- a/src/maasserver/views/account.py
40+++ b/src/maasserver/views/account.py
41@@ -5,14 +5,20 @@
42
43 __all__ = ["authenticate", "login", "logout"]
44
45+import json
46+import mimeparse
47+
48 from django import forms
49 from django.conf import settings as django_settings
50 from django.contrib.auth import (
51 authenticate as dj_authenticate,
52 REDIRECT_FIELD_NAME,
53 )
54-from django.contrib.auth.views import login as dj_login, logout as dj_logout
55+from django.contrib.auth.views import LoginView, logout as dj_logout
56+from django.contrib.auth.forms import AuthenticationForm
57 from django.http import (
58+ HttpResponse,
59+ HttpResponseBadRequest,
60 HttpResponseForbidden,
61 HttpResponseNotAllowed,
62 HttpResponseRedirect,
63@@ -30,6 +36,32 @@ from maasserver.utils.django_urls import reverse
64 from provisioningserver.events import EVENT_TYPES
65
66
67+def wants_json_response(request):
68+ """Return True when the requestor wants a JSON response."""
69+ try:
70+ return ("application", "json") == mimeparse.parse_mime_type(
71+ request.META.get("HTTP_ACCEPT", "")
72+ )[:2]
73+ except mimeparse.MimeTypeParseException:
74+ return False
75+
76+
77+class LoginJSONView(LoginView):
78+ """A `LoginView` that conditionally returns JSON instead of a HTML view."""
79+
80+ def form_valid(self, form):
81+ if wants_json_response(self.request):
82+ return HttpResponse(status=204)
83+ return super().form_valid(form)
84+
85+ def form_invalid(self, form):
86+ if wants_json_response(self.request):
87+ return HttpResponseBadRequest(
88+ json.dumps(form.errors), content_type="application/json"
89+ )
90+ return super().form_invalid(form)
91+
92+
93 @csrf_exempt
94 def login(request):
95 extra_context = {
96@@ -38,6 +70,8 @@ def login(request):
97 "external_auth_url": Config.objects.get_config("external_auth_url"),
98 }
99 if request.user.is_authenticated:
100+ if wants_json_response(request):
101+ return HttpResponse(status=204)
102 return HttpResponseRedirect(reverse("index"))
103 else:
104 redirect_url = request.GET.get(
105@@ -47,11 +81,13 @@ def login(request):
106 redirect_field_name = None # Ignore next page.
107 else:
108 redirect_field_name = REDIRECT_FIELD_NAME
109- result = dj_login(
110- request,
111+ result = LoginJSONView.as_view(
112+ template_name="registration/login.html",
113 redirect_field_name=redirect_field_name,
114+ form_class=AuthenticationForm,
115 extra_context=extra_context,
116- )
117+ redirect_authenticated_user=False,
118+ )(request)
119 if request.user.is_authenticated:
120 create_audit_event(
121 EVENT_TYPES.AUTHORISATION,
122@@ -88,10 +124,19 @@ def logout(request):
123 % ("admin" if request.user.is_superuser else "user")
124 ),
125 )
126- return dj_logout(request, next_page=reverse("login"))
127+ response = dj_logout(request, next_page=reverse("login"))
128+ if wants_json_response(request):
129+ return HttpResponse(status=204)
130+ return response
131+ elif wants_json_response(request):
132+ return HttpResponseBadRequest(
133+ json.dumps(form.errors), content_type="application/json"
134+ )
135 else:
136 form = LogoutForm()
137
138+ if wants_json_response(request):
139+ return HttpResponseNotAllowed([request.method])
140 return render(request, "maasserver/logout_confirm.html", {"form": form})
141
142
143diff --git a/src/maasserver/views/tests/test_account.py b/src/maasserver/views/tests/test_account.py
144index 2dba63a..7e84764 100644
145--- a/src/maasserver/views/tests/test_account.py
146+++ b/src/maasserver/views/tests/test_account.py
147@@ -50,6 +50,16 @@ class TestLogin(MAASServerTestCase):
148 response = self.client.get(reverse("login"))
149 self.assertEqual(reverse("index"), extract_redirect(response))
150
151+ def test_login_json_returns_204_when_already_authenticated(self):
152+ password = factory.make_string()
153+ user = factory.make_User(password=password)
154+ self.client.handler.enforce_csrf_checks = True
155+ self.client.login(username=user.username, password=password)
156+ response = self.client.get(
157+ reverse("login"), HTTP_ACCEPT="application/json"
158+ )
159+ self.assertThat(response, HasStatusCode(http.client.NO_CONTENT))
160+
161 def test_login_doesnt_redirect_to_logout_GET(self):
162 password = factory.make_string()
163 user = factory.make_User(password=password)
164@@ -100,6 +110,31 @@ class TestLogin(MAASServerTestCase):
165 )
166 self.assertEqual(reverse("prefs"), extract_redirect(response))
167
168+ def test_login_json_returns_204_on_authentication(self):
169+ password = factory.make_string()
170+ user = factory.make_User(password=password)
171+ self.client.handler.enforce_csrf_checks = True
172+ response = self.client.post(
173+ reverse("login"),
174+ {"username": user.username, "password": password},
175+ HTTP_ACCEPT="application/json",
176+ )
177+ self.assertThat(response, HasStatusCode(http.client.NO_CONTENT))
178+
179+ def test_login_json_returns_400_on_bad_authentication(self):
180+ password = factory.make_string()
181+ user = factory.make_User(password=password)
182+ self.client.handler.enforce_csrf_checks = True
183+ response = self.client.post(
184+ reverse("login"),
185+ {
186+ "username": factory.make_name("username"),
187+ "password": factory.make_name("password"),
188+ },
189+ HTTP_ACCEPT="application/json",
190+ )
191+ self.assertThat(response, HasStatusCode(http.client.BAD_REQUEST))
192+
193 def test_login_sets_autocomplete_off_in_production(self):
194 self.patch(settings, "DEBUG", False)
195 factory.make_User()
196@@ -161,10 +196,10 @@ class TestLogout(MAASServerTestCase):
197 logout_link, get_content_links(response, element="#user-options")
198 )
199
200- def test_loggout_uses_POST(self):
201+ def test_logout_uses_POST(self):
202 # Using POST for logging out, along with Django's csrf_token
203 # tag, guarantees that we're protected against CSRF attacks on
204- # the loggout page.
205+ # the logout page.
206 password = factory.make_string()
207 user = factory.make_User(password=password)
208 self.client.handler.enforce_csrf_checks = True
209@@ -173,6 +208,31 @@ class TestLogout(MAASServerTestCase):
210 self.client.post(reverse("logout"))
211 self.assertNotIn(SESSION_KEY, self.client.session)
212
213+ def test_logout_json_returns_204(self):
214+ password = factory.make_string()
215+ user = factory.make_User(password=password)
216+ self.client.handler.enforce_csrf_checks = True
217+ self.client.login(username=user.username, password=password)
218+ self.client.handler.enforce_csrf_checks = False
219+ response = self.client.post(
220+ reverse("logout"), HTTP_ACCEPT="application/json"
221+ )
222+ self.assertThat(response, HasStatusCode(http.client.NO_CONTENT))
223+ self.assertNotIn(SESSION_KEY, self.client.session)
224+
225+ def test_logout_json_GET_returns_405(self):
226+ password = factory.make_string()
227+ user = factory.make_User(password=password)
228+ self.client.handler.enforce_csrf_checks = True
229+ self.client.login(username=user.username, password=password)
230+ self.client.handler.enforce_csrf_checks = False
231+ response = self.client.get(
232+ reverse("logout"), HTTP_ACCEPT="application/json"
233+ )
234+ self.assertThat(
235+ response, HasStatusCode(http.client.METHOD_NOT_ALLOWED)
236+ )
237+
238 def test_logout_creates_audit_event(self):
239 password = factory.make_string()
240 user = factory.make_User(password=password)
241diff --git a/utilities/check-imports b/utilities/check-imports
242index 338a4ca..56f37c6 100755
243--- a/utilities/check-imports
244+++ b/utilities/check-imports
245@@ -268,6 +268,7 @@ RegionControllerRule = Rule(
246 Allow("macaroonbakery|macaroonbakery.**"),
247 Allow("markupsafe|markupsafe.**"),
248 Allow("metadataserver|metadataserver.**"),
249+ Allow("mimeparse"),
250 Allow("netaddr|netaddr.**"),
251 Allow("oauth|oauth.**"),
252 Allow("OpenSSL|OpenSSL.**"),

Subscribers

People subscribed via source and target branches