diff --git a/docs/topics/api/auth_internal.rst b/docs/topics/api/auth_internal.rst index 70c4e1ea7b..6af0162812 100644 --- a/docs/topics/api/auth_internal.rst +++ b/docs/topics/api/auth_internal.rst @@ -26,6 +26,13 @@ responses of the following endpoints: * ``/api/v3/accounts/register/`` * ``/api/v3/accounts/authenticate/`` +A JWT may also be obtained through the JSON API as outlined in the +:ref:`internal login JSON API ` section. This is only +accessible through the VPN and requires using the following endpoints: + + * ``/api/v3/internal/accounts/login/start/`` + * ``/api/v3/internal/accounts/login/`` + The token is available in two forms: * For the endpoints returning JSON, as a property called ``token``. diff --git a/docs/topics/api/internal.rst b/docs/topics/api/internal.rst index 091e178008..7175170328 100644 --- a/docs/topics/api/internal.rst +++ b/docs/topics/api/internal.rst @@ -30,3 +30,21 @@ add-ons, and can return disabled, unreviewer, unlisted or even deleted add-ons. :>json string next: The URL of the next page of results. :>json string previous: The URL of the previous page of results. :>json array results: An array of :ref:`add-ons `. + +----------------------- +Internal Login JSON API +----------------------- + +.. _internal-login-json-api: + +The JSON API login flow is initiated by accessing the start endpoint which +will add an ``fxa_state`` to the user's session and redirect them to Firefox +Accounts. When the user finishes authenticating with Firefox Accounts they +will be redirected to the client application which can make a request to the +login endpoint to exchange the Firefox Accounts token and state for a JWT. + +.. http:get:: /api/v3/internal/accounts/login/start/ + + :param string to: A path to append to the state. The state will be returned + from FxA as ``state:path``, the path will be URL safe base64 encoded. + :status 302: Redirect user to Firefox Accounts. diff --git a/settings.py b/settings.py index 0a4aca4d12..7d2d459660 100644 --- a/settings.py +++ b/settings.py @@ -98,6 +98,16 @@ FXA_CONFIG = { 'redirect_url': 'http://olympia.dev/api/v3/accounts/authorize/', 'scope': 'profile', } +ADMIN_FXA_CONFIG = { + 'client_id': '0f95f6474c24c1dc', + 'client_secret': + 'ca45e503a1b4ec9e2a3d4855d79849e098da18b7dfe42b6bc76dfed420fc1d38', + 'content_host': 'https://stable.dev.lcip.org', + 'oauth_host': 'https://oauth-stable.dev.lcip.org/v1', + 'profile_host': 'https://stable.dev.lcip.org/profile/v1', + 'redirect_uri': 'http://localhost:3000/fxa-authenticate', + 'scope': 'profile', +} # CSP report endpoint which returns a 204 from addons-nginx in local dev. CSP_REPORT_URI = '/csp-report' diff --git a/src/olympia/internal_tools/tests/test_views.py b/src/olympia/internal_tools/tests/test_views.py index 8a8b6bc485..b9f4a3079b 100644 --- a/src/olympia/internal_tools/tests/test_views.py +++ b/src/olympia/internal_tools/tests/test_views.py @@ -1,11 +1,23 @@ # -*- coding: utf-8 -*- +import base64 import json +import urlparse from django.core.urlresolvers import reverse +from django.test.utils import override_settings -from olympia.amo.tests import addon_factory, ESTestCase +import mock + +from olympia.amo.tests import addon_factory, ESTestCase, TestCase from olympia.users.models import UserProfile +FXA_CONFIG = { + 'oauth_host': 'https://accounts.firefox.com/v1', + 'client_id': '999abc111', + 'redirect_uri': 'https://addons-frontend/fxa-authenticate', + 'scope': 'profile', +} + class TestInternalAddonSearchView(ESTestCase): fixtures = ['base/users'] @@ -151,3 +163,73 @@ class TestInternalAddonSearchView(ESTestCase): result = data['results'][0] assert result['id'] == addon2.pk assert result['name'] == {'en-US': u'By second Addôn'} + + +@override_settings(ADMIN_FXA_CONFIG=FXA_CONFIG) +class TestLoginStartView(TestCase): + + def setUp(self): + super(TestLoginStartView, self).setUp() + self.url = reverse('internal-login-start') + + def test_state_is_set(self): + self.initialize_session({}) + assert 'fxa_state' not in self.client.session + state = 'somerandomstate' + with mock.patch('olympia.internal_tools.views.generate_fxa_state', + lambda: state): + response = self.client.get(self.url) + assert 'fxa_state' in self.client.session + assert self.client.session['fxa_state'] == state + + def test_redirect_uri_is_correct(self): + self.initialize_session({}) + with mock.patch('olympia.internal_tools.views.generate_fxa_state', + lambda: 'arandomstring'): + response = self.client.get(self.url) + assert response.status_code == 302 + url = urlparse.urlparse(response['location']) + assert '{scheme}://{netloc}{path}'.format( + scheme=url.scheme, + netloc=url.netloc, + path=url.path, + ) == 'https://accounts.firefox.com/v1/authorization' + assert urlparse.parse_qs(url.query) == { + 'client_id': ['999abc111'], + 'redirect_uri': ['https://addons-frontend/fxa-authenticate'], + 'scope': ['profile'], + 'state': ['arandomstring'], + } + + def test_state_is_not_overriden(self): + self.initialize_session({'fxa_state': 'thisisthestate'}) + response = self.client.get(self.url) + assert self.client.session['fxa_state'] == 'thisisthestate' + + def test_to_is_included_in_redirect_state(self): + path = '/addons/unlisted-addon/' + # The =s will be stripped from the URL. + assert '=' in base64.urlsafe_b64encode(path) + state = 'somenewstatestring' + self.initialize_session({}) + with mock.patch('olympia.internal_tools.views.generate_fxa_state', + lambda: state): + response = self.client.get( + '{url}?to={path}'.format(path=path, url=self.url)) + assert self.client.session['fxa_state'] == state + url = urlparse.urlparse(response['location']) + query = urlparse.parse_qs(url.query) + state_parts = query['state'][0].split(':') + assert len(state_parts) == 2 + assert state_parts[0] == state + assert '=' not in state_parts[1] + assert base64.urlsafe_b64decode(state_parts[1] + '====') == path + + def test_to_is_excluded_when_unsafe(self): + path = 'https://www.google.com' + self.initialize_session({}) + response = self.client.get( + '{url}?to={path}'.format(path=path, url=self.url)) + url = urlparse.urlparse(response['location']) + query = urlparse.parse_qs(url.query) + assert ':' not in query['state'][0] diff --git a/src/olympia/internal_tools/urls.py b/src/olympia/internal_tools/urls.py index 8994754354..21ef8cf3dc 100644 --- a/src/olympia/internal_tools/urls.py +++ b/src/olympia/internal_tools/urls.py @@ -9,4 +9,7 @@ urlpatterns = patterns( url(r'^addons/search/$', views.InternalAddonSearchView.as_view(), name='internal-addon-search'), + url(r'^accounts/login/start/$', + views.LoginStart.as_view(), + name='internal-login-start'), ) diff --git a/src/olympia/internal_tools/views.py b/src/olympia/internal_tools/views.py index fb808b4d4a..dc1a127388 100644 --- a/src/olympia/internal_tools/views.py +++ b/src/olympia/internal_tools/views.py @@ -1,3 +1,13 @@ +from base64 import urlsafe_b64encode +from urllib import urlencode + +from django.conf import settings +from django.http import HttpResponseRedirect +from django.utils.http import is_safe_url + +from rest_framework.views import APIView + +from olympia.accounts.helpers import generate_fxa_state from olympia.addons.views import AddonSearchView from olympia.api.authentication import JSONWebTokenAuthentication from olympia.api.permissions import AnyOf, GroupPermission @@ -14,3 +24,22 @@ class InternalAddonSearchView(AddonSearchView): # Restricted to specific permissions. permission_classes = [AnyOf(GroupPermission('AdminTools', 'View'), GroupPermission('ReviewerAdminTools', 'View'))] + + +class LoginStart(APIView): + + def get(self, request): + request.session.setdefault('fxa_state', generate_fxa_state()) + state = request.session['fxa_state'] + next_path = request.GET.get('to') + if next_path and is_safe_url(next_path): + state += ':' + urlsafe_b64encode(next_path).rstrip('=') + query = { + 'client_id': settings.ADMIN_FXA_CONFIG['client_id'], + 'redirect_uri': settings.ADMIN_FXA_CONFIG['redirect_uri'], + 'scope': settings.ADMIN_FXA_CONFIG['scope'], + 'state': state, + } + return HttpResponseRedirect('{host}/authorization?{query}'.format( + host=settings.ADMIN_FXA_CONFIG['oauth_host'], + query=urlencode(query)))