11import json
22
33from 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
77from django .urls import reverse
88from django .utils .decorators import method_decorator
99from django .views .decorators .csrf import csrf_exempt
10- from django .views .generic import View
10+ from django .views .generic import DetailView , FormView , View
1111from oauthlib .oauth2 import DeviceApplicationServer
1212
1313from 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