views.py 35.7 KB
Newer Older
1
import functools
ichuang committed
2 3 4
import json
import logging
import random
5
import re
6
import string
7
import fnmatch
8
import unicodedata
9
import urllib
ichuang committed
10

11
from textwrap import dedent
ichuang committed
12
from external_auth.models import ExternalAuthMap
13
from external_auth.djangostore import DjangoOpenIDStore
ichuang committed
14 15

from django.conf import settings
16
from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
ichuang committed
17
from django.contrib.auth.models import User
18 19 20 21
from django.core.urlresolvers import reverse
from django.core.validators import validate_email
from django.core.exceptions import ValidationError

22
if settings.FEATURES.get('AUTH_USE_CAS'):
23 24
    from django_cas.views import login as django_cas_login

25
from student.helpers import get_next_url_for_login_page
26
from student.models import UserProfile
ichuang committed
27

28
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden
29
from django.utils.http import urlquote, is_safe_url
ichuang committed
30
from django.shortcuts import redirect
31 32
from django.utils.translation import ugettext as _

David Baumgold committed
33
from edxmako.shortcuts import render_to_response, render_to_string
ichuang committed
34 35 36 37
try:
    from django.views.decorators.csrf import csrf_exempt
except ImportError:
    from django.contrib.csrf.middleware import csrf_exempt
38
from django.views.decorators.csrf import ensure_csrf_cookie
39

40
import django_openid_auth.views as openid_views
41 42
from django_openid_auth import auth as openid_auth
from openid.consumer.consumer import SUCCESS
ichuang committed
43

44
from openid.server.server import Server, ProtocolError, UntrustedReturnURL
45
from openid.server.trustroot import TrustRoot
46
from openid.extensions import ax, sreg
Diana Huang committed
47
from ratelimitbackend.exceptions import RateLimitException
48

49
import student.views
50
from xmodule.modulestore.django import modulestore
51
from opaque_keys.edx.locations import SlashSeparatedCourseKey
ichuang committed
52

53
log = logging.getLogger("edx.external_auth")
54
AUDIT_LOG = logging.getLogger("audit")
ichuang committed
55

56 57
SHIBBOLETH_DOMAIN_PREFIX = settings.SHIBBOLETH_DOMAIN_PREFIX
OPENID_DOMAIN_PREFIX = settings.OPENID_DOMAIN_PREFIX
58

59 60 61 62 63
# -----------------------------------------------------------------------------
# OpenID Common
# -----------------------------------------------------------------------------


ichuang committed
64
@csrf_exempt
65 66 67 68 69
def default_render_failure(request,
                           message,
                           status=403,
                           template_name='extauth_failure.html',
                           exception=None):
70 71 72 73
    """Render an Openid error page to the user"""

    log.debug("In openid_failure " + message)

74 75
    data = render_to_string(template_name,
                            dict(message=message, exception=exception))
76

ichuang committed
77 78
    return HttpResponse(data, status=status)

79 80 81 82

# -----------------------------------------------------------------------------
# OpenID Authentication
# -----------------------------------------------------------------------------
83

84

85
def generate_password(length=12, chars=string.letters + string.digits):
86
    """Generate internal password for externally authenticated user"""
87
    choice = random.SystemRandom().choice
88
    return ''.join([choice(chars) for _i in range(length)])
89

90

ichuang committed
91
@csrf_exempt
92 93 94
def openid_login_complete(request,
                          redirect_field_name=REDIRECT_FIELD_NAME,
                          render_failure=None):
ichuang committed
95 96
    """Complete the openid login process"""

97
    render_failure = (render_failure or default_render_failure)
98

99
    openid_response = openid_views.parse_openid_response(request)
ichuang committed
100
    if not openid_response:
101 102
        return render_failure(request,
                              'This is an OpenID relying party endpoint.')
ichuang committed
103 104 105

    if openid_response.status == SUCCESS:
        external_id = openid_response.identity_url
106
        oid_backend = openid_auth.OpenIDBackend()
ichuang committed
107
        details = oid_backend._extract_user_details(openid_response)
ichuang committed
108

109
        log.debug('openid success, details=%s', details)
ichuang committed
110

111
        url = getattr(settings, 'OPENID_SSO_SERVER_URL', None)
112
        external_domain = "{0}{1}".format(OPENID_DOMAIN_PREFIX, url)
113 114 115
        fullname = '%s %s' % (details.get('first_name', ''),
                              details.get('last_name', ''))

116 117 118 119 120 121
        return _external_login_or_signup(
            request,
            external_id,
            external_domain,
            details,
            details.get('email', ''),
122 123
            fullname,
            retfun=functools.partial(redirect, get_next_url_for_login_page(request)),
124
        )
125

ichuang committed
126
    return render_failure(request, 'Openid failure')
127

128

129 130 131 132 133 134 135
def _external_login_or_signup(request,
                              external_id,
                              external_domain,
                              credentials,
                              email,
                              fullname,
                              retfun=None):
136
    """Generic external auth login or signup"""
137 138
    # see if we have a map from this external_id to an edX username
    try:
139
        eamap = ExternalAuthMap.objects.get(external_id=external_id,
140
                                            external_domain=external_domain)
141
        log.debug(u'Found eamap=%s', eamap)
142 143
    except ExternalAuthMap.DoesNotExist:
        # go render form for creating edX user
144 145
        eamap = ExternalAuthMap(external_id=external_id,
                                external_domain=external_domain,
146
                                external_credentials=json.dumps(credentials))
147 148
        eamap.external_email = email
        eamap.external_name = fullname
149
        eamap.internal_password = generate_password()
150
        log.debug(u'Created eamap=%s', eamap)
151 152
        eamap.save()

153
    log.info(u"External_Auth login_or_signup for %s : %s : %s : %s", external_domain, external_id, email, fullname)
154
    uses_shibboleth = settings.FEATURES.get('AUTH_USE_SHIB') and external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX)
155
    uses_certs = settings.FEATURES.get('AUTH_USE_CERTIFICATES')
156 157
    internal_user = eamap.user
    if internal_user is None:
158
        if uses_shibboleth:
159 160 161 162
            # If we are using shib, try to link accounts
            # For Stanford shib, the email the idp returns is actually under the control of the user.
            # Since the id the idps return is not user-editable, and is of the from "username@stanford.edu",
            # use the id to link accounts instead.
163
            try:
164
                link_user = User.objects.get(email=eamap.external_id)
165 166 167 168 169
                if not ExternalAuthMap.objects.filter(user=link_user).exists():
                    # if there's no pre-existing linked eamap, we link the user
                    eamap.user = link_user
                    eamap.save()
                    internal_user = link_user
170
                    log.info(u'SHIB: Linking existing account for %s', eamap.external_id)
171 172
                    # now pass through to log in
                else:
173 174
                    # otherwise, there must have been an error, b/c we've already linked a user with these external
                    # creds
175 176 177 178 179 180 181
                    failure_msg = _(
                        "You have already created an account using "
                        "an external login like WebAuth or Shibboleth. "
                        "Please contact {tech_support_email} for support."
                    ).format(
                        tech_support_email=settings.TECH_SUPPORT_EMAIL,
                    )
182
                    return default_render_failure(request, failure_msg)
183
            except User.DoesNotExist:
184
                log.info(u'SHIB: No user for %s yet, doing signup', eamap.external_email)
ichuang committed
185
                return _signup(request, eamap, retfun)
186
        else:
187
            log.info(u'No user for %s yet. doing signup', eamap.external_email)
188
            return _signup(request, eamap, retfun)
189 190

    # We trust shib's authentication, so no need to authenticate using the password again
191 192
    uname = internal_user.username
    if uses_shibboleth:
193 194 195 196 197
        user = internal_user
        # Assuming this 'AUTHENTICATION_BACKENDS' is set in settings, which I think is safe
        if settings.AUTHENTICATION_BACKENDS:
            auth_backend = settings.AUTHENTICATION_BACKENDS[0]
        else:
198
            auth_backend = 'ratelimitbackend.backends.RateLimitModelBackend'
199
        user.backend = auth_backend
200
        if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
201
            AUDIT_LOG.info(u'Linked user.id: {0} logged in via Shibboleth'.format(user.id))
202
        else:
203
            AUDIT_LOG.info(u'Linked user "{0}" logged in via Shibboleth'.format(user.email))
204 205 206
    elif uses_certs:
        # Certificates are trusted, so just link the user and log the action
        user = internal_user
207
        user.backend = 'ratelimitbackend.backends.RateLimitModelBackend'
208
        if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
209
            AUDIT_LOG.info(u'Linked user_id {0} logged in via SSL certificate'.format(user.id))
210
        else:
211
            AUDIT_LOG.info(u'Linked user "{0}" logged in via SSL certificate'.format(user.email))
212
    else:
Diana Huang committed
213
        user = authenticate(username=uname, password=eamap.internal_password, request=request)
214
    if user is None:
215
        # we want to log the failure, but don't want to log the password attempted:
216
        if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
217
            AUDIT_LOG.warning(u'External Auth Login failed')
218
        else:
219
            AUDIT_LOG.warning(u'External Auth Login failed for "{0}"'.format(uname))
220
        return _signup(request, eamap, retfun)
221 222

    if not user.is_active:
223 224 225 226 227 228
        if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
            # if BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH, we trust external auth and activate any users
            # that aren't already active
            user.is_active = True
            user.save()
            if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
229
                AUDIT_LOG.info(u'Activating user {0} due to external auth'.format(user.id))
230
            else:
231
                AUDIT_LOG.info(u'Activating user "{0}" due to external auth'.format(uname))
232
        else:
233
            if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
234
                AUDIT_LOG.warning(u'User {0} is not active after external login'.format(user.id))
235
            else:
236
                AUDIT_LOG.warning(u'User "{0}" is not active after external login'.format(uname))
237 238 239
            # TODO: improve error page
            msg = 'Account not yet activated: please look for link in your email'
            return default_render_failure(request, msg)
240

241 242
    login(request, user)
    request.session.set_expiry(0)
243

244
    if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
245
        AUDIT_LOG.info(u"Login success - user.id: {0}".format(user.id))
246
    else:
247
        AUDIT_LOG.info(u"Login success - {0} ({1})".format(user.username, user.email))
248 249 250
    if retfun is None:
        return redirect('/')
    return retfun()
251 252


253 254 255 256
def _flatten_to_ascii(txt):
    """
    Flattens possibly unicode txt to ascii (django username limitation)
    @param name:
257
    @return: the flattened txt (in the same type as was originally passed in)
258
    """
259 260 261 262 263
    if isinstance(txt, str):
        txt = txt.decode('utf-8')
        return unicodedata.normalize('NFKD', txt).encode('ASCII', 'ignore')
    else:
        return unicode(unicodedata.normalize('NFKD', txt).encode('ASCII', 'ignore'))
264

265

ichuang committed
266
@ensure_csrf_cookie
267
def _signup(request, eamap, retfun=None):
ichuang committed
268 269 270 271 272 273
    """
    Present form to complete for signup via external authentication.
    Even though the user has external credentials, he/she still needs
    to create an account on the edX system, and fill in the user
    registration form.

274
    eamap is an ExternalAuthMap object, specifying the external user
ichuang committed
275
    for which to complete the signup.
276

277 278
    retfun is a function to execute for the return value, if immediate
    signup is used.  That allows @ssl_login_shortcut() to work.
ichuang committed
279
    """
280 281 282
    # save this for use by student.views.create_account
    request.session['ExternalAuthMap'] = eamap

283
    if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP', ''):
284 285
        # do signin immediately, by calling create_account, instead of asking
        # student to fill in form.  MIT students already have information filed.
286 287 288 289 290
        username = eamap.external_email.split('@', 1)[0]
        username = username.replace('.', '_')
        post_vars = dict(username=username,
                         honor_code=u'true',
                         terms_of_service=u'true')
291
        log.info(u'doing immediate signup for %s, params=%s', username, post_vars)
292
        student.views.create_account(request, post_vars)
293 294 295 296 297
        # should check return content for successful completion before
        if retfun is not None:
            return retfun()
        else:
            return redirect('/')
298

299 300
    # default conjoin name, no spaces, flattened to ascii b/c django can't handle unicode usernames, sadly
    # but this only affects username, not fullname
301
    username = re.sub(r'\s', '', _flatten_to_ascii(eamap.external_name), flags=re.UNICODE)
302

ichuang committed
303
    context = {'has_extauth_info': True,
304
               'show_signup_immediately': True,
305
               'extauth_domain': eamap.external_domain,
306
               'extauth_id': eamap.external_id,
ichuang committed
307
               'extauth_email': eamap.external_email,
308
               'extauth_username': username,
ichuang committed
309
               'extauth_name': eamap.external_name,
310
               'ask_for_tos': True,
ichuang committed
311
               }
312

313 314
    # Some openEdX instances can't have terms of service for shib users, like
    # according to Stanford's Office of General Counsel
315
    uses_shibboleth = (settings.FEATURES.get('AUTH_USE_SHIB') and
316
                       eamap.external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX))
317
    if uses_shibboleth and settings.FEATURES.get('SHIB_DISABLE_TOS'):
318 319
        context['ask_for_tos'] = False

320 321 322 323 324 325 326 327 328 329
    # detect if full name is blank and ask for it from user
    context['ask_for_fullname'] = eamap.external_name.strip() == ''

    # validate provided mail and if it's not valid ask the user
    try:
        validate_email(eamap.external_email)
        context['ask_for_email'] = False
    except ValidationError:
        context['ask_for_email'] = True

330
    log.info(u'EXTAUTH: Doing signup for %s', eamap.external_id)
ichuang committed
331

332
    return student.views.register_user(request, extra_context=context)
333

334

335
# -----------------------------------------------------------------------------
336
# MIT SSL
337 338
# -----------------------------------------------------------------------------

339

340
def _ssl_dn_extract_info(dn_string):
341
    """
342 343 344
    Extract username, email address (may be anyuser@anydomain.com) and
    full name from the SSL DN string.  Return (user,email,fullname) if
    successful, and None otherwise.
345
    """
346
    ss = re.search('/emailAddress=(.*)@([^/]+)', dn_string)
347 348 349 350 351
    if ss:
        user = ss.group(1)
        email = "%s@%s" % (user, ss.group(2))
    else:
        return None
352
    ss = re.search('/CN=([^/]+)/', dn_string)
353 354 355 356 357
    if ss:
        fullname = ss.group(1)
    else:
        return None
    return (user, email, fullname)
Calen Pennington committed
358

359

360
def ssl_get_cert_from_request(request):
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385
    """
    Extract user information from certificate, if it exists, returning (user, email, fullname).
    Else return None.
    """
    certkey = "SSL_CLIENT_S_DN"  # specify the request.META field to use

    cert = request.META.get(certkey, '')
    if not cert:
        cert = request.META.get('HTTP_' + certkey, '')
    if not cert:
        try:
            # try the direct apache2 SSL key
            cert = request._req.subprocess_env.get(certkey, '')
        except Exception:
            return ''

    return cert


def ssl_login_shortcut(fn):
    """
    Python function decorator for login procedures, to allow direct login
    based on existing ExternalAuth record and MIT ssl certificate.
    """
    def wrapped(*args, **kwargs):
386 387 388 389 390 391
        """
        This manages the function wrapping, by determining whether to inject
        the _external signup or just continuing to the internal function
        call.
        """

392
        if not settings.FEATURES['AUTH_USE_CERTIFICATES']:
393 394
            return fn(*args, **kwargs)
        request = args[0]
395

396
        if request.user and request.user.is_authenticated():  # don't re-authenticate
397 398
            return fn(*args, **kwargs)

399
        cert = ssl_get_cert_from_request(request)
400 401 402
        if not cert:		# no certificate information - show normal login window
            return fn(*args, **kwargs)

403
        def retfun():
404
            """Wrap function again for call by _external_login_or_signup"""
405 406
            return fn(*args, **kwargs)

407 408 409 410 411 412 413
        (_user, email, fullname) = _ssl_dn_extract_info(cert)
        return _external_login_or_signup(
            request,
            external_id=email,
            external_domain="ssl:MIT",
            credentials=cert,
            email=email,
414 415
            fullname=fullname,
            retfun=retfun
416
        )
417
    return wrapped
418

419

420
@csrf_exempt
421
def ssl_login(request):
422
    """
423
    This is called by branding.views.index when
424
    FEATURES['AUTH_USE_CERTIFICATES'] = True
425

426 427 428
    Used for MIT user authentication.  This presumes the web server
    (nginx) has been configured to require specific client
    certificates.
429

430 431 432 433
    If the incoming protocol is HTTPS (SSL) then authenticate via
    client certificate.  The certificate provides user email and
    fullname; this populates the ExternalAuthMap.  The user is
    nevertheless still asked to complete the edX signup.
434

435
    Else continues on with student.views.index, and no authentication.
436
    """
437
    # Just to make sure we're calling this only at MIT:
438
    if not settings.FEATURES['AUTH_USE_CERTIFICATES']:
439 440
        return HttpResponseForbidden()

441
    cert = ssl_get_cert_from_request(request)
442

443 444
    if not cert:
        # no certificate information - go onward to main index
445
        return student.views.index(request)
446

447
    (_user, email, fullname) = _ssl_dn_extract_info(cert)
448

449
    redirect_to = get_next_url_for_login_page(request)
450
    retfun = functools.partial(redirect, redirect_to)
451 452 453 454 455 456 457 458 459
    return _external_login_or_signup(
        request,
        external_id=email,
        external_domain="ssl:MIT",
        credentials=cert,
        email=email,
        fullname=fullname,
        retfun=retfun
    )
460

461

462
# -----------------------------------------------------------------------------
463 464 465 466 467 468 469
# CAS (Central Authentication Service)
# -----------------------------------------------------------------------------
def cas_login(request, next_page=None, required=False):
    """
        Uses django_cas for authentication.
        CAS is a common authentcation method pioneered by Yale.
        See http://en.wikipedia.org/wiki/Central_Authentication_Service
470

471 472 473 474 475 476 477
        Does normal CAS login then generates user_profile if nonexistent,
        and if login was successful.  We assume that user details are
        maintained by the central service, and thus an empty user profile
        is appropriate.
    """

    ret = django_cas_login(request, next_page, required)
478

479 480 481
    if request.user.is_authenticated():
        user = request.user
        if not UserProfile.objects.filter(user=user):
482 483
            user_profile = UserProfile(name=user.username, user=user)
            user_profile.save()
484 485 486 487 488

    return ret


# -----------------------------------------------------------------------------
489 490
# Shibboleth (Stanford and others.  Uses *Apache* environment variables)
# -----------------------------------------------------------------------------
491
def shib_login(request):
492 493 494 495 496 497 498 499 500 501 502 503 504
    """
        Uses Apache's REMOTE_USER environment variable as the external id.
        This in turn typically uses EduPersonPrincipalName
        http://www.incommonfederation.org/attributesummary.html#eduPersonPrincipal
        but the configuration is in the shibboleth software.
    """
    shib_error_msg = _(dedent(
        """
        Your university identity server did not return your ID information to us.
        Please try logging in again.  (You may need to restart your browser.)
        """))

    if not request.META.get('REMOTE_USER'):
505
        log.error(u"SHIB: no REMOTE_USER found in request.META")
506 507
        return default_render_failure(request, shib_error_msg)
    elif not request.META.get('Shib-Identity-Provider'):
508
        log.error(u"SHIB: no Shib-Identity-Provider in request.META")
509 510
        return default_render_failure(request, shib_error_msg)
    else:
511
        # If we get here, the user has authenticated properly
512
        shib = {attr: request.META.get(attr, '').decode('utf-8')
513
                for attr in ['REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider', 'displayName']}
514

515 516 517
        # Clean up first name, last name, and email address
        # TODO: Make this less hardcoded re: format, but split will work
        # even if ";" is not present, since we are accessing 1st element
518 519
        shib['sn'] = shib['sn'].split(";")[0].strip().capitalize()
        shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize()
520

521
    # TODO: should we be logging creds here, at info level?
522
    log.info(u"SHIB creds returned: %r", shib)
523

524 525
    fullname = shib['displayName'] if shib['displayName'] else u'%s %s' % (shib['givenName'], shib['sn'])

526 527
    redirect_to = get_next_url_for_login_page(request)
    retfun = functools.partial(_safe_postlogin_redirect, redirect_to, request.get_host())
528

529 530 531 532 533 534
    return _external_login_or_signup(
        request,
        external_id=shib['REMOTE_USER'],
        external_domain=SHIBBOLETH_DOMAIN_PREFIX + shib['Shib-Identity-Provider'],
        credentials=shib,
        email=shib['mail'],
535 536
        fullname=fullname,
        retfun=retfun
537
    )
538 539


540 541 542 543 544 545 546 547 548 549 550 551 552 553
def _safe_postlogin_redirect(redirect_to, safehost, default_redirect='/'):
    """
    If redirect_to param is safe (not off this host), then perform the redirect.
    Otherwise just redirect to '/'.
    Basically copied from django.contrib.auth.views.login
    @param redirect_to: user-supplied redirect url
    @param safehost: which host is safe to redirect to
    @return: an HttpResponseRedirect
    """
    if is_safe_url(url=redirect_to, host=safehost):
        return redirect(redirect_to)
    return redirect(default_redirect)


554 555 556 557 558
def course_specific_login(request, course_id):
    """
       Dispatcher function for selecting the specific login method
       required by the course
    """
559 560
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    course = modulestore().get_course(course_key)
561
    if not course:
562
        # couldn't find the course, will just return vanilla signin page
563
        return redirect_with_get('signin_user', request.GET)
564

565
    # now the dispatching conditionals.  Only shib for now
566 567 568 569 570
    if (
        settings.FEATURES.get('AUTH_USE_SHIB') and
        course.enrollment_domain and
        course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX)
    ):
571
        return redirect_with_get('shib-login', request.GET)
572

573
    # Default fallthrough to normal signin page
574
    return redirect_with_get('signin_user', request.GET)
575 576 577 578 579 580 581


def course_specific_register(request, course_id):
    """
        Dispatcher function for selecting the specific registration method
        required by the course
    """
582 583
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    course = modulestore().get_course(course_key)
584 585

    if not course:
586
        # couldn't find the course, will just return vanilla registration page
587
        return redirect_with_get('register_user', request.GET)
588

589
    # now the dispatching conditionals.  Only shib for now
590 591 592 593 594
    if (
        settings.FEATURES.get('AUTH_USE_SHIB') and
        course.enrollment_domain and
        course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX)
    ):
595
        # shib-login takes care of both registration and login flows
596
        return redirect_with_get('shib-login', request.GET)
597

598
    # Default fallthrough to normal registration page
599
    return redirect_with_get('register_user', request.GET)
600 601


602
def redirect_with_get(view_name, get_querydict, do_reverse=True):
603
    """
604
        Helper function to carry over get parameters across redirects
605
        Using urlencode(safe='/') because the @login_required decorator generates 'next' queryparams with '/' unencoded
606
    """
607 608 609 610
    if do_reverse:
        url = reverse(view_name)
    else:
        url = view_name
611
    if get_querydict:
612
        return redirect("%s?%s" % (url, get_querydict.urlencode(safe='/')))
613 614 615 616
    return redirect(view_name)


# -----------------------------------------------------------------------------
617 618 619 620
# OpenID Provider
# -----------------------------------------------------------------------------


621 622 623 624
def get_xrds_url(resource, request):
    """
    Return the XRDS url for a resource
    """
625
    host = request.get_host()
626 627

    location = host + '/openid/provider/' + resource + '/'
628 629

    if request.is_secure():
630
        return 'https://' + location
631
    else:
632
        return 'http://' + location
633

634

635
def add_openid_simple_registration(request, response, data):
636 637 638 639 640 641 642 643 644 645 646
    sreg_data = {}
    sreg_request = sreg.SRegRequest.fromOpenIDRequest(request)
    sreg_fields = sreg_request.allRequestedFields()

    # if consumer requested simple registration fields, add them
    if sreg_fields:
        for field in sreg_fields:
            if field == 'email' and 'email' in data:
                sreg_data['email'] = data['email']
            elif field == 'fullname' and 'fullname' in data:
                sreg_data['fullname'] = data['fullname']
647 648
            elif field == 'nickname' and 'nickname' in data:
                sreg_data['nickname'] = data['nickname']
649 650

        # construct sreg response
651 652
        sreg_response = sreg.SRegResponse.extractResponse(sreg_request,
                                                          sreg_data)
653 654
        sreg_response.toMessage(response.fields)

655 656

def add_openid_attribute_exchange(request, response, data):
657 658 659
    try:
        ax_request = ax.FetchRequest.fromOpenIDRequest(request)
    except ax.AXError:
660
        #  not using OpenID attribute exchange extension
661 662 663 664 665 666 667
        pass
    else:
        ax_response = ax.FetchResponse()

        # if consumer requested attribute exchange fields, add them
        if ax_request and ax_request.requested_attributes:
            for type_uri in ax_request.requested_attributes.iterkeys():
668 669 670 671 672 673
                email_schema = 'http://axschema.org/contact/email'
                name_schema = 'http://axschema.org/namePerson'
                if type_uri == email_schema and 'email' in data:
                    ax_response.addValue(email_schema, data['email'])
                elif type_uri == name_schema and 'fullname' in data:
                    ax_response.addValue(name_schema, data['fullname'])
674 675 676 677

            # construct ax response
            ax_response.toMessage(response.fields)

678 679 680 681 682 683 684 685 686

def provider_respond(server, request, response, data):
    """
    Respond to an OpenID request
    """
    # get and add extensions
    add_openid_simple_registration(request, response, data)
    add_openid_attribute_exchange(request, response, data)

687 688 689 690 691 692 693 694 695 696 697
    # create http response from OpenID response
    webresponse = server.encodeResponse(response)
    http_response = HttpResponse(webresponse.body)
    http_response.status_code = webresponse.code

    # add OpenID headers to response
    for k, v in webresponse.headers.iteritems():
        http_response[k] = v

    return http_response

698 699 700 701 702 703

def validate_trust_root(openid_request):
    """
    Only allow OpenID requests from valid trust roots
    """

704 705
    trusted_roots = getattr(settings, 'OPENID_PROVIDER_TRUSTED_ROOT', None)

706
    if not trusted_roots:
707 708 709
        # not using trusted roots
        return True

710
    # don't allow empty trust roots
711
    if (not hasattr(openid_request, 'trust_root') or
712 713
            not openid_request.trust_root):
        log.error('no trust_root')
714
        return False
715 716

    # ensure trust root parses cleanly (one wildcard, of form *.foo.com, etc.)
717
    trust_root = TrustRoot.parse(openid_request.trust_root)
718 719
    if not trust_root:
        log.error('invalid trust_root')
720
        return False
721 722

    # don't allow empty return tos
723
    if (not hasattr(openid_request, 'return_to') or
724 725
            not openid_request.return_to):
        log.error('empty return_to')
726
        return False
727 728 729

    # ensure return to is within trust root
    if not trust_root.validateURL(openid_request.return_to):
730
        log.error('invalid return_to')
731 732 733 734
        return False

    # check that the root matches the ones we trust
    if not any(r for r in trusted_roots if fnmatch.fnmatch(trust_root, r)):
735
        log.error('non-trusted root')
736
        return False
737

738
    return True
739 740


741 742 743 744 745 746
@csrf_exempt
def provider_login(request):
    """
    OpenID login endpoint
    """

747
    # make and validate endpoint
748
    endpoint = get_xrds_url('login', request)
749 750 751 752
    if not endpoint:
        return default_render_failure(request, "Invalid OpenID request")

    # initialize store and server
753
    store = DjangoOpenIDStore()
754 755
    server = Server(store, endpoint)

756 757 758
    # first check to see if the request is an OpenID request.
    # If so, the client will have specified an 'openid.mode' as part
    # of the request.
759
    querydict = dict(request.REQUEST.items())
760 761 762
    error = False
    if 'openid.mode' in request.GET or 'openid.mode' in request.POST:
        # decode request
763 764
        try:
            openid_request = server.decodeRequest(querydict)
765
        except (UntrustedReturnURL, ProtocolError):
766
            openid_request = None
767 768 769

        if not openid_request:
            return default_render_failure(request, "Invalid OpenID request")
770

771
        # don't allow invalid and non-trusted trust roots
772
        if not validate_trust_root(openid_request):
773
            return default_render_failure(request, "Invalid OpenID trust root")
774

775 776
        # checkid_immediate not supported, require user interaction
        if openid_request.mode == 'checkid_immediate':
777 778
            return provider_respond(server, openid_request,
                                    openid_request.answer(False), {})
779 780

        # checkid_setup, so display login page
Calen Pennington committed
781
        # (by falling through to the provider_login at the
782
        # bottom of this method).
783 784 785
        elif openid_request.mode == 'checkid_setup':
            if openid_request.idSelect():
                # remember request and original path
786
                request.session['openid_setup'] = {
787
                    'request': openid_request,
788 789
                    'url': request.get_full_path(),
                    'post_params': request.POST,
790 791 792 793 794 795 796 797 798
                }

                # user failed login on previous attempt
                if 'openid_error' in request.session:
                    error = True
                    del request.session['openid_error']

        # OpenID response
        else:
799 800
            return provider_respond(server, openid_request,
                                    server.handleRequest(openid_request), {})
801

802 803
    # handle login redirection:  these are also sent to this view function,
    # but are distinguished by lacking the openid mode.  We also know that
Calen Pennington committed
804
    # they are posts, because they come from the popup
805
    elif request.method == 'POST' and 'openid_setup' in request.session:
806
        # get OpenID request from session
807 808 809
        openid_setup = request.session['openid_setup']
        openid_request = openid_setup['request']
        openid_request_url = openid_setup['url']
810 811 812 813 814 815 816 817 818 819 820 821 822 823
        post_params = openid_setup['post_params']
        # We need to preserve the parameters, and the easiest way to do this is
        # through the URL
        url_post_params = {
            param: post_params[param] for param in post_params if param.startswith('openid')
        }

        encoded_params = urllib.urlencode(url_post_params)

        if '?' not in openid_request_url:
            openid_request_url = openid_request_url + '?' + encoded_params
        else:
            openid_request_url = openid_request_url + '&' + encoded_params

824
        del request.session['openid_setup']
825

826
        # don't allow invalid trust roots
827
        if not validate_trust_root(openid_request):
828
            return default_render_failure(request, "Invalid OpenID trust root")
829

830
        # check if user with given email exists
Calen Pennington committed
831
        # Failure is redirected to this method (by using the original URL),
832
        # which will bring up the login dialog.
833
        email = request.POST.get('email', None)
834 835 836 837
        try:
            user = User.objects.get(email=email)
        except User.DoesNotExist:
            request.session['openid_error'] = True
838
            if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
Jacek Bzdak committed
839
                AUDIT_LOG.warning(u"OpenID login failed - Unknown user email")
840
            else:
Jacek Bzdak committed
841
                msg = u"OpenID login failed - Unknown user email: {0}".format(email)
842
                AUDIT_LOG.warning(msg)
843
            return HttpResponseRedirect(openid_request_url)
844

845 846
        # attempt to authenticate user (but not actually log them in...)
        # Failure is again redirected to the login dialog.
847
        username = user.username
848
        password = request.POST.get('password', None)
Diana Huang committed
849 850 851
        try:
            user = authenticate(username=username, password=password, request=request)
        except RateLimitException:
Jacek Bzdak committed
852
            AUDIT_LOG.warning(u'OpenID - Too many failed login attempts.')
Diana Huang committed
853 854
            return HttpResponseRedirect(openid_request_url)

855 856
        if user is None:
            request.session['openid_error'] = True
857
            if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
Jacek Bzdak committed
858
                AUDIT_LOG.warning(u"OpenID login failed - invalid password")
859
            else:
Jacek Bzdak committed
860 861
                AUDIT_LOG.warning(
                    u"OpenID login failed - password for %s is invalid", email)
862
            return HttpResponseRedirect(openid_request_url)
863

864 865
        # authentication succeeded, so fetch user information
        # that was requested
866 867 868 869 870
        if user is not None and user.is_active:
            # remove error from session since login succeeded
            if 'openid_error' in request.session:
                del request.session['openid_error']

871
            if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
Jacek Bzdak committed
872
                AUDIT_LOG.info(u"OpenID login success - user.id: %s", user.id)
873
            else:
Jacek Bzdak committed
874 875
                AUDIT_LOG.info(
                    u"OpenID login success - %s (%s)", user.username, user.email)
876
            # redirect user to return_to location
877
            url = endpoint + urlquote(user.username)
878
            response = openid_request.answer(True, None, url)
879

Calen Pennington committed
880
            # Note too that this is hardcoded, and not really responding to
881
            # the extensions that were registered in the first place.
882 883
            results = {
                'nickname': user.username,
884
                'email': user.email,
885
                'fullname': user.profile.name,
886
            }
Calen Pennington committed
887

888
            # the request succeeded:
889
            return provider_respond(server, openid_request, response, results)
890

891
        # the account is not active, so redirect back to the login page:
892
        request.session['openid_error'] = True
893
        if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
Jacek Bzdak committed
894 895
            AUDIT_LOG.warning(
                u"Login failed - Account not active for user.id %s", user.id)
896
        else:
Jacek Bzdak committed
897 898
            AUDIT_LOG.warning(
                u"Login failed - Account not active for user %s", username)
899
        return HttpResponseRedirect(openid_request_url)
900

901 902 903
    # determine consumer domain if applicable
    return_to = ''
    if 'openid.return_to' in request.REQUEST:
904 905
        return_to = request.REQUEST['openid.return_to']
        matches = re.match(r'\w+:\/\/([\w\.-]+)', return_to)
906 907
        return_to = matches.group(1)

908 909
    # display login page
    response = render_to_response('provider_login.html', {
910 911
        'error': error,
        'return_to': return_to
912 913
    })

914
    # add custom XRDS header necessary for discovery process
915 916 917
    response['X-XRDS-Location'] = get_xrds_url('xrds', request)
    return response

918

919 920 921 922 923
def provider_identity(request):
    """
    XRDS for identity discovery
    """

924 925
    response = render_to_response('identity.xml',
                                  {'url': get_xrds_url('login', request)},
926
                                  content_type='text/xml')
927 928 929 930 931

    # custom XRDS header necessary for discovery process
    response['X-XRDS-Location'] = get_xrds_url('identity', request)
    return response

932

933 934 935 936 937
def provider_xrds(request):
    """
    XRDS for endpoint discovery
    """

938 939
    response = render_to_response('xrds.xml',
                                  {'url': get_xrds_url('login', request)},
940
                                  content_type='text/xml')
941 942 943 944

    # custom XRDS header necessary for discovery process
    response['X-XRDS-Location'] = get_xrds_url('xrds', request)
    return response