Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 0.5.1 #337

Merged
merged 13 commits into from May 24, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions RELEASE.rst
@@ -1,6 +1,18 @@
Release Notes
=============

Version 0.5.1
-------------

- Fixed encrypted response getting ascii-escaped
- add feature site nofication through cms (#309)
- Added hubspot ecommerce bridge (#276)
- Move Header Bundle back to Original Location
- Use query parameters when loading checkout page (#283)
- Fix coupon apply button bug (#296)
- Added SDN compliance api and data model
- Convert Sections to Generic

Version 0.5.0 (Released May 22, 2019)
-------------

Expand Down
16 changes: 16 additions & 0 deletions app.json
Expand Up @@ -35,9 +35,17 @@
"CYBERSOURCE_ACCESS_KEY": {
"description": "CyberSource Access Key"
},
"CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY": {
"description": "The public key to encrypt export results with for our own security purposes. Should be a base64 encoded NaCl public key.",
"required": false
},
"CYBERSOURCE_PROFILE_ID": {
"description": "CyberSource Profile ID"
},
"CYBERSOURCE_MERCHANT_ID": {
"description": "The cybersource merchant id",
"required": false
},
"CYBERSOURCE_REFERENCE_PREFIX": {
"description": "a string prefix to identify the application in CyberSource transactions"
},
Expand All @@ -47,6 +55,14 @@
"CYBERSOURCE_SECURITY_KEY": {
"description": "CyberSource API key"
},
"CYBERSOURCE_TRANSACTION_KEY": {
"description": "The cybersource transaction key",
"required": false
},
"CYBERSOURCE_WSDL_URL": {
"description": "The URL to the cybersource WSDL",
"required": false
},
"GA_TRACKING_ID": {
"description": "Google analytics tracking ID",
"required": false
Expand Down
8 changes: 8 additions & 0 deletions authentication/exceptions.py
Expand Up @@ -76,5 +76,13 @@ class UnexpectedExistingUserException(PartialException):
"""Raised if a user already exists but shouldn't in the given pipeline step"""


class UserExportBlockedException(AuthException):
"""The user is blocked for export reasons from continuing to sign up"""


class UserTryAgainLaterException(AuthException):
"""The user should try to register again later"""


class UserMissingSocialAuthException(Exception):
"""Raised if the user doesn't have a social auth"""
70 changes: 70 additions & 0 deletions authentication/pipeline/compliance.py
@@ -0,0 +1,70 @@
"""Compliance pipeline actions"""
import logging

from django.conf import settings
from django.core import mail
from social_core.exceptions import AuthException

from authentication.exceptions import (
UserExportBlockedException,
UserTryAgainLaterException,
)
from compliance import api


log = logging.getLogger()


def verify_exports_compliance(
strategy, backend, user=None, **kwargs
): # pylint: disable=unused-argument
"""
Verify that the user is allowed by exports compliance

Args:
strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate
backend (social_core.backends.base.BaseAuth): the backend being used to authenticate
user (User): the current user
"""
if not api.is_exports_verification_enabled():
log.warning("Export compliance checks are disabled")
return {}

# skip this step if the user is active or they have an existing export inquiry logged
if user.is_active and user.exports_inquiries.exists():
return {}

try:
export_inquiry = api.verify_user_with_exports(user)
except Exception as exc: # pylint: disable=broad-except
# hard failure to request the exports API, log an error but don't let the user proceed
log.exception("Unable to verify exports compliance")
raise UserTryAgainLaterException(backend) from exc

if export_inquiry is None:
raise UserTryAgainLaterException(backend)
elif export_inquiry.is_denied:
log.info(
"User with email '%s' was denied due to exports violation, for reason_code=%s, info_code=%s",
user.email,
export_inquiry.reason_code,
export_inquiry.info_code,
)
try:
with mail.get_connection(settings.NOTIFICATION_EMAIL_BACKEND) as connection:
mail.send_mail(
f"Exports Compliance: denied {user.email}",
f"User with email '{user.email}' was denied due to exports violation, for reason_code={export_inquiry.reason_code}, info_code={export_inquiry.info_code}",
settings.MAILGUN_FROM_EMAIL,
[settings.EMAIL_SUPPORT],
connection=connection,
)
except Exception: # pylint: disable=broad-except
log.exception(
"Exception sending email to support regarding export compliance check failure"
)
raise UserExportBlockedException(backend)
elif export_inquiry.is_unknown:
raise AuthException("Unable to authenticate, please contact support")

return {}
110 changes: 110 additions & 0 deletions authentication/pipeline/compliance_test.py
@@ -0,0 +1,110 @@
"""Compliance pipeline tests"""
import pytest
from social_core.exceptions import AuthException

from authentication.exceptions import (
UserExportBlockedException,
UserTryAgainLaterException,
)
from authentication.pipeline import compliance
from compliance.factories import ExportsInquiryLogFactory


pytestmark = pytest.mark.django_db


def test_verify_exports_compliance_disabled(mocker):
"""Assert that nothing is done when the api is disabled"""
mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.is_exports_verification_enabled.return_value = False

assert compliance.verify_exports_compliance(None, None) == {}


@pytest.mark.parametrize(
"is_active, inquiry_exists, should_verify",
[
[True, True, False],
[True, False, True],
[False, True, True],
[False, False, True],
],
)
def test_verify_exports_compliance_user_active(
mailoutbox, mocker, user, is_active, inquiry_exists, should_verify
): # pylint: disable=too-many-arguments
"""Assert that the user is verified only if they already haven't been"""
user.is_active = is_active
if inquiry_exists:
ExportsInquiryLogFactory.create(user=user)

mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.return_value = mocker.Mock(
is_denied=False, is_unknown=False
)

assert compliance.verify_exports_compliance(None, None, user=user) == {}

if should_verify:
mock_api.verify_user_with_exports.assert_called_once_with(user)
assert len(mailoutbox) == 0


def test_verify_exports_compliance_no_record(mocker, user):
"""Assert that an error to try again later is raised if no ExportsInquiryLog is created"""

mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.return_value = None

with pytest.raises(UserTryAgainLaterException):
compliance.verify_exports_compliance(None, None, user=user)

mock_api.verify_user_with_exports.assert_called_once_with(user)


def test_verify_exports_compliance_api_raises_exception(mocker, user):
"""Assert that an error to try again later is raised if the export api raises an exception"""

mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.side_effect = Exception("error")

with pytest.raises(UserTryAgainLaterException):
compliance.verify_exports_compliance(None, None, user=user)

mock_api.verify_user_with_exports.assert_called_once_with(user)


@pytest.mark.parametrize("email_fails", [True, False])
def test_verify_exports_compliance_denied(mailoutbox, mocker, user, email_fails):
"""Assert that a UserExportBlockedException is raised if the inquiry result is denied"""
mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.return_value = mocker.Mock(
is_denied=True, is_unknown=False, reason_code=100, info_code="123"
)

if email_fails:
# a mail sending error should not obscurve the true error
mocker.patch(
"authentication.pipeline.compliance.mail.send_mail",
side_effect=Exception("mail error"),
)

with pytest.raises(UserExportBlockedException):
compliance.verify_exports_compliance(None, None, user=user)

mock_api.verify_user_with_exports.assert_called_once_with(user)
assert len(mailoutbox) == (0 if email_fails else 1)


def test_verify_exports_compliance_unknown(mailoutbox, mocker, user):
"""Assert that a UserExportBlockedException is raised if the inquiry result is unknown"""
mock_api = mocker.patch("authentication.pipeline.compliance.api")
mock_api.verify_user_with_exports.return_value = mocker.Mock(
is_denied=False, is_unknown=True
)

with pytest.raises(AuthException):
compliance.verify_exports_compliance(None, None, user=user)

mock_api.verify_user_with_exports.assert_called_once_with(user)
assert len(mailoutbox) == 0
30 changes: 28 additions & 2 deletions authentication/pipeline/user.py
Expand Up @@ -14,6 +14,8 @@
UnexpectedExistingUserException,
)
from authentication.utils import SocialAuthState

from compliance import api as compliance_api
from users.serializers import UserSerializer, ProfileSerializer

# pylint: disable=keyword-arg-before-vararg
Expand Down Expand Up @@ -176,10 +178,34 @@ def forbid_hijack(strategy, backend, **kwargs): # pylint: disable=unused-argume
Args:
strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate
backend (social_core.backends.base.BaseAuth): the backend being used to authenticate
user (User): the current user
flow (str): the type of flow (login or register)
"""
# As first step in pipeline, stop a hijacking admin from going any further
if strategy.session_get("is_hijacked_user"):
raise AuthException("You are hijacking another user, don't try to login again")
return {}


def activate_user(
strategy, backend, user=None, is_new=False, **kwargs
): # pylint: disable=unused-argument
"""
Activate the user's account if they passed export controls

Args:
strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate
backend (social_core.backends.base.BaseAuth): the backend being used to authenticate
user (User): the current user
"""
if user.is_active or not is_new:
return {}

export_inquiry = user.exports_inquiries.order_by("-created_on").first()

# if the user has an export inquiry that is considered successful, activate them
if not compliance_api.is_exports_verification_enabled() or (
export_inquiry is not None and export_inquiry.is_success
):
user.is_active = True
user.save()

return {}
34 changes: 34 additions & 0 deletions authentication/pipeline/user_test.py
Expand Up @@ -18,6 +18,8 @@
RequireUserException,
)
from authentication.utils import SocialAuthState
from compliance.constants import RESULT_SUCCESS, RESULT_DENIED, RESULT_UNKNOWN
from compliance.factories import ExportsInquiryLogFactory


@pytest.fixture
Expand Down Expand Up @@ -371,3 +373,35 @@ def test_forbid_hijack(mocker, hijacked):
user_actions.forbid_hijack(*args, **kwargs)
else:
assert user_actions.forbid_hijack(*args, **kwargs) == {}


@pytest.mark.parametrize("is_active", [True, False])
@pytest.mark.parametrize("is_new", [True, False])
@pytest.mark.parametrize(
"is_enabled, has_inquiry, computed_result, expected",
[
[True, True, RESULT_SUCCESS, True], # feature enabled, result is success
[True, True, RESULT_DENIED, False], # feature enabled, result is denied
[True, True, RESULT_UNKNOWN, False], # feature enabled, result is unknown
[False, False, None, True], # feature disabled
[True, False, None, False], # feature enabled, no result
],
)
def test_activate_user(
mocker, user, is_active, is_new, is_enabled, has_inquiry, computed_result, expected
): # pylint: disable=too-many-arguments
"""Test that activate_user takes the correct action"""
user.is_active = is_active
if has_inquiry:
ExportsInquiryLogFactory.create(user=user, computed_result=computed_result)

mocker.patch(
"authentication.pipeline.user.compliance_api.is_exports_verification_enabled",
return_value=is_enabled,
)

assert user_actions.activate_user(None, None, user=user, is_new=is_new) == {}

if not user.is_active and is_new:
# only if the user is inactive and just registered
assert user.is_active is expected
13 changes: 13 additions & 0 deletions authentication/serializers.py
Expand Up @@ -21,6 +21,8 @@
RequireProviderException,
RequireRegistrationException,
RequireProfileException,
UserExportBlockedException,
UserTryAgainLaterException,
)
from authentication.utils import SocialAuthState

Expand Down Expand Up @@ -284,6 +286,17 @@ def create(self, validated_data):
result = SocialAuthState(
SocialAuthState.STATE_REGISTER_EXTRA_DETAILS, partial=exc.partial
)
except UserExportBlockedException:
result = SocialAuthState(
SocialAuthState.STATE_USER_BLOCKED,
errors=["Unable to complete registration, please contact support"],
)
except UserTryAgainLaterException:
result = SocialAuthState(
SocialAuthState.STATE_ERROR_TEMPORARY,
errors=["Unable to register at this time, please try again later"],
)

return result


Expand Down
2 changes: 2 additions & 0 deletions authentication/utils.py
Expand Up @@ -24,8 +24,10 @@ class SocialAuthState:
# end states
STATE_SUCCESS = "success"
STATE_ERROR = "error"
STATE_ERROR_TEMPORARY = "error-temporary"
STATE_INACTIVE = "inactive"
STATE_INVALID_EMAIL = "invalid-email"
STATE_USER_BLOCKED = "user-blocked"

def __init__(
self,
Expand Down