Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/sentry/auth/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from django.utils.translation import ugettext_lazy as _

from sentry.app import locks
from sentry.auth.provider import MigratingIdentityId
from sentry.auth.exceptions import IdentityNotValid
from sentry.models import (
AuditLogEntry, AuditLogEntryEvent, AuthIdentity, AuthProvider, Organization, OrganizationMember,
Expand Down Expand Up @@ -646,20 +647,36 @@ def _finish_login_pipeline(self, identity):
their account.
"""
auth_provider = self.auth_provider
user_id = identity['id']

lock = locks.get(
'sso:auth:{}:{}'.format(
auth_provider.id,
md5_text(identity['id']).hexdigest(),
md5_text(user_id).hexdigest(),
),
duration=5,
)
with TimedRetryPolicy(5)(lock.acquire):
try:
auth_identity = AuthIdentity.objects.select_related('user').get(
auth_provider=auth_provider,
ident=identity['id'],
ident=user_id,
)
except AuthIdentity.DoesNotExist:
auth_identity = None

# Handle migration of identity keys
if not auth_identity and isinstance(user_id, MigratingIdentityId):
try:
auth_identity = AuthIdentity.objects.select_related('user').get(
auth_provider=auth_provider,
ident=user_id.legacy_id,
)
auth_identity.update(ident=user_id.id)
except AuthIdentity.DoesNotExist:
auth_identity = None

if not auth_identity:
return self._handle_unknown_identity(identity)

# If the User attached to this AuthIdentity is not active,
Expand Down
18 changes: 18 additions & 0 deletions src/sentry/auth/provider.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
from __future__ import absolute_import, print_function

import logging
from collections import namedtuple

from .view import ConfigureView


class MigratingIdentityId(namedtuple('MigratingIdentityId', ['id', 'legacy_id'])):
"""
MigratingIdentityId may be used in the ``id`` field of an identity
dictionary to facilitate migrating user identites from one identifying id
to another.
"""
__slots__ = ()

def __unicode__(self):
# Default to id when coercing for query lookup
return self.id


class Provider(object):
"""
A provider indicates how authenticate should happen for a given service,
Expand Down Expand Up @@ -62,6 +76,10 @@ def build_identity(self, state):

The ``email`` and ``id`` keys are required, ``name`` is optional.

The ``id`` may be passed in as a ``MigratingIdentityId`` should the
the id key be migrating from one value to another and have multiple
lookup values.

If the identity can not be constructed an ``IdentityNotValid`` error
should be raised.
"""
Expand Down
8 changes: 5 additions & 3 deletions src/sentry/auth/providers/dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
from django.http import HttpResponse

from sentry.auth import Provider, AuthView
from sentry.auth.provider import MigratingIdentityId


class AskEmail(AuthView):
def dispatch(self, request, helper):
if 'email' in request.POST:
helper.bind_state('email', request.POST['email'])
helper.bind_state('email', request.POST.get('email'))
helper.bind_state('legacy_email', request.POST.get('legacy_email'))
return helper.next_step()

return HttpResponse(DummyProvider.TEMPLATE)
Expand All @@ -23,9 +25,9 @@ def get_auth_pipeline(self):

def build_identity(self, state):
return {
'name': 'Dummy',
'id': state['email'],
'id': MigratingIdentityId(id=state['email'], legacy_id=state.get('legacy_email')),
'email': state['email'],
'name': 'Dummy',
}

def refresh_identity(self, auth_identity):
Expand Down
34 changes: 34 additions & 0 deletions tests/sentry/web/frontend/test_auth_organization_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,3 +652,37 @@ def test_swapped_identities(self):
member2 = OrganizationMember.objects.get(id=member2.id)
assert not getattr(member2.flags, 'sso:linked')
assert getattr(member2.flags, 'sso:invalid')

def test_flow_as_unauthenticated_existing_user_legacy_identity_migration(self):
organization = self.create_organization(name='foo', owner=self.user)
user = self.create_user('bar@example.com')
auth_provider = AuthProvider.objects.create(
organization=organization,
provider='dummy',
)
user_ident = AuthIdentity.objects.create(
auth_provider=auth_provider,
user=user,
ident='foo@example.com',
)

path = reverse('sentry-auth-organization', args=[organization.slug])

resp = self.client.post(path, {'init': True})

assert resp.status_code == 200
assert self.provider.TEMPLATE in resp.content.decode('utf-8')

path = reverse('sentry-auth-sso')

resp = self.client.post(path, {
'email': 'foo@new-domain.com',
'legacy_email': 'foo@example.com'
})

# Ensure the ident was migrated from the legacy identity
updated_ident = AuthIdentity.objects.get(id=user_ident.id)
assert updated_ident.ident == 'foo@new-domain.com'

assert resp.status_code == 302
assert resp['Location'] == 'http://testserver' + reverse('sentry-login')