|
2 | 2 |
|
3 | 3 | from django import forms, http
|
4 | 4 | from django.contrib.auth.decorators import login_required
|
| 5 | +from django.db.models import Q |
5 | 6 | from django.shortcuts import render
|
6 | 7 | from django.urls import reverse
|
7 | 8 | from django.utils.decorators import method_decorator
|
8 | 9 | from django.views.decorators.csrf import csrf_exempt
|
9 | 10 | from django.views.generic import View
|
10 | 11 | from oauthlib.oauth2 import DeviceApplicationServer
|
11 |
| -from oauthlib.oauth2.rfc8628.errors import ( |
12 |
| - AccessDenied, |
13 |
| - ExpiredTokenError, |
14 |
| -) |
15 | 12 |
|
16 | 13 | from oauth2_provider.compat import login_not_required
|
17 | 14 | from oauth2_provider.models import Device, DeviceCodeResponse, DeviceRequest, create_device, get_device_model
|
@@ -41,52 +38,71 @@ class DeviceForm(forms.Form):
|
41 | 38 | user_code = forms.CharField(required=True)
|
42 | 39 |
|
43 | 40 |
|
44 |
| -# it's common to see in real world products |
45 |
| -# device flow's only asking the user to sign in after they input the |
46 |
| -# user code but since the user has to be signed in regardless to approve the |
47 |
| -# device login we're making the decision here to require being logged in |
48 |
| -# up front |
49 | 41 | @login_required
|
50 | 42 | def device_user_code_view(request):
|
| 43 | + """ |
| 44 | + The view where the user is instructed (by the device) to come to in order to |
| 45 | + enter the user code. More details in this section of the RFC: |
| 46 | + https://datatracker.ietf.org/doc/html/rfc8628#section-3.3 |
| 47 | +
|
| 48 | + Note: it's common to see in other implementations of this RFC that only ask the |
| 49 | + user to sign in after they input the user code but since the user has to be signed |
| 50 | + in regardless, to approve the device login we're making the decision here, for |
| 51 | + simplicity, to require being logged in up front. |
| 52 | + """ |
51 | 53 | form = DeviceForm(request.POST)
|
52 | 54 |
|
53 | 55 | if request.method != "POST":
|
54 | 56 | return render(request, "oauth2_provider/device/user_code.html", {"form": form})
|
55 | 57 |
|
56 | 58 | if not form.is_valid():
|
57 |
| - return render(request, "oauth2_provider/device/user_code.html", {"form": form}) |
| 59 | + form.add_error(None, "Form invalid") |
| 60 | + return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400) |
58 | 61 |
|
59 | 62 | user_code: str = form.cleaned_data["user_code"]
|
60 |
| - device: Device = get_device_model().objects.get(user_code=user_code) |
| 63 | + try: |
| 64 | + device: Device = get_device_model().objects.get(user_code=user_code) |
| 65 | + except Device.DoesNotExist: |
| 66 | + form.add_error("user_code", "Incorrect user code") |
| 67 | + return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=404) |
61 | 68 |
|
62 | 69 | device.user = request.user
|
63 | 70 | device.save(update_fields=["user"])
|
64 | 71 |
|
65 |
| - if device is None: |
66 |
| - form.add_error("user_code", "Incorrect user code") |
67 |
| - return render(request, "oauth2_provider/device/user_code.html", {"form": form}) |
68 |
| - |
69 | 72 | if device.is_expired():
|
70 |
| - device.status = device.EXPIRED |
71 |
| - device.save(update_fields=["status"]) |
72 |
| - raise ExpiredTokenError |
| 73 | + form.add_error("user_code", "Expired user code") |
| 74 | + return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400) |
73 | 75 |
|
74 | 76 | # User of device has already made their decision for this device
|
75 |
| - if device.status in (device.DENIED, device.AUTHORIZED): |
76 |
| - raise AccessDenied |
| 77 | + if device.status != device.AUTHORIZATION_PENDING: |
| 78 | + form.add_error("user_code", "User code has already been used") |
| 79 | + return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400) |
77 | 80 |
|
78 | 81 | # 308 to indicate we want to keep the redirect being a POST request
|
79 | 82 | return http.HttpResponsePermanentRedirect(
|
80 |
| - reverse("oauth2_provider:device-confirm", kwargs={"device_code": device.device_code}), status=308 |
| 83 | + reverse( |
| 84 | + "oauth2_provider:device-confirm", |
| 85 | + kwargs={"client_id": device.client_id, "user_code": user_code}, |
| 86 | + ), |
| 87 | + status=308, |
81 | 88 | )
|
82 | 89 |
|
83 | 90 |
|
84 | 91 | @login_required
|
85 |
| -def device_confirm_view(request: http.HttpRequest, device_code: str): |
86 |
| - device: Device = get_device_model().objects.get(device_code=device_code) |
87 |
| - |
88 |
| - if device.status in (device.AUTHORIZED, device.DENIED): |
89 |
| - return http.HttpResponse("Invalid") |
| 92 | +def device_confirm_view(request: http.HttpRequest, client_id: str, user_code: str): |
| 93 | + try: |
| 94 | + device: Device = get_device_model().objects.get( |
| 95 | + # there is a db index on client_id |
| 96 | + Q(client_id=client_id) & Q(user_code=user_code) |
| 97 | + ) |
| 98 | + except Device.DoesNotExist: |
| 99 | + return http.HttpResponseNotFound("<h1>Device not found</h1>") |
| 100 | + |
| 101 | + if device.status != device.AUTHORIZATION_PENDING: |
| 102 | + # AUTHORIZATION_PENDING is the only accepted state, anything else implies |
| 103 | + # that the user already approved/denied OR the deadline has passed (aka |
| 104 | + # expired) |
| 105 | + return http.HttpResponseBadRequest("Invalid") |
90 | 106 |
|
91 | 107 | action = request.POST.get("action")
|
92 | 108 |
|
|
0 commit comments