|
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