Skip to content

Commit 824c834

Browse files
committed
Use class based views to make them more easily extensible
This commit updates the views related to device auth flow from functions to class-based views. This makes it easy to import them in other projects and only overwrite small bits of functionality (specifically looking at templates and context_data). A 3rd view is added for the final step where the user is presented with the status of the device after they approve or deny. The views now also have a more "standard" django form behaviour, changes being relfected in the tests.
1 parent 0c0a337 commit 824c834

File tree

5 files changed

+277
-121
lines changed

5 files changed

+277
-121
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{% extends "oauth2_provider/base.html" %}
2+
{% block content %}
3+
<html>
4+
<head>
5+
<title>Device</title>
6+
</head>
7+
<body>
8+
<h1> Device {{ object.get_status_display }} </h1>
9+
</body>
10+
</html>
11+
{% endblock content %}

oauth2_provider/urls.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,17 @@
1212
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
1313
path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"),
1414
path("device-authorization/", views.DeviceAuthorizationView.as_view(), name="device-authorization"),
15-
path("device/", views.device_user_code_view, name="device"),
16-
path("device-confirm/<str:client_id>/<str:user_code>", views.device_confirm_view, name="device-confirm"),
15+
path("device/", views.DeviceUserCodeView.as_view(), name="device"),
16+
path(
17+
"device-confirm/<slug:client_id>/<slug:user_code>",
18+
views.DeviceConfirmView.as_view(),
19+
name="device-confirm",
20+
),
21+
path(
22+
"device-grant-status/<slug:client_id>/<slug:user_code>",
23+
views.DeviceGrantStatusView.as_view(),
24+
name="device-grant-status",
25+
),
1726
]
1827

1928

oauth2_provider/views/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@
1717
from .introspect import IntrospectTokenView
1818
from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView
1919
from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView
20-
from .device import DeviceAuthorizationView, device_user_code_view, device_confirm_view
20+
from .device import DeviceAuthorizationView, DeviceUserCodeView, DeviceConfirmView, DeviceGrantStatusView

oauth2_provider/views/device.py

Lines changed: 138 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import json
22

33
from django import forms, http
4-
from django.contrib.auth.decorators import login_required
5-
from django.db.models import Q
6-
from django.shortcuts import render
4+
from django.contrib.auth.mixins import LoginRequiredMixin
5+
from django.core.exceptions import ValidationError
6+
from django.shortcuts import get_object_or_404
77
from django.urls import reverse
88
from django.utils.decorators import method_decorator
99
from django.views.decorators.csrf import csrf_exempt
10-
from django.views.generic import View
10+
from django.views.generic import DetailView, FormView, View
1111
from oauthlib.oauth2 import DeviceApplicationServer
1212

1313
from oauth2_provider.compat import login_not_required
@@ -40,12 +40,43 @@ def post(self, request, *args, **kwargs):
4040
return http.JsonResponse(data=response, status=status, headers=headers)
4141

4242

43-
class DeviceForm(forms.Form):
43+
class DeviceGrantForm(forms.Form):
4444
user_code = forms.CharField(required=True)
4545

46+
def clean_user_code(self):
47+
"""
48+
Performs validation on the user_code provided by the user and adds to the cleaned_data dict
49+
the "device_grant" object associated with the user_code, which is useful to process the
50+
response in the DeviceUserCodeView.
4651
47-
@login_required
48-
def device_user_code_view(request):
52+
It can raise one of the following ValidationErrors, with the associated codes:
53+
54+
* incorrect_user_code: if a device grant associated with the user_code does not exist
55+
* expired_user_code: if the device grant associated with the user_code has expired
56+
* user_code_already_used: if the device grant associated with the user_code has been already
57+
approved or denied. The only accepted state of the device grant is AUTHORIZATION_PENDING.
58+
"""
59+
cleaned_data = super().clean()
60+
user_code: str = cleaned_data["user_code"]
61+
try:
62+
device_grant: DeviceGrant = get_device_grant_model().objects.get(user_code=user_code)
63+
except DeviceGrant.DoesNotExist:
64+
raise ValidationError("Incorrect user code", code="incorrect_user_code")
65+
66+
if device_grant.is_expired():
67+
raise ValidationError("Expired user code", code="expired_user_code")
68+
69+
# User of device has already made their decision for this device.
70+
if device_grant.status != device_grant.AUTHORIZATION_PENDING:
71+
raise ValidationError("User code has already been used", code="user_code_already_used")
72+
73+
# Make the device_grant available to the View, saving one additional db call.
74+
cleaned_data["device_grant"] = device_grant
75+
76+
return user_code
77+
78+
79+
class DeviceUserCodeView(LoginRequiredMixin, FormView):
4980
"""
5081
The view where the user is instructed (by the device) to come to in order to
5182
enter the user code. More details in this section of the RFC:
@@ -56,69 +87,111 @@ def device_user_code_view(request):
5687
in regardless, to approve the device login we're making the decision here, for
5788
simplicity, to require being logged in up front.
5889
"""
59-
form = DeviceForm(request.POST)
6090

61-
if request.method != "POST":
62-
return render(request, "oauth2_provider/device/user_code.html", {"form": form})
91+
template_name = "oauth2_provider/device/user_code.html"
92+
form_class = DeviceGrantForm
6393

64-
if not form.is_valid():
65-
form.add_error(None, "Form invalid")
66-
return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400)
94+
def get_success_url(self):
95+
return reverse(
96+
"oauth2_provider:device-confirm",
97+
kwargs={
98+
"client_id": self.device_grant.client_id,
99+
"user_code": self.device_grant.user_code,
100+
},
101+
)
67102

68-
user_code: str = form.cleaned_data["user_code"]
69-
try:
70-
device: DeviceGrant = get_device_grant_model().objects.get(user_code=user_code)
71-
except DeviceGrant.DoesNotExist:
72-
form.add_error("user_code", "Incorrect user code")
73-
return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=404)
103+
def form_valid(self, form):
104+
"""
105+
Sets the device_grant on the instance so that it can be accessed
106+
in get_success_url. It comes in handy when users want to overwrite
107+
get_success_url, redirecting to the URL with the URL params pointing
108+
to the current device.
109+
"""
110+
device_grant: DeviceGrant = form.cleaned_data["device_grant"]
74111

75-
device.user = request.user
76-
device.save(update_fields=["user"])
112+
device_grant.user = self.request.user
113+
device_grant.save(update_fields=["user"])
77114

78-
if device.is_expired():
79-
form.add_error("user_code", "Expired user code")
80-
return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400)
115+
self.device_grant = device_grant
81116

82-
# User of device has already made their decision for this device
83-
if device.status != device.AUTHORIZATION_PENDING:
84-
form.add_error("user_code", "User code has already been used")
85-
return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400)
117+
return super().form_valid(form)
86118

87-
# 308 to indicate we want to keep the redirect being a POST request
88-
return http.HttpResponsePermanentRedirect(
89-
reverse(
90-
"oauth2_provider:device-confirm",
91-
kwargs={"client_id": device.client_id, "user_code": user_code},
92-
),
93-
status=308,
94-
)
95-
96-
97-
@login_required
98-
def device_confirm_view(request: http.HttpRequest, client_id: str, user_code: str):
99-
try:
100-
device: DeviceGrant = get_device_grant_model().objects.get(
101-
# there is a db index on client_id
102-
Q(client_id=client_id) & Q(user_code=user_code)
119+
120+
class DeviceConfirmForm(forms.Form):
121+
"""
122+
Simple form for the user to approve or deny the device.
123+
"""
124+
125+
action = forms.CharField(required=True)
126+
127+
128+
class DeviceConfirmView(LoginRequiredMixin, FormView):
129+
"""
130+
The view where the user approves or denies a device.
131+
"""
132+
133+
template_name = "oauth2_provider/device/accept_deny.html"
134+
form_class = DeviceConfirmForm
135+
136+
def get_object(self):
137+
"""
138+
Returns the DeviceGrant object in the AUTHORIZATION_PENDING state identified
139+
by the slugs client_id and user_code. Raises Http404 if not found.
140+
"""
141+
client_id, user_code = self.kwargs.get("client_id"), self.kwargs.get("user_code")
142+
return get_object_or_404(
143+
DeviceGrant,
144+
client_id=client_id,
145+
user_code=user_code,
146+
status=DeviceGrant.AUTHORIZATION_PENDING,
103147
)
104-
except DeviceGrant.DoesNotExist:
105-
return http.HttpResponseNotFound("<h1>Device not found</h1>")
106-
107-
if device.status != device.AUTHORIZATION_PENDING:
108-
# AUTHORIZATION_PENDING is the only accepted state, anything else implies
109-
# that the user already approved/denied OR the deadline has passed (aka
110-
# expired)
111-
return http.HttpResponseBadRequest("Invalid")
112-
113-
action = request.POST.get("action")
114-
115-
if action == "accept":
116-
device.status = device.AUTHORIZED
117-
device.save(update_fields=["status"])
118-
return http.HttpResponse("approved")
119-
elif action == "deny":
120-
device.status = device.DENIED
121-
device.save(update_fields=["status"])
122-
return http.HttpResponse("deny")
123-
124-
return render(request, "oauth2_provider/device/accept_deny.html")
148+
149+
def get_success_url(self):
150+
return reverse(
151+
"oauth2_provider:device-grant-status",
152+
kwargs={
153+
"client_id": self.kwargs["client_id"],
154+
"user_code": self.kwargs["user_code"],
155+
},
156+
)
157+
158+
def get(self, request, *args, **kwargs):
159+
"""
160+
Enable GET requests for improved user experience. But validate that the URL params
161+
are correct (i.e. there exists a device grant in the db that corresponds to the URL
162+
params) by calling .get_object()
163+
"""
164+
_ = self.get_object() # raises 404 if URL parameters are incorrect
165+
return super().get(request, args, kwargs)
166+
167+
def form_valid(self, form):
168+
"""
169+
Uses get_object() to retrieves the DeviceGrant object and updates its state
170+
to authorized or denied, based on the user input.
171+
"""
172+
device = self.get_object()
173+
action = form.cleaned_data["action"]
174+
175+
if action == "accept":
176+
device.status = device.AUTHORIZED
177+
device.save(update_fields=["status"])
178+
return super().form_valid(form)
179+
elif action == "deny":
180+
device.status = device.DENIED
181+
device.save(update_fields=["status"])
182+
return super().form_valid(form)
183+
else:
184+
return http.HttpResponseBadRequest()
185+
186+
187+
class DeviceGrantStatusView(LoginRequiredMixin, DetailView):
188+
"""
189+
The view to display the status of a DeviceGrant.
190+
"""
191+
192+
model = DeviceGrant
193+
template_name = "oauth2_provider/device/device_grant_status.html"
194+
195+
def get_object(self):
196+
client_id, user_code = self.kwargs.get("client_id"), self.kwargs.get("user_code")
197+
return get_object_or_404(DeviceGrant, client_id=client_id, user_code=user_code)

0 commit comments

Comments
 (0)