Internal login JSON API (fixes #2425)
This commit is contained in:
Родитель
485e513401
Коммит
c64fba4c48
38
settings.py
38
settings.py
|
@ -89,24 +89,26 @@ AES_KEYS = {
|
|||
|
||||
# FxA config for local development only.
|
||||
FXA_CONFIG = {
|
||||
'client_id': 'cd5a21fafacc4744',
|
||||
'client_secret':
|
||||
'4db6f78940c6653d5b0d2adced8caf6c6fd8fd4f2a3a448da927a54daba7d401',
|
||||
'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_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',
|
||||
'default': {
|
||||
'client_id': 'cd5a21fafacc4744',
|
||||
'client_secret':
|
||||
'4db6f78940c6653d5b0d2adced8caf6c6fd8fd4f2a3a448da927a54daba7d401',
|
||||
'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_url': 'http://olympia.dev/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
},
|
||||
'internal': {
|
||||
'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.
|
||||
|
|
|
@ -11,7 +11,7 @@ from jingo import register
|
|||
def fxa_config(context):
|
||||
request = context['request']
|
||||
config = {camel_case(key): value
|
||||
for key, value in settings.FXA_CONFIG.iteritems()
|
||||
for key, value in settings.FXA_CONFIG['default'].iteritems()
|
||||
if key != 'client_secret'}
|
||||
if request.user.is_authenticated():
|
||||
config['email'] = request.user.email
|
||||
|
|
|
@ -4,13 +4,17 @@ import mock
|
|||
|
||||
from olympia.accounts import helpers
|
||||
|
||||
FXA_CONFIG = {
|
||||
'default': {
|
||||
'client_id': 'foo',
|
||||
'client_secret': 'bar',
|
||||
'something': 'hello, world!',
|
||||
'a_different_thing': 'howdy, world!',
|
||||
},
|
||||
}
|
||||
|
||||
@override_settings(FXA_CONFIG={
|
||||
'client_id': 'foo',
|
||||
'client_secret': 'bar',
|
||||
'something': 'hello, world!',
|
||||
'a_different_thing': 'howdy, world!',
|
||||
})
|
||||
|
||||
@override_settings(FXA_CONFIG=FXA_CONFIG)
|
||||
def test_fxa_config_anonymous():
|
||||
context = mock.MagicMock()
|
||||
context['request'].session = {'fxa_state': 'thestate!'}
|
||||
|
@ -23,12 +27,7 @@ def test_fxa_config_anonymous():
|
|||
}
|
||||
|
||||
|
||||
@override_settings(FXA_CONFIG={
|
||||
'client_id': 'foo',
|
||||
'client_secret': 'bar',
|
||||
'something': 'hello, world!',
|
||||
'a_different_thing': 'howdy, world!',
|
||||
})
|
||||
@override_settings(FXA_CONFIG=FXA_CONFIG)
|
||||
def test_fxa_config_logged_in():
|
||||
context = mock.MagicMock()
|
||||
context['request'].session = {'fxa_state': 'thestate!'}
|
||||
|
|
|
@ -252,6 +252,11 @@ class TestWithUser(TestCase):
|
|||
'next_path': None,
|
||||
}
|
||||
|
||||
@override_settings(FXA_CONFIG={'default': {}})
|
||||
def test_unknown_config_blows_up_early(self):
|
||||
with self.assertRaises(AssertionError):
|
||||
views.with_user(format='json', config='notconfigured')
|
||||
|
||||
def test_profile_exists_with_user_and_path(self):
|
||||
identity = {'uid': '1234', 'email': 'hey@yo.it'}
|
||||
self.fxa_identify.return_value = identity
|
||||
|
@ -490,7 +495,7 @@ class TestRegisterUser(TestCase):
|
|||
assert not user.has_usable_password()
|
||||
|
||||
|
||||
@override_settings(FXA_CONFIG=FXA_CONFIG)
|
||||
@override_settings(FXA_CONFIG={'default': FXA_CONFIG})
|
||||
class BaseAuthenticationView(APITestCase, InitializeSessionMixin):
|
||||
|
||||
def setUp(self):
|
||||
|
|
|
@ -96,7 +96,9 @@ def register_user(request, identity):
|
|||
return user
|
||||
|
||||
|
||||
def login_user(request, user, identity):
|
||||
def update_user(user, identity):
|
||||
"""Update a user's info from FxA. Returns whether the user migrated to FxA
|
||||
with this login."""
|
||||
if (user.fxa_id != identity['uid'] or
|
||||
user.email != identity['email']):
|
||||
log.info(
|
||||
|
@ -104,13 +106,20 @@ def login_user(request, user, identity):
|
|||
'New {new_email} {new_uid}'.format(
|
||||
pk=user.pk, old_email=user.email, old_uid=user.fxa_id,
|
||||
new_email=identity['email'], new_uid=identity['uid']))
|
||||
if not user.fxa_migrated():
|
||||
messages.success(
|
||||
request,
|
||||
_(u'Great job!'),
|
||||
_(u'You can now log in to Add-ons with your Firefox Account.'),
|
||||
extra_tags='fxa')
|
||||
migrated = not user.fxa_migrated()
|
||||
user.update(fxa_id=identity['uid'], email=identity['email'])
|
||||
return migrated
|
||||
return False
|
||||
|
||||
|
||||
def login_user(request, user, identity):
|
||||
migrated = update_user(user, identity)
|
||||
if migrated:
|
||||
messages.success(
|
||||
request,
|
||||
_(u'Great job!'),
|
||||
_(u'You can now log in to Add-ons with your Firefox Account.'),
|
||||
extra_tags='fxa')
|
||||
log.info('Logging in user {} from FxA'.format(user))
|
||||
login(request, user)
|
||||
|
||||
|
@ -159,7 +168,10 @@ def parse_next_path(state_parts):
|
|||
return next_path
|
||||
|
||||
|
||||
def with_user(format):
|
||||
def with_user(format, config='default'):
|
||||
assert config in settings.FXA_CONFIG, \
|
||||
'"{config}" not found in FXA_CONFIG'.format(config=config)
|
||||
|
||||
def outer(fn):
|
||||
@functools.wraps(fn)
|
||||
def inner(self, request):
|
||||
|
@ -171,7 +183,7 @@ def with_user(format):
|
|||
state_parts = data.get('state', '').split(':', 1)
|
||||
state = state_parts[0]
|
||||
next_path = parse_next_path(state_parts)
|
||||
if 'code' not in data:
|
||||
if not data.get('code'):
|
||||
log.info('No code provided.')
|
||||
return render_error(
|
||||
request, ERROR_NO_CODE, next_path=next_path, format=format)
|
||||
|
@ -188,7 +200,7 @@ def with_user(format):
|
|||
|
||||
try:
|
||||
identity = verify.fxa_identify(
|
||||
data['code'], config=settings.FXA_CONFIG)
|
||||
data['code'], config=settings.FXA_CONFIG[config])
|
||||
except verify.IdentificationError:
|
||||
log.info('Profile not found. Code: {}'.format(data['code']))
|
||||
return render_error(
|
||||
|
@ -222,20 +234,21 @@ def with_user(format):
|
|||
return outer
|
||||
|
||||
|
||||
def add_api_token_to_response(response, user):
|
||||
def add_api_token_to_response(response, user, set_cookie=True):
|
||||
# Generate API token and add it to the json response.
|
||||
payload = jwt_api_settings.JWT_PAYLOAD_HANDLER(user)
|
||||
token = jwt_api_settings.JWT_ENCODE_HANDLER(payload)
|
||||
if hasattr(response, 'data'):
|
||||
response.data['token'] = token
|
||||
# Also include the API token in a session cookie, so that it is available
|
||||
# for universal frontend apps.
|
||||
response.set_cookie(
|
||||
'jwt_api_auth_token',
|
||||
token,
|
||||
max_age=settings.SESSION_COOKIE_AGE or None,
|
||||
secure=settings.SESSION_COOKIE_SECURE or None,
|
||||
httponly=settings.SESSION_COOKIE_HTTPONLY or None)
|
||||
if set_cookie:
|
||||
# Also include the API token in a session cookie, so that it is
|
||||
# available for universal frontend apps.
|
||||
response.set_cookie(
|
||||
'jwt_api_auth_token',
|
||||
token,
|
||||
max_age=settings.SESSION_COOKIE_AGE or None,
|
||||
secure=settings.SESSION_COOKIE_SECURE or None,
|
||||
httponly=settings.SESSION_COOKIE_HTTPONLY or None)
|
||||
|
||||
return response
|
||||
|
||||
|
|
|
@ -234,14 +234,26 @@ if NEWRELIC_ENABLE:
|
|||
NEWRELIC_INI = '/etc/newrelic.d/%s.ini' % DOMAIN
|
||||
|
||||
FXA_CONFIG = {
|
||||
'client_id': env('FXA_CLIENT_ID'),
|
||||
'client_secret': env('FXA_CLIENT_SECRET'),
|
||||
'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_url':
|
||||
'https://addons-dev.allizom.org/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
'default': {
|
||||
'client_id': env('FXA_CLIENT_ID'),
|
||||
'client_secret': env('FXA_CLIENT_SECRET'),
|
||||
'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_url':
|
||||
'https://addons-dev.allizom.org/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
},
|
||||
'internal': {
|
||||
'client_id': env('INTERNAL_FXA_CLIENT_ID'),
|
||||
'client_secret': env('INTERNAL_FXA_CLIENT_SECRET'),
|
||||
'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_url':
|
||||
'https://addons-dev.allizom.org/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
},
|
||||
}
|
||||
|
||||
READ_ONLY = env.bool('READ_ONLY', default=False)
|
||||
|
|
|
@ -199,14 +199,26 @@ if NEWRELIC_ENABLE:
|
|||
NEWRELIC_INI = '/etc/newrelic.d/%s.ini' % DOMAIN
|
||||
|
||||
FXA_CONFIG = {
|
||||
'client_id': env('FXA_CLIENT_ID'),
|
||||
'client_secret': env('FXA_CLIENT_SECRET'),
|
||||
'content_host': 'https://accounts.firefox.com',
|
||||
'oauth_host': 'https://oauth.accounts.firefox.com/v1',
|
||||
'profile_host': 'https://profile.accounts.firefox.com/v1',
|
||||
'redirect_url':
|
||||
'https://addons.mozilla.org/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
'default': {
|
||||
'client_id': env('FXA_CLIENT_ID'),
|
||||
'client_secret': env('FXA_CLIENT_SECRET'),
|
||||
'content_host': 'https://accounts.firefox.com',
|
||||
'oauth_host': 'https://oauth.accounts.firefox.com/v1',
|
||||
'profile_host': 'https://profile.accounts.firefox.com/v1',
|
||||
'redirect_url':
|
||||
'https://addons.mozilla.org/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
},
|
||||
'internal': {
|
||||
'client_id': env('INTERNAL_FXA_CLIENT_ID'),
|
||||
'client_secret': env('INTERNAL_FXA_CLIENT_SECRET'),
|
||||
'content_host': 'https://accounts.firefox.com',
|
||||
'oauth_host': 'https://oauth.accounts.firefox.com/v1',
|
||||
'profile_host': 'https://profile.accounts.firefox.com/v1',
|
||||
'redirect_url':
|
||||
'https://addons.mozilla.org/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
},
|
||||
}
|
||||
|
||||
VALIDATOR_TIMEOUT = 360
|
||||
|
|
|
@ -228,14 +228,26 @@ if NEWRELIC_ENABLE:
|
|||
NEWRELIC_INI = '/etc/newrelic.d/%s.ini' % DOMAIN
|
||||
|
||||
FXA_CONFIG = {
|
||||
'client_id': env('FXA_CLIENT_ID'),
|
||||
'client_secret': env('FXA_CLIENT_SECRET'),
|
||||
'content_host': 'https://accounts.firefox.com',
|
||||
'oauth_host': 'https://oauth.accounts.firefox.com/v1',
|
||||
'profile_host': 'https://profile.accounts.firefox.com/v1',
|
||||
'redirect_url':
|
||||
'https://addons.allizom.org/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
'default': {
|
||||
'client_id': env('FXA_CLIENT_ID'),
|
||||
'client_secret': env('FXA_CLIENT_SECRET'),
|
||||
'content_host': 'https://accounts.firefox.com',
|
||||
'oauth_host': 'https://oauth.accounts.firefox.com/v1',
|
||||
'profile_host': 'https://profile.accounts.firefox.com/v1',
|
||||
'redirect_url':
|
||||
'https://addons.allizom.org/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
},
|
||||
'internal': {
|
||||
'client_id': env('INTERNAL_FXA_CLIENT_ID'),
|
||||
'client_secret': env('INTERNAL_FXA_CLIENT_SECRET'),
|
||||
'content_host': 'https://accounts.firefox.com',
|
||||
'oauth_host': 'https://oauth.accounts.firefox.com/v1',
|
||||
'profile_host': 'https://profile.accounts.firefox.com/v1',
|
||||
'redirect_url':
|
||||
'https://addons.allizom.org/api/v3/accounts/authorize/',
|
||||
'scope': 'profile',
|
||||
},
|
||||
}
|
||||
|
||||
READ_ONLY = env.bool('READ_ONLY', default=False)
|
||||
|
|
|
@ -7,7 +7,10 @@ from django.core.urlresolvers import reverse
|
|||
from django.test.utils import override_settings
|
||||
|
||||
import mock
|
||||
from rest_framework_jwt.serializers import VerifyJSONWebTokenSerializer
|
||||
|
||||
from olympia.accounts import verify, views
|
||||
from olympia.accounts.tests.test_views import BaseAuthenticationView
|
||||
from olympia.amo.tests import addon_factory, ESTestCase, TestCase
|
||||
from olympia.users.models import UserProfile
|
||||
|
||||
|
@ -165,7 +168,7 @@ class TestInternalAddonSearchView(ESTestCase):
|
|||
assert result['name'] == {'en-US': u'By second Addôn'}
|
||||
|
||||
|
||||
@override_settings(ADMIN_FXA_CONFIG=FXA_CONFIG)
|
||||
@override_settings(FXA_CONFIG={'internal': FXA_CONFIG})
|
||||
class TestLoginStartView(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
@ -231,3 +234,74 @@ class TestLoginStartView(TestCase):
|
|||
url = urlparse.urlparse(response['location'])
|
||||
query = urlparse.parse_qs(url.query)
|
||||
assert ':' not in query['state'][0]
|
||||
|
||||
|
||||
@override_settings(FXA_CONFIG={'internal': FXA_CONFIG})
|
||||
class TestLoginView(BaseAuthenticationView):
|
||||
view_name = 'internal-login'
|
||||
|
||||
def setUp(self):
|
||||
super(TestLoginView, self).setUp()
|
||||
self.state = 'stateaosidoiajsdaagdsasi'
|
||||
self.initialize_session({'fxa_state': self.state})
|
||||
self.code = 'codeaosidjoiajsdioasjdoa'
|
||||
self.update_user = self.patch(
|
||||
'olympia.internal_tools.views.update_user')
|
||||
|
||||
def post(self, **kwargs):
|
||||
kwargs.setdefault('state', self.state)
|
||||
kwargs.setdefault('code', self.code)
|
||||
return self.client.post(self.url, kwargs)
|
||||
|
||||
def test_no_code_provided(self):
|
||||
response = self.post(code='')
|
||||
assert response.status_code == 422
|
||||
assert response.data['error'] == views.ERROR_NO_CODE
|
||||
assert not self.update_user.called
|
||||
|
||||
def test_wrong_state(self):
|
||||
response = self.post(state='a-different-state')
|
||||
assert response.status_code == 400
|
||||
assert response.data['error'] == views.ERROR_STATE_MISMATCH
|
||||
assert not self.update_user.called
|
||||
|
||||
def test_no_fxa_profile(self):
|
||||
self.fxa_identify.side_effect = verify.IdentificationError
|
||||
response = self.post()
|
||||
assert response.status_code == 401
|
||||
assert response.data['error'] == views.ERROR_NO_PROFILE
|
||||
self.fxa_identify.assert_called_with(self.code, config=FXA_CONFIG)
|
||||
assert not self.update_user.called
|
||||
|
||||
def test_no_amo_account_cant_login(self):
|
||||
self.fxa_identify.return_value = {'email': 'me@yeahoo.com', 'uid': '5'}
|
||||
response = self.post()
|
||||
assert response.status_code == 422
|
||||
assert response.data['error'] == views.ERROR_NO_USER
|
||||
self.fxa_identify.assert_called_with(self.code, config=FXA_CONFIG)
|
||||
assert not self.update_user.called
|
||||
|
||||
def test_login_success(self):
|
||||
user = UserProfile.objects.create(
|
||||
username='foobar', email='real@yeahoo.com')
|
||||
identity = {'email': 'real@yeahoo.com', 'uid': '9001'}
|
||||
self.fxa_identify.return_value = identity
|
||||
response = self.post()
|
||||
assert response.status_code == 200
|
||||
assert response.data['email'] == 'real@yeahoo.com'
|
||||
assert 'jwt_api_auth_token' not in self.client.cookies
|
||||
verify = VerifyJSONWebTokenSerializer().validate(response.data)
|
||||
assert verify['user'] == user
|
||||
self.update_user.assert_called_with(user, identity)
|
||||
|
||||
def test_account_exists_migrated_multiple(self):
|
||||
"""Test that login fails if the user is logged in but the fxa_id is
|
||||
set on a different UserProfile."""
|
||||
UserProfile.objects.create(email='real@yeahoo.com', username='foo')
|
||||
UserProfile.objects.create(
|
||||
email='different@yeahoo.com', fxa_id='9005', username='bar')
|
||||
self.fxa_identify.return_value = {'email': 'real@yeahoo.com',
|
||||
'uid': '9005'}
|
||||
with self.assertRaises(UserProfile.MultipleObjectsReturned):
|
||||
self.post()
|
||||
assert not self.update_user.called
|
||||
|
|
|
@ -12,4 +12,7 @@ urlpatterns = patterns(
|
|||
url(r'^accounts/login/start/$',
|
||||
views.LoginStart.as_view(),
|
||||
name='internal-login-start'),
|
||||
url(r'^accounts/login/$',
|
||||
views.LoginView.as_view(),
|
||||
name='internal-login'),
|
||||
)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import logging
|
||||
from base64 import urlsafe_b64encode
|
||||
from urllib import urlencode
|
||||
|
||||
|
@ -5,14 +6,19 @@ 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 rest_framework.views import APIView, Response
|
||||
|
||||
|
||||
from olympia.accounts.helpers import generate_fxa_state
|
||||
from olympia.accounts.views import (
|
||||
add_api_token_to_response, update_user, with_user, ERROR_NO_USER)
|
||||
from olympia.addons.views import AddonSearchView
|
||||
from olympia.api.authentication import JSONWebTokenAuthentication
|
||||
from olympia.api.permissions import AnyOf, GroupPermission
|
||||
from olympia.search.filters import SearchQueryFilter, SortingFilter
|
||||
|
||||
log = logging.getLogger('internal_tools')
|
||||
|
||||
|
||||
class InternalAddonSearchView(AddonSearchView):
|
||||
# AddonSearchView disables auth classes so we need to add it back.
|
||||
|
@ -29,17 +35,32 @@ class InternalAddonSearchView(AddonSearchView):
|
|||
class LoginStart(APIView):
|
||||
|
||||
def get(self, request):
|
||||
config = settings.FXA_CONFIG['internal']
|
||||
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'],
|
||||
'client_id': config['client_id'],
|
||||
'redirect_uri': config['redirect_uri'],
|
||||
'scope': config['scope'],
|
||||
'state': state,
|
||||
}
|
||||
return HttpResponseRedirect('{host}/authorization?{query}'.format(
|
||||
host=settings.ADMIN_FXA_CONFIG['oauth_host'],
|
||||
host=config['oauth_host'],
|
||||
query=urlencode(query)))
|
||||
|
||||
|
||||
class LoginView(APIView):
|
||||
|
||||
@with_user(format='json', config='internal')
|
||||
def post(self, request, user, identity, next_path):
|
||||
if user is None:
|
||||
return Response({'error': ERROR_NO_USER}, status=422)
|
||||
else:
|
||||
update_user(user, identity)
|
||||
response = Response({'email': identity['email']})
|
||||
add_api_token_to_response(response, user, set_cookie=False)
|
||||
log.info('Logging in user {} from FxA'.format(user))
|
||||
return response
|
||||
|
|
Загрузка…
Ссылка в новой задаче