~vcs-imports/reviewboard/trunk

« back to all changes in this revision

Viewing changes to reviewboard/accounts/sso/backends/saml/views.py

  • Committer: David Trowbridge
  • Date: 2022-06-16 20:59:51 UTC
  • mfrom: (4910.2.123)
  • Revision ID: git-v1:6801d06c2f05c7b9817c865205dcf823cacafce3
Merge branch 'release-5.0.x'

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
"""Views for SAML SSO.
 
2
 
 
3
Version Added:
 
4
    5.0
 
5
"""
 
6
 
 
7
from enum import Enum
 
8
from urllib.parse import urlparse
 
9
import logging
 
10
 
 
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,
 
15
                         HttpResponse,
 
16
                         HttpResponseBadRequest,
 
17
                         HttpResponseRedirect,
 
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
 
25
try:
 
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
 
30
except ImportError:
 
31
    OneLogin_Saml2_Auth = None
 
32
    OneLogin_Saml2_Error = None
 
33
    OneLogin_Saml2_Settings = None
 
34
    OneLogin_Saml2_Utils = None
 
35
 
 
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
 
44
 
 
45
 
 
46
logger = logging.getLogger(__file__)
 
47
 
 
48
 
 
49
class SAMLViewMixin(View):
 
50
    """Mixin to provide common functionality for SAML views.
 
51
 
 
52
    Version Added:
 
53
        5.0
 
54
    """
 
55
 
 
56
    def __init__(self, *args, **kwargs):
 
57
        """Initialize the view.
 
58
 
 
59
        Args:
 
60
            *args (tuple):
 
61
                Positional arguments to pass through to the base class.
 
62
 
 
63
            **kwargs (dict):
 
64
                Keyword arguments to pass through to the base class.
 
65
        """
 
66
        super().__init__(*args, **kwargs)
 
67
        self._saml_auth = None
 
68
        self._saml_request = None
 
69
 
 
70
    def get_saml_request(self, request):
 
71
        """Return the SAML request.
 
72
 
 
73
        Args:
 
74
            request (django.http.HttpRequest):
 
75
                The HTTP request from the client.
 
76
 
 
77
        Returns:
 
78
            dict:
 
79
            Information about the SAML request.
 
80
        """
 
81
        if self._saml_request is None:
 
82
            server_url = urlparse(get_server_url())
 
83
 
 
84
            if server_url.scheme == 'https':
 
85
                https = 'on'
 
86
            else:
 
87
                https = 'off'
 
88
 
 
89
            self._saml_request = {
 
90
                'https': https,
 
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,
 
98
            }
 
99
 
 
100
        return self._saml_request
 
101
 
 
102
    def get_saml_auth(self, request):
 
103
        """Return the SAML auth information.
 
104
 
 
105
        Args:
 
106
            request (django.http.HttpRequest):
 
107
                The HTTP request from the client.
 
108
 
 
109
        Returns:
 
110
            onelogin.saml2.auth.OneLogin_Saml2_Auth:
 
111
            The SAML Auth object.
 
112
        """
 
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())
 
118
 
 
119
        return self._saml_auth
 
120
 
 
121
    def dispatch(self, *args, **kwargs):
 
122
        """Handle a dispatch for the view.
 
123
 
 
124
        Args:
 
125
            *args (tuple):
 
126
                Positional arguments to pass through to the parent class.
 
127
 
 
128
            **kwargs (dict):
 
129
                Keyword arguments to pass through to the parent class.
 
130
 
 
131
        Returns:
 
132
            django.http.HttpResponse:
 
133
            The response to send back to the client.
 
134
 
 
135
        Raises:
 
136
            django.http.Http404:
 
137
                The SAML backend is not enabled, so treat all SAML views as
 
138
                404.
 
139
        """
 
140
        if not self.sso_backend.is_enabled():
 
141
            raise Http404
 
142
 
 
143
        return super().dispatch(*args, **kwargs)
 
144
 
 
145
 
 
146
class SAMLACSView(SAMLViewMixin, BaseSSOView):
 
147
    """ACS view for SAML SSO.
 
148
 
 
149
    Version Added:
 
150
        5.0
 
151
    """
 
152
 
 
153
    @property
 
154
    def success_url(self):
 
155
        """The URL to redirect to after a successful login.
 
156
 
 
157
        Type:
 
158
            str
 
159
        """
 
160
        url = self.request.POST.get('RelayState')
 
161
 
 
162
        assert OneLogin_Saml2_Utils is not None
 
163
        self_url = OneLogin_Saml2_Utils.get_self_url(
 
164
            self.get_saml_request(self.request))
 
165
 
 
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)
 
169
        else:
 
170
            return settings.LOGIN_REDIRECT_URL
 
171
 
 
172
    @cached_property
 
173
    def link_user_url(self):
 
174
        """The URL to the link-user flow.
 
175
 
 
176
        Type:
 
177
            str
 
178
        """
 
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})
 
184
 
 
185
    def post(self, request, *args, **kwargs):
 
186
        """Handle a POST request.
 
187
 
 
188
        Args:
 
189
            request (django.http.HttpRequest):
 
190
                The request from the client.
 
191
 
 
192
            *args (tuple):
 
193
                Additional positional arguments.
 
194
 
 
195
            **kwargs (dict):
 
196
                Additional keyword arguments.
 
197
 
 
198
        Returns:
 
199
            django.http.HttpResponse:
 
200
            The response to send back to the client.
 
201
        """
 
202
        auth = self.get_saml_auth(request)
 
203
        session = request.session
 
204
 
 
205
        try:
 
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,
 
209
                             exc_info=True)
 
210
            return HttpResponseBadRequest('Bad SSO response: %s' % str(e),
 
211
                                          content_type='text/plain')
 
212
 
 
213
        # TODO: store/check last request ID, last message ID, last assertion ID
 
214
        # to prevent replay attacks.
 
215
 
 
216
        error = auth.get_last_error_reason()
 
217
 
 
218
        if error:
 
219
            logger.error('SAML: Unable to process SSO request: %s', error)
 
220
            return HttpResponseBadRequest('Bad SSO response: %s' % error,
 
221
                                          content_type='text/plain')
 
222
 
 
223
        # Store some state on the session to identify where we are in the SAML
 
224
        # workflow.
 
225
        session.pop('AuthNRequestID', None)
 
226
 
 
227
        linked_account = get_object_or_none(LinkedAccount,
 
228
                                            service_id='sso:saml',
 
229
                                            service_user_id=auth.get_nameid())
 
230
 
 
231
        if linked_account:
 
232
            user = linked_account.user
 
233
            self.sso_backend.login_user(request, user)
 
234
            return HttpResponseRedirect(self.success_url)
 
235
        else:
 
236
            username = auth.get_nameid()
 
237
 
 
238
            try:
 
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'
 
246
                                              % e,
 
247
                                              content_type='text/plain')
 
248
 
 
249
            request.session['sso'] = {
 
250
                'user_data': {
 
251
                    'id': username,
 
252
                    'first_name': first_name,
 
253
                    'last_name': last_name,
 
254
                    'email': email,
 
255
                },
 
256
                'raw_user_attrs': auth.get_attributes(),
 
257
                'session_index': auth.get_session_index(),
 
258
            }
 
259
 
 
260
            return HttpResponseRedirect(self.link_user_url)
 
261
 
 
262
    def _get_user_attr_value(self, auth, key):
 
263
        """Return the value of a user attribute.
 
264
 
 
265
        Args:
 
266
            auth (onelogin.saml2.auth.OneLogin_Saml2_Auth):
 
267
                The SAML authentication object.
 
268
 
 
269
            key (str):
 
270
                The key to look up.
 
271
 
 
272
        Returns:
 
273
            str:
 
274
            The attribute, if it exists.
 
275
 
 
276
        Raises:
 
277
            KeyError:
 
278
                The given key was not present in the SAML assertion.
 
279
        """
 
280
        value = auth.get_attribute(key)
 
281
 
 
282
        if value and isinstance(value, list):
 
283
            return value[0]
 
284
 
 
285
        raise KeyError(key)
 
286
 
 
287
 
 
288
@method_decorator(csrf_protect, name='dispatch')
 
289
class SAMLLinkUserView(SAMLViewMixin, BaseSSOView, LoginView):
 
290
    """Link user view for SAML SSO.
 
291
 
 
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.
 
294
 
 
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
 
298
    address.
 
299
 
 
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
 
304
    the success URL.
 
305
 
 
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.
 
311
 
 
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.
 
316
 
 
317
    Version Added:
 
318
        5.0
 
319
    """
 
320
 
 
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.
 
325
 
 
326
    form_class = SAMLLinkUserForm
 
327
 
 
328
    class Mode(Enum):
 
329
        CONNECT_EXISTING_ACCOUNT = 'connect'
 
330
        CONNECT_WITH_LOGIN = 'connect-login'
 
331
        PROVISION = 'provision'
 
332
 
 
333
    def dispatch(self, *args, **kwargs):
 
334
        """Dispatch the view.
 
335
 
 
336
        Args:
 
337
            *args (tuple):
 
338
                Positional arguments to pass to the parent class.
 
339
 
 
340
            **kwargs (dict):
 
341
                Keyword arguments to pass to the parent class.
 
342
        """
 
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,
 
352
            computed_username)
 
353
 
 
354
        requested_mode = self.request.GET.get('mode')
 
355
 
 
356
        if requested_mode and requested_mode in self.Mode:
 
357
            self._mode = requested_mode
 
358
        elif self._sso_user:
 
359
            self._mode = self.Mode.CONNECT_EXISTING_ACCOUNT
 
360
        else:
 
361
            self._mode = self.Mode.PROVISION
 
362
 
 
363
        return super(SAMLLinkUserView, self).dispatch(*args, **kwargs)
 
364
 
 
365
    def get_template_names(self):
 
366
        """Return the template to use when rendering.
 
367
 
 
368
        Returns:
 
369
            list:
 
370
            A single-item list with the template name to use when rendering.
 
371
 
 
372
        Raises:
 
373
            ValueError:
 
374
                The current mode is not valid.
 
375
        """
 
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']
 
382
        else:
 
383
            raise ValueError('Unknown link-user mode "%s"' % self._mode)
 
384
 
 
385
    def get_initial(self):
 
386
        """Return the initial data for the form.
 
387
 
 
388
        Returns:
 
389
            dict:
 
390
            Initial data for the form.
 
391
        """
 
392
        initial = super(SAMLLinkUserView, self).get_initial()
 
393
 
 
394
        if self._sso_user is not None:
 
395
            initial['username'] = self._sso_user.username
 
396
        else:
 
397
            initial['username'] = self._provision_username
 
398
 
 
399
        initial['provision'] = (self._mode == self.Mode.PROVISION)
 
400
 
 
401
        return initial
 
402
 
 
403
    def get_context_data(self, **kwargs):
 
404
        """Return additional context data for rendering the template.
 
405
 
 
406
        Args:
 
407
            **kwargs (dict):
 
408
                Keyword arguments for the view.
 
409
 
 
410
        Returns:
 
411
            dict:
 
412
            Additional data to inject into the render context.
 
413
        """
 
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
 
418
 
 
419
        return context
 
420
 
 
421
    def get(self, request, *args, **kwargs):
 
422
        """Handle a GET request for the form.
 
423
 
 
424
        Args:
 
425
            request (django.http.HttpRequest):
 
426
                The HTTP request from the client.
 
427
 
 
428
            *args (tuple):
 
429
                Positional arguments to pass through to the base class.
 
430
 
 
431
            **kwargs (dict):
 
432
                Keyword arguments to pass through to the base class.
 
433
 
 
434
        Returns:
 
435
            django.http.HttpResponse:
 
436
            The response to send back to the client.
 
437
        """
 
438
        if not self._sso_user_data:
 
439
            return HttpResponseRedirect(
 
440
                local_site_reverse('login', request=request))
 
441
 
 
442
        siteconfig = SiteConfiguration.objects.get_current()
 
443
 
 
444
        if self._sso_user and not siteconfig.get('saml_require_login_to_link'):
 
445
            return self.link_user(self._sso_user)
 
446
 
 
447
        return super(SAMLLinkUserView, self).get(request, *args, **kwargs)
 
448
 
 
449
    def form_valid(self, form):
 
450
        """Handler for when the form has successfully authenticated.
 
451
 
 
452
        Args:
 
453
            form (reviewboard.accounts.sso.backends.saml.forms.
 
454
                  SAMLLinkUserForm):
 
455
                The link-user form.
 
456
 
 
457
        Returns:
 
458
            django.http.HttpResponseRedirect:
 
459
            A redirect to the next page.
 
460
        """
 
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
 
466
 
 
467
            first_name = self._sso_user_data.get('first_name')
 
468
            last_name = self._sso_user_data.get('last_name')
 
469
 
 
470
            logger.info('SAML: Provisiong user "%s" (%s <%s %s>)',
 
471
                        self._provision_username, self._sso_data_email,
 
472
                        first_name, last_name)
 
473
 
 
474
            user = User.objects.create(
 
475
                username=self._provision_username,
 
476
                email=self._sso_data_email,
 
477
                first_name=first_name,
 
478
                last_name=last_name)
 
479
        else:
 
480
            user = form.get_user()
 
481
 
 
482
        return self.link_user(user)
 
483
 
 
484
    def link_user(self, user):
 
485
        """Link the given user.
 
486
 
 
487
        Args:
 
488
            user (django.contrib.auth.models.User):
 
489
                The user to link.
 
490
 
 
491
        Returns:
 
492
            django.http.HttpResponseRedirect:
 
493
            A redirect to the success URL.
 
494
        """
 
495
        sso_id = self._sso_user_data.get('id')
 
496
 
 
497
        logger.info('SAML: Linking SSO user "%s" to Review Board user "%s"',
 
498
                    sso_id, user.username)
 
499
 
 
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())
 
505
 
 
506
 
 
507
class SAMLLoginView(SAMLViewMixin, BaseSSOView):
 
508
    """Login view for SAML SSO.
 
509
 
 
510
    Version Added:
 
511
        5.0
 
512
    """
 
513
 
 
514
    def get(self, request, *args, **kwargs):
 
515
        """Handle a GET request for the login URL.
 
516
 
 
517
        Args:
 
518
            request (django.http.HttpRequest):
 
519
                The request from the client.
 
520
 
 
521
            *args (tuple, unused):
 
522
                Additional positional arguments.
 
523
 
 
524
            **kwargs (dict, unused):
 
525
                Additional keyword arguments.
 
526
 
 
527
        Returns:
 
528
            django.http.HttpResponseRedirect:
 
529
            A redirect to start the login flow.
 
530
        """
 
531
        auth = self.get_saml_auth(request)
 
532
 
 
533
        return HttpResponseRedirect(
 
534
            auth.login(settings.LOGIN_REDIRECT_URL))
 
535
 
 
536
 
 
537
class SAMLMetadataView(SAMLViewMixin, BaseSSOView):
 
538
    """Metadata view for SAML SSO.
 
539
 
 
540
    Version Added:
 
541
        5.0
 
542
    """
 
543
 
 
544
    def get(self, request, *args, **kwargs):
 
545
        """Handle a GET request.
 
546
 
 
547
        Args:
 
548
            request (django.http.HttpRequest):
 
549
                The HTTP request from the client.
 
550
 
 
551
            *args (tuple):
 
552
                Positional arguments from the URL definition.
 
553
 
 
554
            **kwargs (dict):
 
555
                Keyword arguments from the URL definition.
 
556
        """
 
557
        assert OneLogin_Saml2_Settings is not None
 
558
        saml_settings = OneLogin_Saml2_Settings(
 
559
            get_saml2_settings(),
 
560
            sp_validation_only=True)
 
561
 
 
562
        metadata = saml_settings.get_sp_metadata()
 
563
        errors = saml_settings.validate_metadata(metadata)
 
564
 
 
565
        if errors:
 
566
            logger.error('SAML: Got errors from metadata validation: %s',
 
567
                         ', '.join(errors))
 
568
            return HttpResponseServerError(', '.join(errors),
 
569
                                           content_type='text/plain')
 
570
 
 
571
        return HttpResponse(metadata, content_type='text/xml')
 
572
 
 
573
 
 
574
class SAMLSLSView(SAMLViewMixin, BaseSSOView):
 
575
    """SLS view for SAML SSO.
 
576
 
 
577
    Version Added:
 
578
        5.0
 
579
    """
 
580
 
 
581
    def get(self, request, *args, **kwargs):
 
582
        """Handle a POST request.
 
583
 
 
584
        Args:
 
585
            request (django.http.HttpRequest):
 
586
                The request from the client.
 
587
 
 
588
            *args (tuple):
 
589
                Additional positional arguments.
 
590
 
 
591
            **kwargs (dict):
 
592
                Additional keyword arguments.
 
593
 
 
594
        Returns:
 
595
            django.http.HttpResponse:
 
596
            The response to send back to the client.
 
597
        """
 
598
        auth = self.get_saml_auth(request)
 
599
        request_id = None
 
600
 
 
601
        if 'LogoutRequestId' in request.session:
 
602
            request_id = request.session['LogoutRequestId']
 
603
 
 
604
        redirect_url = auth.process_slo(
 
605
            request_id=request_id,
 
606
            delete_session_cb=lambda: request.session.flush())
 
607
 
 
608
        errors = auth.get_errors()
 
609
 
 
610
        if 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')
 
615
 
 
616
        if redirect_url:
 
617
            return HttpResponseRedirect(redirect_url)
 
618
        else:
 
619
            return HttpResponseRedirect(settings.LOGIN_URL)