diff --git a/openedx/core/djangoapps/password_policy/hibp.py b/openedx/core/djangoapps/password_policy/hibp.py new file mode 100644 index 0000000000..71db66fbad --- /dev/null +++ b/openedx/core/djangoapps/password_policy/hibp.py @@ -0,0 +1,60 @@ +""" +Wrapper to use pwnedpassword Service +""" + + +import logging + +import requests +from requests.exceptions import ReadTimeout +from rest_framework.status import HTTP_408_REQUEST_TIMEOUT + +from openedx.core.djangoapps.user_authn.config.waffle import ENABLE_PWNED_PASSWORD_API + +log = logging.getLogger(__name__) + + +def convert_password_tuple(value): + """ + a conversion function used to convert a string to a tuple + """ + signature, count = value.split(":") + return (signature, int(count)) + + +class PwnedPasswordsAPI: + """ + WrapperClass on pwned password service + to fetch similar password signatures + along with their count + """ + API_URL = "https://api.pwnedpasswords.com" + + @staticmethod + def range(password): + """ + Returns a dict containing hashed password signatures along with their count + + **Argument(s): + password: a sha-1-hashed string against which pwnedservice is invoked + + **Returns: + { + "7ecd77ecd7": 341, + "7ecd77ecd77ecd7": 12, + } + """ + range_url = PwnedPasswordsAPI.API_URL + '/range/{}'.format(password[:5]) + + if ENABLE_PWNED_PASSWORD_API.is_enabled(): + try: + response = requests.get(range_url, timeout=5) + entries = dict(map(convert_password_tuple, response.text.split("\r\n"))) + return entries + + except ReadTimeout: + log.warning('Request timed out for {}'.format(password)) + return HTTP_408_REQUEST_TIMEOUT + + except Exception as exc: # pylint: disable=W0703 + log.exception(f"Unable to range the password: {exc}") diff --git a/openedx/core/djangoapps/password_policy/tests/test_hibp.py b/openedx/core/djangoapps/password_policy/tests/test_hibp.py new file mode 100644 index 0000000000..6f0040b33c --- /dev/null +++ b/openedx/core/djangoapps/password_policy/tests/test_hibp.py @@ -0,0 +1,54 @@ +""" +Test 'have i been pwned' password service +""" + + +from unittest.mock import Mock, patch + +from django.test import TestCase +from edx_toggles.toggles.testutils import override_waffle_switch +from requests.exceptions import ReadTimeout +from testfixtures import LogCapture + +from openedx.core.djangoapps.password_policy.hibp import PwnedPasswordsAPI, log +from openedx.core.djangoapps.user_authn.config.waffle import ENABLE_PWNED_PASSWORD_API + + +class PwnedPasswordsAPITest(TestCase): + """ + Tests pwned password service + """ + @override_waffle_switch(ENABLE_PWNED_PASSWORD_API, True) + @patch('requests.get') + def test_matched_pwned_passwords(self, mock_get): + """ + Test that pwned service returns pwned passwords dict + """ + response_string = "7ecd77ecd7:341\r\n7ecd77ecd77ecd7:12" + pwned_password = { + "7ecd77ecd7": 341, + "7ecd77ecd77ecd7": 12, + } + response = Mock() + response.text = response_string + mock_get.return_value = response + response = PwnedPasswordsAPI.range('7ecd7') + + self.assertEqual(response, pwned_password) + + @override_waffle_switch(ENABLE_PWNED_PASSWORD_API, True) + @patch('requests.get', side_effect=ReadTimeout) + def test_warning_log_on_timeout(self, mock_get): # pylint: disable=unused-argument + """ + Test that captures the warning log on timeout + """ + with LogCapture(log.name) as log_capture: + PwnedPasswordsAPI.range('7ecd7') + log_capture.check_present( + ( + log.name, + 'WARNING', + 'Request timed out for 7ecd7' + ) + ) + assert 'Request timed out for 7ecd7' in log_capture.records[0].getMessage() diff --git a/openedx/core/djangoapps/user_authn/config/waffle.py b/openedx/core/djangoapps/user_authn/config/waffle.py index 030b9ef412..dc409d4eaa 100644 --- a/openedx/core/djangoapps/user_authn/config/waffle.py +++ b/openedx/core/djangoapps/user_authn/config/waffle.py @@ -23,3 +23,17 @@ ENABLE_LOGIN_USING_THIRDPARTY_AUTH_ONLY = LegacyWaffleSwitch( 'enable_login_using_thirdparty_auth_only', __name__ ) + +# .. toggle_name: user_authn.enable_pwned_password_api +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: When enabled, user password's vulnerability would be checked via pwned password database +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2021-09-22 +# .. toggle_target_removal_date: 2021-12-31 +# .. toggle_tickets: VAN-664 +ENABLE_PWNED_PASSWORD_API = LegacyWaffleSwitch( + _WAFFLE_SWITCH_NAMESPACE, + 'enable_pwned_password_api', + __name__ +) diff --git a/openedx/core/djangoapps/user_authn/tasks.py b/openedx/core/djangoapps/user_authn/tasks.py index 5a4d2d36b9..4e2f7a8b74 100644 --- a/openedx/core/djangoapps/user_authn/tasks.py +++ b/openedx/core/djangoapps/user_authn/tasks.py @@ -2,8 +2,10 @@ This file contains celery tasks for sending email """ - +import hashlib import logging +import math + from celery import shared_task from celery.exceptions import MaxRetriesExceededError from django.conf import settings @@ -13,13 +15,54 @@ from edx_ace import ace from edx_ace.errors import RecoverableChannelDeliveryError from edx_ace.message import Message from edx_django_utils.monitoring import set_code_owner_attribute +from rest_framework.status import HTTP_408_REQUEST_TIMEOUT +from common.djangoapps.track import segment +from openedx.core.djangoapps.password_policy.hibp import PwnedPasswordsAPI from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.lib.celery.task_utils import emulate_http_request log = logging.getLogger('edx.celery.task') +def get_pwned_properties(pwned_response, password): + """ + Derive different pwned parameters for analytics + """ + properties = {} + if pwned_response == HTTP_408_REQUEST_TIMEOUT: + properties['vulnerability'] = 'unknown' + pwned_count = 0 + else: + pwned_count = pwned_response.get(password, 0) + properties['vulnerability'] = 'yes' if pwned_count > 0 else 'no' + + if pwned_count > 0: + properties['frequency'] = math.ceil(math.log10(pwned_count)) + + return properties + + +@shared_task +def check_pwned_password_and_send_track_event(user_id, password, internal_user=False): + """ + Check the Pwned Databases and send its event to Segment + """ + try: + password = hashlib.sha1(password.encode('utf-8')).hexdigest() + pwned_response = PwnedPasswordsAPI.range(password) + if pwned_response is not None: + properties = get_pwned_properties(pwned_response, password) + properties['internal_user'] = internal_user + segment.track(user_id, 'edx.bi.user.pwned.password.status', properties) + except Exception: # pylint: disable=W0703 + log.exception( + 'Unable to get response from pwned password api for user_id: "%s"', + user_id, + ) + return None # lint-amnesty, pylint: disable=raise-missing-from + + @shared_task(bind=True) @set_code_owner_attribute def send_activation_email(self, msg_string, from_address=None): diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index a92cad9e05..e536be42ab 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -4,18 +4,17 @@ Views for login / logout and associated functionality Much of this file was broken out from views.py, previous history can be found there. """ +import hashlib import json import logging -import hashlib import re import urllib from django.conf import settings -from django.contrib.auth import authenticate -from django.contrib.auth import login as django_login -from django.contrib.auth import get_user_model -from django.contrib.auth.decorators import login_required from django.contrib import admin +from django.contrib.auth import authenticate, get_user_model +from django.contrib.auth import login as django_login +from django.contrib.auth.decorators import login_required from django.db.models import Q from django.http import HttpRequest, HttpResponse, HttpResponseForbidden from django.shortcuts import redirect @@ -31,31 +30,33 @@ from rest_framework.views import APIView from openedx_events.learning.data import UserData, UserPersonalData from openedx_events.learning.signals import SESSION_LOGIN_COMPLETED + +from common.djangoapps import third_party_auth from common.djangoapps.edxmako.shortcuts import render_to_response -from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_authn.views.login_form import get_login_session_form -from openedx.core.djangoapps.user_authn.cookies import get_response_with_refreshed_jwt_cookies, set_logged_in_cookies -from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError -from openedx.core.djangoapps.user_authn.toggles import should_redirect_to_authn_microfrontend -from openedx.core.djangoapps.util.user_messages import PageLevelMessages -from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user -from openedx.core.djangoapps.user_authn.views.utils import ( - ENTERPRISE_ENROLLMENT_URL_REGEX, UUID4_REGEX, API_V1 -) -from openedx.core.djangoapps.user_authn.toggles import is_require_third_party_auth_enabled -from openedx.core.djangoapps.user_authn.config.waffle import ENABLE_LOGIN_USING_THIRDPARTY_AUTH_ONLY -from openedx.core.djangolib.markup import HTML, Text -from openedx.core.lib.api.view_utils import require_post_params # lint-amnesty, pylint: disable=unused-import -from openedx.features.enterprise_support.api import activate_learner_enterprise, get_enterprise_learner_data_from_api from common.djangoapps.student.helpers import get_next_url_for_login_page, get_redirect_url_with_host -from common.djangoapps.student.models import LoginFailures, AllowedAuthUser, UserProfile +from common.djangoapps.student.models import AllowedAuthUser, LoginFailures, UserProfile from common.djangoapps.student.views import compose_and_send_activation_email from common.djangoapps.third_party_auth import pipeline, provider -from common.djangoapps import third_party_auth from common.djangoapps.track import segment from common.djangoapps.util.json_request import JsonResponse from common.djangoapps.util.password_policy_validators import normalize_password +from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.user_authn.config.waffle import ENABLE_LOGIN_USING_THIRDPARTY_AUTH_ONLY +from openedx.core.djangoapps.user_authn.cookies import get_response_with_refreshed_jwt_cookies, set_logged_in_cookies +from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError +from openedx.core.djangoapps.user_authn.toggles import ( + is_require_third_party_auth_enabled, + should_redirect_to_authn_microfrontend +) +from openedx.core.djangoapps.user_authn.views.login_form import get_login_session_form +from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user +from openedx.core.djangoapps.user_authn.views.utils import API_V1, ENTERPRISE_ENROLLMENT_URL_REGEX, UUID4_REGEX +from openedx.core.djangoapps.user_authn.tasks import check_pwned_password_and_send_track_event +from openedx.core.djangoapps.util.user_messages import PageLevelMessages +from openedx.core.djangolib.markup import HTML, Text +from openedx.core.lib.api.view_utils import require_post_params # lint-amnesty, pylint: disable=unused-import +from openedx.features.enterprise_support.api import activate_learner_enterprise, get_enterprise_learner_data_from_api log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") @@ -557,6 +558,8 @@ def login_user(request, api_version='v1'): if possibly_authenticated_user and password_policy_compliance.should_enforce_compliance_on_login(): # Important: This call must be made AFTER the user was successfully authenticated. _enforce_password_policy_compliance(request, possibly_authenticated_user) + is_internal_user = user.email.split('@')[1] == 'edx.org' + check_pwned_password_and_send_track_event.delay(user.id, request.POST.get('password'), is_internal_user) if possibly_authenticated_user is None or not possibly_authenticated_user.is_active: _handle_failed_authentication(user, possibly_authenticated_user) diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index 23e0c413b5..6f9e4a787b 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -61,6 +61,7 @@ from openedx.core.djangoapps.user_authn.views.registration_form import ( RegistrationFormFactory, get_registration_extension_form ) +from openedx.core.djangoapps.user_authn.tasks import check_pwned_password_and_send_track_event from openedx.core.djangoapps.user_authn.toggles import is_require_third_party_auth_enabled from common.djangoapps.student.helpers import ( AccountValidationError, @@ -276,12 +277,17 @@ def create_account_with_params(request, params): # TODO: there is no error checking here to see that the user actually logged in successfully, # and is not yet an active user. - if new_user is not None: - AUDIT_LOG.info(f"Login success on new account creation - {new_user.username}") - + is_new_user(request, new_user) return new_user +def is_new_user(request, user): + if user is not None: + AUDIT_LOG.info(f"Login success on new account creation - {user.username}") + is_internal_user = user.email.split('@')[1] == 'edx.org' + check_pwned_password_and_send_track_event.delay(user.id, request.POST.get('password'), is_internal_user) + + def _link_user_to_third_party_provider( is_third_party_auth_enabled, third_party_auth_credentials_in_api,