views.py 13.7 KB
Newer Older
1 2
""" Views for a student's account information. """

3
import logging
4
import json
5 6
from ipware.ip import get_ip

7
from django.conf import settings
8
from django.contrib import messages
Usman Khalid committed
9
from django.contrib.auth.decorators import login_required
10
from django.http import (
11
    HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
12
)
13
from django.shortcuts import redirect
14
from django.http import HttpRequest
Usman Khalid committed
15
from django_countries import countries
16
from django.core.urlresolvers import reverse, resolve
17
from django.utils.translation import ugettext as _
18 19
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods
20

Usman Khalid committed
21
from lang_pref.api import released_languages
22
from opaque_keys import InvalidKeyError
Usman Khalid committed
23
from opaque_keys.edx.keys import CourseKey
24
from edxmako.shortcuts import render_to_response
25
from microsite_configuration import microsite
Usman Khalid committed
26

27
from embargo import api as embargo_api
28 29 30 31
from external_auth.login_and_register import (
    login as external_auth_login,
    register as external_auth_register
)
Usman Khalid committed
32
from student.models import UserProfile
33 34 35 36
from student.views import (
    signin_user as old_login_view,
    register_user as old_register_view
)
Usman Khalid committed
37 38
from student_account.helpers import auth_pipeline_urls
import third_party_auth
39
from third_party_auth import pipeline
Usman Khalid committed
40
from util.bad_request_rate_limiter import BadRequestRateLimiter
41

42 43
from openedx.core.djangoapps.user_api.accounts.api import request_password_change
from openedx.core.djangoapps.user_api.errors import UserNotFound
44 45
from util.bad_request_rate_limiter import BadRequestRateLimiter

46 47
from student_account.helpers import auth_pipeline_urls

48 49

AUDIT_LOG = logging.getLogger("audit")
50 51


52
@require_http_methods(['GET'])
53
@ensure_csrf_cookie
54 55 56 57 58 59 60
def login_and_registration_form(request, initial_mode="login"):
    """Render the combined login/registration form, defaulting to login

    This relies on the JS to asynchronously load the actual form from
    the user_api.

    Keyword Args:
61
        initial_mode (string): Either "login" or "register".
62 63

    """
64 65 66 67
    # If we're already logged in, redirect to the dashboard
    if request.user.is_authenticated():
        return redirect(reverse('dashboard'))

68 69 70
    # Retrieve the form descriptions from the user API
    form_descriptions = _get_form_descriptions(request)

71 72 73 74 75 76 77 78 79 80 81 82 83
    # If this is a microsite, revert to the old login/registration pages.
    # We need to do this for now to support existing themes.
    if microsite.is_request_in_microsite():
        if initial_mode == "login":
            return old_login_view(request)
        elif initial_mode == "register":
            return old_register_view(request)

    # Allow external auth to intercept and handle the request
    ext_auth_response = _external_auth_intercept(request, initial_mode)
    if ext_auth_response is not None:
        return ext_auth_response

84
    # Otherwise, render the combined login/registration page
85 86 87
    context = {
        'disable_courseware_js': True,
        'initial_mode': initial_mode,
88
        'third_party_auth': json.dumps(_third_party_auth_context(request)),
Renzo Lucioni committed
89
        'platform_name': settings.PLATFORM_NAME,
90 91 92 93 94 95 96 97 98
        'responsive': True,

        # Include form descriptions retrieved from the user API.
        # We could have the JS client make these requests directly,
        # but we include them in the initial page load to avoid
        # the additional round-trip to the server.
        'login_form_desc': form_descriptions['login'],
        'registration_form_desc': form_descriptions['registration'],
        'password_reset_form_desc': form_descriptions['password_reset'],
99 100 101 102

        # We need to pass these parameters so that the header's
        # "Sign In" button preserves the querystring params.
        'enrollment_action': request.GET.get('enrollment_action'),
103 104
        'course_id': request.GET.get('course_id'),
        'course_mode': request.GET.get('course_mode'),
105 106 107 108 109
    }

    return render_to_response('student_account/login_and_register.html', context)


110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146
@require_http_methods(['POST'])
def password_change_request_handler(request):
    """Handle password change requests originating from the account page.

    Uses the Account API to email the user a link to the password reset page.

    Note:
        The next step in the password reset process (confirmation) is currently handled
        by student.views.password_reset_confirm_wrapper, a custom wrapper around Django's
        password reset confirmation view.

    Args:
        request (HttpRequest)

    Returns:
        HttpResponse: 200 if the email was sent successfully
        HttpResponse: 400 if there is no 'email' POST parameter, or if no user with
            the provided email exists
        HttpResponse: 403 if the client has been rate limited
        HttpResponse: 405 if using an unsupported HTTP method

    Example usage:

        POST /account/password

    """
    limiter = BadRequestRateLimiter()
    if limiter.is_rate_limit_exceeded(request):
        AUDIT_LOG.warning("Password reset rate limit exceeded")
        return HttpResponseForbidden()

    user = request.user
    # Prefer logged-in user's email
    email = user.email if user.is_authenticated() else request.POST.get('email')

    if email:
        try:
147 148
            request_password_change(email, request.get_host(), request.is_secure())
        except UserNotFound:
149 150 151 152
            AUDIT_LOG.info("Invalid password reset attempt")
            # Increment the rate limit counter
            limiter.tick_bad_request_counter(request)

153
            return HttpResponseBadRequest(_("No user with the provided email address exists."))
154 155 156

        return HttpResponse(status=200)
    else:
157
        return HttpResponseBadRequest(_("No email address provided."))
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175


def _third_party_auth_context(request):
    """Context for third party auth providers and the currently running pipeline.

    Arguments:
        request (HttpRequest): The request, used to determine if a pipeline
            is currently running.

    Returns:
        dict

    """
    context = {
        "currentProvider": None,
        "providers": []
    }

176
    course_id = request.GET.get("course_id")
177
    email_opt_in = request.GET.get('email_opt_in')
178
    redirect_to = request.GET.get("next")
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208

    # Check if the user is trying to enroll in a course
    # that they don't have access to based on country
    # access rules.
    #
    # If so, set the redirect URL to the blocked page.
    # We need to set it here, rather than redirecting
    # from within the pipeline, because a redirect
    # from the pipeline can prevent users
    # from completing the authentication process.
    #
    # Note that we can't check the user's country
    # profile at this point, since the user hasn't
    # authenticated.  If the user ends up being blocked
    # by their country preference, we let them enroll;
    # they'll still be blocked when they try to access
    # the courseware.
    if course_id:
        try:
            course_key = CourseKey.from_string(course_id)
            redirect_url = embargo_api.redirect_if_blocked(
                course_key,
                ip_address=get_ip(request),
                url=request.path
            )
            if redirect_url:
                redirect_to = embargo_api.message_url_path(course_key, "enrollment")
        except InvalidKeyError:
            pass

209
    login_urls = auth_pipeline_urls(
210
        third_party_auth.pipeline.AUTH_ENTRY_LOGIN,
211
        course_id=course_id,
212 213
        email_opt_in=email_opt_in,
        redirect_url=redirect_to
214 215
    )
    register_urls = auth_pipeline_urls(
216
        third_party_auth.pipeline.AUTH_ENTRY_REGISTER,
217
        course_id=course_id,
218 219
        email_opt_in=email_opt_in,
        redirect_url=redirect_to
220 221
    )

222 223 224 225 226
    if third_party_auth.is_enabled():
        context["providers"] = [
            {
                "name": enabled.NAME,
                "iconClass": enabled.ICON_CLASS,
227 228
                "loginUrl": login_urls[enabled.NAME],
                "registerUrl": register_urls[enabled.NAME]
229 230 231 232 233 234 235 236 237 238 239 240
            }
            for enabled in third_party_auth.provider.Registry.enabled()
        ]

        running_pipeline = third_party_auth.pipeline.get(request)
        if running_pipeline is not None:
            current_provider = third_party_auth.provider.Registry.get_by_backend_name(
                running_pipeline.get('backend')
            )
            context["currentProvider"] = current_provider.NAME

    return context
241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288


def _get_form_descriptions(request):
    """Retrieve form descriptions from the user API.

    Arguments:
        request (HttpRequest): The original request, used to retrieve session info.

    Returns:
        dict: Keys are 'login', 'registration', and 'password_reset';
            values are the JSON-serialized form descriptions.

    """
    return {
        'login': _local_server_get('/user_api/v1/account/login_session/', request.session),
        'registration': _local_server_get('/user_api/v1/account/registration/', request.session),
        'password_reset': _local_server_get('/user_api/v1/account/password_reset/', request.session)
    }


def _local_server_get(url, session):
    """Simulate a server-server GET request for an in-process API.

    Arguments:
        url (str): The URL of the request (excluding the protocol and domain)
        session (SessionStore): The session of the original request,
            used to get past the CSRF checks.

    Returns:
        str: The content of the response

    """
    # Since the user API is currently run in-process,
    # we simulate the server-server API call by constructing
    # our own request object.  We don't need to include much
    # information in the request except for the session
    # (to get past through CSRF validation)
    request = HttpRequest()
    request.method = "GET"
    request.session = session

    # Call the Django view function, simulating
    # the server-server API call
    view, args, kwargs = resolve(url)
    response = view(request, *args, **kwargs)

    # Return the content of the response
    return response.content
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305


def _external_auth_intercept(request, mode):
    """Allow external auth to intercept a login/registration request.

    Arguments:
        request (Request): The original request.
        mode (str): Either "login" or "register"

    Returns:
        Response or None

    """
    if mode == "login":
        return external_auth_login(request)
    elif mode == "register":
        return external_auth_register(request)
Usman Khalid committed
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325


@login_required
@require_http_methods(['GET'])
def account_settings(request):
    """Render the current user's account settings page.

    Args:
        request (HttpRequest)

    Returns:
        HttpResponse: 200 if the page was sent successfully
        HttpResponse: 302 if not logged in (redirect to login page)
        HttpResponse: 405 if using an unsupported HTTP method

    Example usage:

        GET /account/settings

    """
326
    return render_to_response('student_account/account_settings.html', account_settings_context(request))
Usman Khalid committed
327 328


329
def account_settings_context(request):
Usman Khalid committed
330 331 332
    """ Context for the account settings page.

    Args:
333
        request: The request object.
Usman Khalid committed
334 335 336 337 338

    Returns:
        dict

    """
339 340
    user = request.user

Usman Khalid committed
341
    country_options = [
342
        (country_code, _(country_name))  # pylint: disable=translation-of-non-string
Usman Khalid committed
343 344 345 346 347 348 349 350
        for country_code, country_name in sorted(
            countries.countries, key=lambda(__, name): unicode(name)
        )
    ]

    year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS]

    context = {
351 352
        'auth': {},
        'duplicate_provider': None,
Usman Khalid committed
353 354 355 356
        'fields': {
            'country': {
                'options': country_options,
            }, 'gender': {
357
                'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES],  # pylint: disable=translation-of-non-string
Usman Khalid committed
358 359 360
            }, 'language': {
                'options': released_languages(),
            }, 'level_of_education': {
361
                'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES],  # pylint: disable=translation-of-non-string
Usman Khalid committed
362 363 364 365 366 367 368
            }, 'password': {
                'url': reverse('password_reset'),
            }, 'year_of_birth': {
                'options': year_of_birth_options,
            }, 'preferred_language': {
                'options': settings.ALL_LANGUAGES,
            }
369 370 371 372
        },
        'platform_name': settings.PLATFORM_NAME,
        'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}),
        'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}),
Usman Khalid committed
373 374
    }

375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397
    if third_party_auth.is_enabled():
        # If the account on the third party provider is already connected with another edX account,
        # we display a message to the user.
        context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request))

        auth_states = pipeline.get_provider_user_states(user)

        context['auth']['providers'] = [{
            'name': state.provider.NAME,  # The name of the provider e.g. Facebook
            'connected': state.has_account,  # Whether the user's edX account is connected with the provider.
            # If the user is not connected, they should be directed to this page to authenticate
            # with the particular provider.
            'connect_url': pipeline.get_login_url(
                state.provider.NAME,
                pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS,
                # The url the user should be directed to after the auth process has completed.
                redirect_url=reverse('account_settings'),
            ),
            # If the user is connected, sending a POST request to this url removes the connection
            # information for this provider from their edX account.
            'disconnect_url': pipeline.get_disconnect_url(state.provider.NAME),
        } for state in auth_states]

Usman Khalid committed
398
    return context