add hipb api client
This commit is contained in:
60
openedx/core/djangoapps/password_policy/hibp.py
Normal file
60
openedx/core/djangoapps/password_policy/hibp.py
Normal file
@@ -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}")
|
||||
54
openedx/core/djangoapps/password_policy/tests/test_hibp.py
Normal file
54
openedx/core/djangoapps/password_policy/tests/test_hibp.py
Normal file
@@ -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()
|
||||
@@ -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__
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user