This commit is contained in:
Mark Striemer 2016-04-21 15:31:31 -05:00
Родитель 485e513401
Коммит c64fba4c48
11 изменённых файлов: 234 добавлений и 81 удалений

Просмотреть файл

@ -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