Skip to content

Commit caca3e1

Browse files
SAML2 third_party_auth provider(s) - PR 8018
1 parent 2942846 commit caca3e1

21 files changed

Lines changed: 279 additions & 70 deletions

File tree

common/djangoapps/student/views.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ def register_user(request, extra_context=None):
424424
# selected provider.
425425
if third_party_auth.is_enabled() and pipeline.running(request):
426426
running_pipeline = pipeline.get(request)
427-
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
427+
current_provider = provider.Registry.get_from_pipeline(running_pipeline)
428428
overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
429429
overrides['running_pipeline'] = running_pipeline
430430
overrides['selected_provider'] = current_provider.NAME
@@ -952,10 +952,11 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
952952
running_pipeline = pipeline.get(request)
953953
username = running_pipeline['kwargs'].get('username')
954954
backend_name = running_pipeline['backend']
955-
requested_provider = provider.Registry.get_by_backend_name(backend_name)
955+
third_party_uid = running_pipeline['kwargs']['uid']
956+
requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
956957

957958
try:
958-
user = pipeline.get_authenticated_user(username, backend_name)
959+
user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid)
959960
third_party_auth_successful = True
960961
except User.DoesNotExist:
961962
AUDIT_LOG.warning(
@@ -1509,7 +1510,7 @@ def create_account_with_params(request, params):
15091510
provider_name = None
15101511
if third_party_auth.is_enabled() and pipeline.running(request):
15111512
running_pipeline = pipeline.get(request)
1512-
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
1513+
current_provider = provider.Registry.get_from_pipeline(running_pipeline)
15131514
provider_name = current_provider.NAME
15141515

15151516
analytics.track(

common/djangoapps/third_party_auth/pipeline.py

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,11 @@ class ProviderUserState(object):
196196
lms/templates/dashboard.html.
197197
"""
198198

199-
def __init__(self, enabled_provider, user, state):
199+
def __init__(self, enabled_provider, user, association_id=None):
200+
# UserSocialAuth row ID
201+
self.association_id = association_id
200202
# Boolean. Whether the user has an account associated with the provider
201-
self.has_account = state
203+
self.has_account = association_id is not None
202204
# provider.BaseProvider child. Callers must verify that the provider is
203205
# enabled.
204206
self.provider = enabled_provider
@@ -215,7 +217,7 @@ def get(request):
215217
return request.session.get('partial_pipeline')
216218

217219

218-
def get_authenticated_user(username, backend_name):
220+
def get_authenticated_user(auth_provider, username, uid):
219221
"""Gets a saved user authenticated by a particular backend.
220222
221223
Between pipeline steps User objects are not saved. We need to reconstitute
@@ -224,26 +226,26 @@ def get_authenticated_user(username, backend_name):
224226
authenticate().
225227
226228
Args:
229+
auth_provider: the third_party_auth provider in use for the current pipeline.
227230
username: string. Username of user to get.
228-
backend_name: string. The name of the third-party auth backend from
229-
the running pipeline.
231+
uid: string. The user ID according to the third party.
230232
231233
Returns:
232234
User if user is found and has a social auth from the passed
233-
backend_name.
235+
provider.
234236
235237
Raises:
236238
User.DoesNotExist: if no user matching user is found, or the matching
237239
user has no social auth associated with the given backend.
238240
AssertionError: if the user is not authenticated.
239241
"""
240-
user = models.DjangoStorage.user.user_model().objects.get(username=username)
241-
match = models.DjangoStorage.user.get_social_auth_for_user(user, provider=backend_name)
242+
match = models.DjangoStorage.user.get_social_auth(provider=auth_provider.BACKEND_CLASS.name, uid=uid)
242243

243-
if not match:
244+
if not match or match.user.username != username:
244245
raise User.DoesNotExist
245246

246-
user.backend = provider.Registry.get_by_backend_name(backend_name).get_authentication_backend()
247+
user = match.user
248+
user.backend = auth_provider.get_authentication_backend()
247249
return user
248250

249251

@@ -257,10 +259,12 @@ def _get_enabled_provider_by_name(provider_name):
257259
return enabled_provider
258260

259261

260-
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None):
262+
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None,
263+
extra_params=None, url_params=None):
261264
"""Creates a URL to hook into social auth endpoints."""
262-
kwargs = {'backend': backend_name}
263-
url = reverse(view_name, kwargs=kwargs)
265+
url_params = url_params or {}
266+
url_params['backend'] = backend_name
267+
url = reverse(view_name, kwargs=url_params)
264268

265269
query_params = OrderedDict()
266270
if auth_entry:
@@ -269,6 +273,9 @@ def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None):
269273
if redirect_url:
270274
query_params[AUTH_REDIRECT_KEY] = redirect_url
271275

276+
if extra_params:
277+
query_params.update(extra_params)
278+
272279
return u"{url}?{params}".format(
273280
url=url,
274281
params=urllib.urlencode(query_params)
@@ -288,29 +295,32 @@ def get_complete_url(backend_name):
288295
Raises:
289296
ValueError: if no provider is enabled with the given backend_name.
290297
"""
291-
enabled_provider = provider.Registry.get_by_backend_name(backend_name)
292-
293-
if not enabled_provider:
298+
if not any(provider.Registry.get_enabled_by_backend_name(backend_name)):
294299
raise ValueError('Provider with backend %s not enabled' % backend_name)
295300

296301
return _get_url('social:complete', backend_name)
297302

298303

299-
def get_disconnect_url(provider_name):
304+
def get_disconnect_url(provider_name, association_id):
300305
"""Gets URL for the endpoint that starts the disconnect pipeline.
301306
302307
Args:
303308
provider_name: string. Name of the provider.BaseProvider child you want
304309
to disconnect from.
310+
association_id: int. Optional ID of a specific row in the UserSocialAuth
311+
table to disconnect (useful if multiple providers use a common backend)
305312
306313
Returns:
307314
String. URL that starts the disconnection pipeline.
308315
309316
Raises:
310-
ValueError: if no provider is enabled with the given backend_name.
317+
ValueError: if no provider is enabled with the given name.
311318
"""
312-
enabled_provider = _get_enabled_provider_by_name(provider_name)
313-
return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name)
319+
backend_name = _get_enabled_provider_by_name(provider_name).BACKEND_CLASS.name
320+
if association_id:
321+
return _get_url('social:disconnect_individual', backend_name, url_params={'association_id': association_id})
322+
else:
323+
return _get_url('social:disconnect', backend_name)
314324

315325

316326
def get_login_url(provider_name, auth_entry, redirect_url=None):
@@ -340,6 +350,7 @@ def get_login_url(provider_name, auth_entry, redirect_url=None):
340350
enabled_provider.BACKEND_CLASS.name,
341351
auth_entry=auth_entry,
342352
redirect_url=redirect_url,
353+
extra_params=enabled_provider.get_url_params(),
343354
)
344355

345356

@@ -355,7 +366,7 @@ def get_duplicate_provider(messages):
355366
unfortunately not in a reusable constant.
356367
357368
Returns:
358-
provider.BaseProvider child instance. The provider of the duplicate
369+
string name of the python-social-auth backend that has the duplicate
359370
account, or None if there is no duplicate (and hence no error).
360371
"""
361372
social_auth_messages = [m for m in messages if m.message.endswith('is already in use.')]
@@ -364,7 +375,8 @@ def get_duplicate_provider(messages):
364375
return
365376

366377
assert len(social_auth_messages) == 1
367-
return provider.Registry.get_by_backend_name(social_auth_messages[0].extra_tags.split()[1])
378+
backend_name = social_auth_messages[0].extra_tags.split()[1]
379+
return backend_name
368380

369381

370382
def get_provider_user_states(user):
@@ -378,13 +390,16 @@ def get_provider_user_states(user):
378390
each enabled provider.
379391
"""
380392
states = []
381-
found_user_backends = [
382-
social_auth.provider for social_auth in models.DjangoStorage.user.get_social_auth_for_user(user)
383-
]
393+
found_user_auths = list(models.DjangoStorage.user.get_social_auth_for_user(user))
384394

385395
for enabled_provider in provider.Registry.enabled():
396+
association_id = None
397+
for auth in found_user_auths:
398+
if enabled_provider.match_social_auth(auth):
399+
association_id = auth.id
400+
break
386401
states.append(
387-
ProviderUserState(enabled_provider, user, enabled_provider.BACKEND_CLASS.name in found_user_backends)
402+
ProviderUserState(enabled_provider, user, association_id)
388403
)
389404

390405
return states

common/djangoapps/third_party_auth/provider.py

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"""
66

77
from social.backends import google, linkedin, facebook
8+
from social.backends.saml import OID_EDU_PERSON_PRINCIPAL_NAME
9+
from .saml import SAMLAuthBackend
810

911
_DEFAULT_ICON_CLASS = 'fa-signin'
1012

@@ -109,6 +111,21 @@ def merge_onto(cls, settings):
109111
for key, value in cls.SETTINGS.iteritems():
110112
setattr(settings, key, value)
111113

114+
@classmethod
115+
def get_url_params(cls):
116+
""" Get a dict of GET parameters to append to login links for this provider """
117+
return {}
118+
119+
@classmethod
120+
def is_active_for_pipeline(cls, pipeline):
121+
""" Is this provider being used for the specified pipeline? """
122+
return cls.BACKEND_CLASS.name == pipeline['backend']
123+
124+
@classmethod
125+
def match_social_auth(cls, social_auth):
126+
""" Is this provider being used for this UserSocialAuth entry? """
127+
return cls.BACKEND_CLASS.name == social_auth.provider
128+
112129

113130
class GoogleOauth2(BaseProvider):
114131
"""Provider for Google's Oauth2 auth system."""
@@ -146,6 +163,78 @@ class FacebookOauth2(BaseProvider):
146163
}
147164

148165

166+
class SAMLProviderMixin(object):
167+
""" Base class for SAML/Shibboleth providers """
168+
BACKEND_CLASS = SAMLAuthBackend
169+
ICON_CLASS = 'fa-university'
170+
171+
@classmethod
172+
def get_url_params(cls):
173+
""" Get a dict of GET parameters to append to login links for this provider """
174+
return {'idp': cls.IDP["id"]}
175+
176+
@classmethod
177+
def is_active_for_pipeline(cls, pipeline):
178+
""" Is this provider being used for the specified pipeline? """
179+
if cls.BACKEND_CLASS.name == pipeline['backend']:
180+
idp_name = pipeline['kwargs']['response']['idp_name']
181+
return cls.IDP["id"] == idp_name
182+
return False
183+
184+
@classmethod
185+
def match_social_auth(cls, social_auth):
186+
""" Is this provider being used for this UserSocialAuth entry? """
187+
prefix = cls.IDP["id"] + ":"
188+
return cls.BACKEND_CLASS.name == social_auth.provider and social_auth.uid.startswith(prefix)
189+
190+
191+
class TestShibAProvider(SAMLProviderMixin, BaseProvider):
192+
""" Provider for testshib.org public Shibboleth test server. """
193+
NAME = 'TestShib A'
194+
IDP = {
195+
"id": "testshiba", # Required slug
196+
"entity_id": "https://idp.testshib.org/idp/shibboleth",
197+
"url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
198+
"attr_email": OID_EDU_PERSON_PRINCIPAL_NAME,
199+
"x509cert": """
200+
MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV
201+
MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD
202+
VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4
203+
MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI
204+
EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl
205+
c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B
206+
AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C
207+
yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe
208+
3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT
209+
NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614
210+
kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH
211+
gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G
212+
A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86
213+
9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl
214+
bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo
215+
aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN
216+
BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL
217+
I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo
218+
93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4
219+
/SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj
220+
Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr
221+
8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA==
222+
"""
223+
}
224+
225+
226+
class TestShibBProvider(SAMLProviderMixin, BaseProvider):
227+
""" Provider for testshib.org public Shibboleth test server. """
228+
NAME = 'TestShib B'
229+
IDP = {
230+
"id": "testshibB", # Required slug
231+
"entity_id": "https://idp.testshib.org/idp/shibboleth",
232+
"url": "https://IDP.TESTSHIB.ORG/idp/profile/SAML2/Redirect/SSO",
233+
"attr_email": OID_EDU_PERSON_PRINCIPAL_NAME,
234+
"x509cert": TestShibAProvider.IDP["x509cert"],
235+
}
236+
237+
149238
class Registry(object):
150239
"""Singleton registry of third-party auth providers.
151240
@@ -211,22 +300,48 @@ def get(cls, provider_name):
211300
return cls._ENABLED.get(provider_name)
212301

213302
@classmethod
214-
def get_by_backend_name(cls, backend_name):
215-
"""Gets provider (or None) by backend name.
303+
def get_from_pipeline(cls, running_pipeline):
304+
"""Gets the provider that is being used for the specified pipeline (or None).
216305
217306
Args:
218-
backend_name: string. The python-social-auth
219-
backends.base.BaseAuth.name (for example, 'google-oauth2') to
220-
try and get a provider for.
307+
running_pipeline: The python-social-auth pipeline being used to
308+
authenticate a user.
309+
310+
Returns:
311+
A provider class (a subclass of BaseProvider) or None.
221312
222313
Raises:
223314
RuntimeError: if the registry has not been configured.
224315
"""
225316
cls._check_configured()
226317
for enabled in cls._ENABLED.values():
227-
if enabled.BACKEND_CLASS.name == backend_name:
318+
if enabled.is_active_for_pipeline(running_pipeline):
228319
return enabled
229320

321+
@classmethod
322+
def get_enabled_by_backend_name(cls, backend_name):
323+
"""Generator returning all enabled providers that use the specified
324+
backend.
325+
326+
Example:
327+
>>> list(get_enabled_by_backend_name("tpa-saml"))
328+
[TestShibAProvider, TestShibBProvider]
329+
330+
Args:
331+
backend_name: The name of a python-social-auth backend used by
332+
one or more providers.
333+
334+
Yields:
335+
Provider classes (subclasses of BaseProvider).
336+
337+
Raises:
338+
RuntimeError: if the registry has not been configured.
339+
"""
340+
cls._check_configured()
341+
for enabled in cls._ENABLED.values():
342+
if enabled.BACKEND_CLASS.name == backend_name:
343+
yield enabled
344+
230345
@classmethod
231346
def _reset(cls):
232347
"""Returns the registry to an unconfigured state; for tests only."""
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
Slightly customized python-social-auth backend for SAML 2.0 support
3+
"""
4+
5+
from social.backends.saml import SAMLIdentityProvider, SAMLAuth
6+
7+
8+
class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method
9+
"""
10+
Customized version of SAMLAuth that gets the list of IdPs from third_party_auth's list of
11+
enabled providers.
12+
"""
13+
name = "tpa-saml"
14+
15+
def get_idp(self, idp_name):
16+
""" Given the name of an IdP, get a SAMLIdentityProvider instance """
17+
from .provider import Registry # Import here to avoid circular import
18+
for provider in Registry.enabled():
19+
if issubclass(provider.BACKEND_CLASS, SAMLAuth) and provider.IDP["id"] == idp_name:
20+
return SAMLIdentityProvider(idp_name, **provider.IDP)
21+
raise KeyError("SAML IdP {} not found.".format(idp_name))

0 commit comments

Comments
 (0)