8
from urllib.parse import urlparse
11
from django.conf import settings
12
from django.contrib.auth.models import User
13
from django.contrib.auth.views import LoginView
14
from django.http import (Http404,
16
HttpResponseBadRequest,
18
HttpResponseServerError)
19
from django.utils.decorators import method_decorator
20
from django.views.decorators.csrf import csrf_protect
21
from django.views.generic.base import View
22
from djblets.db.query import get_object_or_none
23
from djblets.siteconfig.models import SiteConfiguration
24
from djblets.util.decorators import cached_property
26
from onelogin.saml2.auth import OneLogin_Saml2_Auth
27
from onelogin.saml2.errors import OneLogin_Saml2_Error
28
from onelogin.saml2.settings import OneLogin_Saml2_Settings
29
from onelogin.saml2.utils import OneLogin_Saml2_Utils
31
OneLogin_Saml2_Auth = None
32
OneLogin_Saml2_Error = None
33
OneLogin_Saml2_Settings = None
34
OneLogin_Saml2_Utils = None
36
from reviewboard.accounts.models import LinkedAccount
37
from reviewboard.accounts.sso.backends.saml.forms import SAMLLinkUserForm
38
from reviewboard.accounts.sso.backends.saml.settings import get_saml2_settings
39
from reviewboard.accounts.sso.users import (find_suggested_username,
40
find_user_for_sso_user_id)
41
from reviewboard.accounts.sso.views import BaseSSOView
42
from reviewboard.admin.server import get_server_url
43
from reviewboard.site.urlresolvers import local_site_reverse
46
logger = logging.getLogger(__file__)
49
class SAMLViewMixin(View):
50
"""Mixin to provide common functionality for SAML views.
56
def __init__(self, *args, **kwargs):
57
"""Initialize the view.
61
Positional arguments to pass through to the base class.
64
Keyword arguments to pass through to the base class.
66
super().__init__(*args, **kwargs)
67
self._saml_auth = None
68
self._saml_request = None
70
def get_saml_request(self, request):
71
"""Return the SAML request.
74
request (django.http.HttpRequest):
75
The HTTP request from the client.
79
Information about the SAML request.
81
if self._saml_request is None:
82
server_url = urlparse(get_server_url())
84
if server_url.scheme == 'https':
89
self._saml_request = {
91
'http_host': server_url.hostname,
92
'get_data': request.GET.copy(),
93
'post_data': request.POST.copy(),
94
'query_string': request.META['QUERY_STRING'],
95
'request_uri': request.path,
96
'script_name': request.META['PATH_INFO'],
97
'server_port': server_url.port,
100
return self._saml_request
102
def get_saml_auth(self, request):
103
"""Return the SAML auth information.
106
request (django.http.HttpRequest):
107
The HTTP request from the client.
110
onelogin.saml2.auth.OneLogin_Saml2_Auth:
111
The SAML Auth object.
113
if self._saml_auth is None:
114
assert OneLogin_Saml2_Auth is not None
115
self._saml_auth = OneLogin_Saml2_Auth(
116
self.get_saml_request(request),
117
get_saml2_settings())
119
return self._saml_auth
121
def dispatch(self, *args, **kwargs):
122
"""Handle a dispatch for the view.
126
Positional arguments to pass through to the parent class.
129
Keyword arguments to pass through to the parent class.
132
django.http.HttpResponse:
133
The response to send back to the client.
137
The SAML backend is not enabled, so treat all SAML views as
140
if not self.sso_backend.is_enabled():
143
return super().dispatch(*args, **kwargs)
146
class SAMLACSView(SAMLViewMixin, BaseSSOView):
147
"""ACS view for SAML SSO.
154
def success_url(self):
155
"""The URL to redirect to after a successful login.
160
url = self.request.POST.get('RelayState')
162
assert OneLogin_Saml2_Utils is not None
163
self_url = OneLogin_Saml2_Utils.get_self_url(
164
self.get_saml_request(self.request))
166
if url is not None and self_url != url:
167
saml_auth = self.get_saml_auth(self.request)
168
return saml_auth.redirect_to(url)
170
return settings.LOGIN_REDIRECT_URL
173
def link_user_url(self):
174
"""The URL to the link-user flow.
179
assert self.sso_backend is not None
180
return local_site_reverse(
181
'sso:%s:link-user' % self.sso_backend.backend_id,
182
request=self.request,
183
kwargs={'backend_id': self.sso_backend.backend_id})
185
def post(self, request, *args, **kwargs):
186
"""Handle a POST request.
189
request (django.http.HttpRequest):
190
The request from the client.
193
Additional positional arguments.
196
Additional keyword arguments.
199
django.http.HttpResponse:
200
The response to send back to the client.
202
auth = self.get_saml_auth(request)
203
session = request.session
206
auth.process_response(request_id=session.get('AuthNRequestID'))
207
except OneLogin_Saml2_Error as e:
208
logger.exception('SAML: Unable to process SSO request: %s', e,
210
return HttpResponseBadRequest('Bad SSO response: %s' % str(e),
211
content_type='text/plain')
213
# TODO: store/check last request ID, last message ID, last assertion ID
214
# to prevent replay attacks.
216
error = auth.get_last_error_reason()
219
logger.error('SAML: Unable to process SSO request: %s', error)
220
return HttpResponseBadRequest('Bad SSO response: %s' % error,
221
content_type='text/plain')
223
# Store some state on the session to identify where we are in the SAML
225
session.pop('AuthNRequestID', None)
227
linked_account = get_object_or_none(LinkedAccount,
228
service_id='sso:saml',
229
service_user_id=auth.get_nameid())
232
user = linked_account.user
233
self.sso_backend.login_user(request, user)
234
return HttpResponseRedirect(self.success_url)
236
username = auth.get_nameid()
239
email = self._get_user_attr_value(auth, 'User.email')
240
first_name = self._get_user_attr_value(auth, 'User.FirstName')
241
last_name = self._get_user_attr_value(auth, 'User.LastName')
242
except KeyError as e:
243
logger.error('SAML: Assertion is missing %s attribute', e)
244
return HttpResponseBadRequest('Bad SSO response: assertion is '
245
'missing %s attribute'
247
content_type='text/plain')
249
request.session['sso'] = {
252
'first_name': first_name,
253
'last_name': last_name,
256
'raw_user_attrs': auth.get_attributes(),
257
'session_index': auth.get_session_index(),
260
return HttpResponseRedirect(self.link_user_url)
262
def _get_user_attr_value(self, auth, key):
263
"""Return the value of a user attribute.
266
auth (onelogin.saml2.auth.OneLogin_Saml2_Auth):
267
The SAML authentication object.
274
The attribute, if it exists.
278
The given key was not present in the SAML assertion.
280
value = auth.get_attribute(key)
282
if value and isinstance(value, list):
288
@method_decorator(csrf_protect, name='dispatch')
289
class SAMLLinkUserView(SAMLViewMixin, BaseSSOView, LoginView):
290
"""Link user view for SAML SSO.
292
This can have several behaviors depending on what combination of state we
293
get from the Identity Provider and what we have stored in the database.
295
The first major case is where we are given data that matches an existing
296
user in the database. Ideally this is via the "username" field, but may
297
also be a matching e-mail address, or parsing a username out of the e-mail
300
In this case, there are two paths. The simple path is where the
301
administrator trusts both the authority and integrity of their Identity
302
Provider and has turned off the "Require login to link" setting. For this,
303
we'll just create the LinkedAccount, authenticate the user, and redirect to
306
If the require login setting is turned on, the user will have a choice.
307
They can enter the password for the detected user to complete the link. If
308
they have an account but the detected one is not correct, they can log in
309
with their username and password to link the other account. Finally, they
310
can provision a new user if they do not yet have one.
312
The second major case is where we cannot find an existing user. In this
313
case, we'll offer the user a choice: if they have an existing login that
314
wasn't found, they can log in with their (non-SSO) username and password.
315
If they don't have an account, they will be able to provision one.
321
# TODO: This has a lot of logic which will likely be applicable to other
322
# SSO backend implementations. When we add a new backend, this should be
323
# refactored to pull out most of the logic into a common base class, and
324
# just implement SAML-specific data here.
326
form_class = SAMLLinkUserForm
329
CONNECT_EXISTING_ACCOUNT = 'connect'
330
CONNECT_WITH_LOGIN = 'connect-login'
331
PROVISION = 'provision'
333
def dispatch(self, *args, **kwargs):
334
"""Dispatch the view.
338
Positional arguments to pass to the parent class.
341
Keyword arguments to pass to the parent class.
343
self._sso_user_data = \
344
self.request.session.get('sso', {}).get('user_data')
345
self._sso_data_username = self._sso_user_data.get('id')
346
self._sso_data_email = self._sso_user_data.get('email')
347
computed_username = find_suggested_username(self._sso_data_email)
348
self._provision_username = self._sso_data_username or computed_username
349
self._sso_user = find_user_for_sso_user_id(
350
self._sso_data_username,
351
self._sso_data_email,
354
requested_mode = self.request.GET.get('mode')
356
if requested_mode and requested_mode in self.Mode:
357
self._mode = requested_mode
359
self._mode = self.Mode.CONNECT_EXISTING_ACCOUNT
361
self._mode = self.Mode.PROVISION
363
return super(SAMLLinkUserView, self).dispatch(*args, **kwargs)
365
def get_template_names(self):
366
"""Return the template to use when rendering.
370
A single-item list with the template name to use when rendering.
374
The current mode is not valid.
376
if self._mode == self.Mode.CONNECT_EXISTING_ACCOUNT:
377
return ['accounts/sso/link-user-connect-existing.html']
378
elif self._mode == self.Mode.CONNECT_WITH_LOGIN:
379
return ['accounts/sso/link-user-login.html']
380
elif self._mode == self.Mode.PROVISION:
381
return ['accounts/sso/link-user-provision.html']
383
raise ValueError('Unknown link-user mode "%s"' % self._mode)
385
def get_initial(self):
386
"""Return the initial data for the form.
390
Initial data for the form.
392
initial = super(SAMLLinkUserView, self).get_initial()
394
if self._sso_user is not None:
395
initial['username'] = self._sso_user.username
397
initial['username'] = self._provision_username
399
initial['provision'] = (self._mode == self.Mode.PROVISION)
403
def get_context_data(self, **kwargs):
404
"""Return additional context data for rendering the template.
408
Keyword arguments for the view.
412
Additional data to inject into the render context.
414
context = super(SAMLLinkUserView, self).get_context_data(**kwargs)
415
context['user'] = self._sso_user
416
context['mode'] = self._mode
417
context['username'] = self._provision_username
421
def get(self, request, *args, **kwargs):
422
"""Handle a GET request for the form.
425
request (django.http.HttpRequest):
426
The HTTP request from the client.
429
Positional arguments to pass through to the base class.
432
Keyword arguments to pass through to the base class.
435
django.http.HttpResponse:
436
The response to send back to the client.
438
if not self._sso_user_data:
439
return HttpResponseRedirect(
440
local_site_reverse('login', request=request))
442
siteconfig = SiteConfiguration.objects.get_current()
444
if self._sso_user and not siteconfig.get('saml_require_login_to_link'):
445
return self.link_user(self._sso_user)
447
return super(SAMLLinkUserView, self).get(request, *args, **kwargs)
449
def form_valid(self, form):
450
"""Handler for when the form has successfully authenticated.
453
form (reviewboard.accounts.sso.backends.saml.forms.
458
django.http.HttpResponseRedirect:
459
A redirect to the next page.
461
if form.cleaned_data['provision']:
462
# We can't provision if there's an existing matching user.
463
# TODO: show an error?
464
assert not self._sso_user
465
assert self._provision_username
467
first_name = self._sso_user_data.get('first_name')
468
last_name = self._sso_user_data.get('last_name')
470
logger.info('SAML: Provisiong user "%s" (%s <%s %s>)',
471
self._provision_username, self._sso_data_email,
472
first_name, last_name)
474
user = User.objects.create(
475
username=self._provision_username,
476
email=self._sso_data_email,
477
first_name=first_name,
480
user = form.get_user()
482
return self.link_user(user)
484
def link_user(self, user):
485
"""Link the given user.
488
user (django.contrib.auth.models.User):
492
django.http.HttpResponseRedirect:
493
A redirect to the success URL.
495
sso_id = self._sso_user_data.get('id')
497
logger.info('SAML: Linking SSO user "%s" to Review Board user "%s"',
498
sso_id, user.username)
500
user.linked_accounts.create(
501
service_id='sso:saml',
502
service_user_id=sso_id)
503
self.sso_backend.login_user(self.request, user)
504
return HttpResponseRedirect(self.get_success_url())
507
class SAMLLoginView(SAMLViewMixin, BaseSSOView):
508
"""Login view for SAML SSO.
514
def get(self, request, *args, **kwargs):
515
"""Handle a GET request for the login URL.
518
request (django.http.HttpRequest):
519
The request from the client.
521
*args (tuple, unused):
522
Additional positional arguments.
524
**kwargs (dict, unused):
525
Additional keyword arguments.
528
django.http.HttpResponseRedirect:
529
A redirect to start the login flow.
531
auth = self.get_saml_auth(request)
533
return HttpResponseRedirect(
534
auth.login(settings.LOGIN_REDIRECT_URL))
537
class SAMLMetadataView(SAMLViewMixin, BaseSSOView):
538
"""Metadata view for SAML SSO.
544
def get(self, request, *args, **kwargs):
545
"""Handle a GET request.
548
request (django.http.HttpRequest):
549
The HTTP request from the client.
552
Positional arguments from the URL definition.
555
Keyword arguments from the URL definition.
557
assert OneLogin_Saml2_Settings is not None
558
saml_settings = OneLogin_Saml2_Settings(
559
get_saml2_settings(),
560
sp_validation_only=True)
562
metadata = saml_settings.get_sp_metadata()
563
errors = saml_settings.validate_metadata(metadata)
566
logger.error('SAML: Got errors from metadata validation: %s',
568
return HttpResponseServerError(', '.join(errors),
569
content_type='text/plain')
571
return HttpResponse(metadata, content_type='text/xml')
574
class SAMLSLSView(SAMLViewMixin, BaseSSOView):
575
"""SLS view for SAML SSO.
581
def get(self, request, *args, **kwargs):
582
"""Handle a POST request.
585
request (django.http.HttpRequest):
586
The request from the client.
589
Additional positional arguments.
592
Additional keyword arguments.
595
django.http.HttpResponse:
596
The response to send back to the client.
598
auth = self.get_saml_auth(request)
601
if 'LogoutRequestId' in request.session:
602
request_id = request.session['LogoutRequestId']
604
redirect_url = auth.process_slo(
605
request_id=request_id,
606
delete_session_cb=lambda: request.session.flush())
608
errors = auth.get_errors()
611
error_text = ', '.join(errors)
612
logger.error('SAML: Unable to process SLO request: %s', error_text)
613
return HttpResponseBadRequest('Bad SLO response: %s' % error_text,
614
content_type='text/plain')
617
return HttpResponseRedirect(redirect_url)
619
return HttpResponseRedirect(settings.LOGIN_URL)