diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 6ae1384fb..90a5ade2a 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -221,6 +221,13 @@ def revoke(self): """ self.delete() + @property + def scopes(self): + """ + Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) + """ + return {name: desc for name, desc in oauth2_settings.SCOPES.items() if name in self.scope.split()} + def __str__(self): return self.token diff --git a/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html b/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html new file mode 100644 index 000000000..e08233a70 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html @@ -0,0 +1,9 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% block content %} +
{% csrf_token %} +

{% trans "Are you sure you want to delete this token?" %}

+ +
+{% endblock %} diff --git a/oauth2_provider/templates/oauth2_provider/authorized-tokens.html b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html new file mode 100644 index 000000000..f25069e61 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html @@ -0,0 +1,24 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% load url from compat %} +{% block content %} +
+

{% trans "Tokens" %}

+ +
+{% endblock %} diff --git a/oauth2_provider/tests/test_models.py b/oauth2_provider/tests/test_models.py index d082ab8dc..d12ed30ef 100644 --- a/oauth2_provider/tests/test_models.py +++ b/oauth2_provider/tests/test_models.py @@ -81,6 +81,36 @@ def test_str(self): app.name = "test_app" self.assertEqual("%s" % app, "test_app") + def test_scopes_property(self): + self.client.login(username="test_user", password="123456") + + app = Application.objects.create( + name="test_app", + redirect_uris="http://localhost http://example.com http://example.it", + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + + access_token = AccessToken( + user=self.user, + scope='read write', + expires=0, + token='', + application=app + ) + + access_token2 = AccessToken( + user=self.user, + scope='write', + expires=0, + token='', + application=app + ) + + self.assertEqual(access_token.scopes, {'read': 'Reading scope', 'write': 'Writing scope'}) + self.assertEqual(access_token2.scopes, {'write': 'Writing scope'}) + @skipIf(django.VERSION < (1, 5), "Behavior is broken on 1.4 and there is no solution") @override_settings(OAUTH2_PROVIDER_APPLICATION_MODEL='tests.TestApplication') diff --git a/oauth2_provider/tests/test_token_view.py b/oauth2_provider/tests/test_token_view.py new file mode 100644 index 000000000..7e02a32b2 --- /dev/null +++ b/oauth2_provider/tests/test_token_view.py @@ -0,0 +1,178 @@ +from __future__ import unicode_literals + +import datetime + +from django.core.urlresolvers import reverse +from django.test import TestCase +from django.utils import timezone + +from ..models import get_application_model, AccessToken +from ..compat import get_user_model + +Application = get_application_model() +UserModel = get_user_model() + + +class TestAuthorizedTokenViews(TestCase): + """ + TestCase superclass for Authorized Token Views' Test Cases + """ + def setUp(self): + self.foo_user = UserModel.objects.create_user("foo_user", "test@user.com", "123456") + self.bar_user = UserModel.objects.create_user("bar_user", "dev@user.com", "123456") + + self.application = Application( + name="Test Application", + redirect_uris="http://localhost http://example.com http://example.it", + user=self.bar_user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + self.application.save() + + def tearDown(self): + self.foo_user.delete() + self.bar_user.delete() + + +class TestAuthorizedTokenListView(TestAuthorizedTokenViews): + """ + Tests for the Authorized Token ListView + """ + def test_list_view_authorization_required(self): + """ + Test that the view redirects to login page if user is not logged-in. + """ + response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + self.assertEqual(response.status_code, 302) + self.assertTrue('/accounts/login/?next=' in response['Location']) + + def test_empty_list_view(self): + """ + Test that when you have no tokens, an appropriate message is shown + """ + self.client.login(username="foo_user", password="123456") + + response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + self.assertEqual(response.status_code, 200) + self.assertIn(b'There are no authorized tokens yet.', response.content) + + def test_list_view_one_token(self): + """ + Test that the view shows your token + """ + self.client.login(username="bar_user", password="123456") + AccessToken.objects.create(user=self.bar_user, token='1234567890', + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope='read write') + + response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + self.assertEqual(response.status_code, 200) + self.assertIn(b'read', response.content) + self.assertIn(b'write', response.content) + self.assertNotIn(b'There are no authorized tokens yet.', response.content) + + def test_list_view_two_tokens(self): + """ + Test that the view shows your tokens + """ + self.client.login(username="bar_user", password="123456") + AccessToken.objects.create(user=self.bar_user, token='1234567890', + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope='read write') + AccessToken.objects.create(user=self.bar_user, token='0123456789', + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope='read write') + + response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + self.assertEqual(response.status_code, 200) + self.assertNotIn(b'There are no authorized tokens yet.', response.content) + + def test_list_view_shows_correct_user_token(self): + """ + Test that only currently logged-in user's tokens are shown + """ + self.client.login(username="bar_user", password="123456") + AccessToken.objects.create(user=self.foo_user, token='1234567890', + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope='read write') + + response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + self.assertEqual(response.status_code, 200) + self.assertIn(b'There are no authorized tokens yet.', response.content) + + +class TestAuthorizedTokenDeleteView(TestAuthorizedTokenViews): + """ + Tests for the Authorized Token DeleteView + """ + def test_delete_view_authorization_required(self): + """ + Test that the view redirects to login page if user is not logged-in. + """ + self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope='read write') + + response = self.client.get(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + self.assertEqual(response.status_code, 302) + self.assertTrue('/accounts/login/?next=' in response['Location']) + + def test_delete_view_works(self): + """ + Test that a GET on this view returns 200 if the token belongs to the logged-in user. + """ + self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope='read write') + + self.client.login(username="foo_user", password="123456") + response = self.client.get(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + self.assertEqual(response.status_code, 200) + + def test_delete_view_token_belongs_to_user(self): + """ + Test that a 404 is returned when trying to GET this view with someone else's tokens. + """ + self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope='read write') + + self.client.login(username="bar_user", password="123456") + response = self.client.get(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + self.assertEqual(response.status_code, 404) + + def test_delete_view_post_actually_deletes(self): + """ + Test that a POST on this view works if the token belongs to the logged-in user. + """ + self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope='read write') + + self.client.login(username="foo_user", password="123456") + response = self.client.post(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + self.assertFalse(AccessToken.objects.exists()) + self.assertRedirects(response, reverse('oauth2_provider:authorized-token-list')) + + def test_delete_view_only_deletes_user_own_token(self): + """ + Test that a 404 is returned when trying to POST on this view with someone else's tokens. + """ + self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope='read write') + + self.client.login(username="bar_user", password="123456") + response = self.client.post(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + self.assertTrue(AccessToken.objects.exists()) + self.assertEqual(response.status_code, 404) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index e098e4148..ebcb9e0b6 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -17,3 +17,9 @@ url(r'^applications/(?P\d+)/delete/$', views.ApplicationDelete.as_view(), name="delete"), url(r'^applications/(?P\d+)/update/$', views.ApplicationUpdate.as_view(), name="update"), ) + +urlpatterns += ( + url(r'^authorized_tokens/$', views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), + url(r'^authorized_tokens/(?P\d+)/delete/$', views.AuthorizedTokenDeleteView.as_view(), + name="authorized-token-delete"), +) diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index e285d518b..257c86add 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -2,3 +2,4 @@ from .application import ApplicationRegistration, ApplicationDetail, ApplicationList, \ ApplicationDelete, ApplicationUpdate from .generic import ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView +from .token import AuthorizedTokensListView, AuthorizedTokenDeleteView diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py new file mode 100644 index 000000000..f7e4562e9 --- /dev/null +++ b/oauth2_provider/views/token.py @@ -0,0 +1,36 @@ +from __future__ import absolute_import, unicode_literals + +from django.core.urlresolvers import reverse_lazy +from django.views.generic import ListView, DeleteView + +from braces.views import LoginRequiredMixin + +from ..models import AccessToken + + +class AuthorizedTokensListView(LoginRequiredMixin, ListView): + """ + Show a page where the current logged-in user can see his tokens so they can revoke them + """ + context_object_name = 'authorized_tokens' + template_name = 'oauth2_provider/authorized-tokens.html' + model = AccessToken + + def get_queryset(self): + """ + Show only user's tokens + """ + return super(AuthorizedTokensListView, self).get_queryset()\ + .select_related('application').filter(user=self.request.user) + + +class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView): + """ + View for revoking a specific token + """ + template_name = 'oauth2_provider/authorized-token-delete.html' + success_url = reverse_lazy('oauth2_provider:authorized-token-list') + model = AccessToken + + def get_queryset(self): + return super(AuthorizedTokenDeleteView, self).get_queryset().filter(user=self.request.user)