added two endpoints for IDV decryption on stage (#25939)

simplified public keys

made migration

fixes for quality

added pylint fixes

fixed for pylint

added endpoint to retrieve user's receipt_ids

added tests for 404 with decryption error

fixed for quality

fixed for quality

updates for feedback

removed unnecessary method

fixed quality issue

updated tests
This commit is contained in:
alangsto
2021-01-07 09:05:33 -05:00
committed by GitHub
parent 40cf6ba413
commit e9dc5baf79
4 changed files with 350 additions and 27 deletions

View File

@@ -37,14 +37,20 @@ from opaque_keys.edx.django.models import CourseKeyField
from lms.djangoapps.verify_student.ssencrypt import (
encrypt_and_encode,
decode_and_decrypt,
generate_signed_message,
random_aes_key,
rsa_encrypt
rsa_encrypt,
rsa_decrypt
)
from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED
from openedx.core.storage import get_storage
from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date, submit_request_to_ss
from .utils import (
auto_verify_for_testing_enabled,
earliest_allowed_verification_date,
submit_request_to_ss
)
log = logging.getLogger(__name__)
@@ -691,6 +697,14 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
except cls.DoesNotExist:
return None
def _save_image_to_storage(self, path, img_data):
"""
Given a path and data, save to S3
Separated out for ease of mocking in testing
"""
buff = ContentFile(img_data)
self._storage.save(path, buff)
@status_before_must_be("created")
def upload_face_image(self, img_data):
"""
@@ -716,9 +730,10 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
else:
aes_key = aes_key_str.decode("hex")
encrypted_data = encrypt_and_encode(img_data, aes_key)
path = self._get_path("face")
buff = ContentFile(encrypt_and_encode(img_data, aes_key))
self._storage.save(path, buff)
self._save_image_to_storage(path, encrypted_data)
@status_before_must_be("created")
def upload_photo_id_image(self, img_data):
@@ -748,8 +763,8 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
# Save this to the storage backend
path = self._get_path("photo_id")
buff = ContentFile(encrypt_and_encode(img_data, aes_key))
self._storage.save(path, buff)
encrypted_data = encrypt_and_encode(img_data, aes_key)
self._save_image_to_storage(path, encrypted_data)
# Update our record fields
if six.PY3:
@@ -759,6 +774,61 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
self.save()
def _get_image_from_storage(self, path):
"""
Given a path, read data from storage and return
Separated for ease of mocking in testing
"""
with self._storage.open(path, mode='rb') as img_file:
byte_img_data = img_file.read()
return byte_img_data
@status_before_must_be("must_retry", "submitted", "approved", "denied")
def download_face_image(self):
"""
Download the associated face image from storage
"""
if not settings.VERIFY_STUDENT["SOFTWARE_SECURE"].get("RSA_PRIVATE_KEY", None):
return None
path = self._get_path("face")
byte_img_data = self._get_image_from_storage(path)
aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
try:
if six.PY3:
aes_key = codecs.decode(aes_key_str, "hex")
else:
aes_key = aes_key_str.decode("hex")
img_bytes = decode_and_decrypt(byte_img_data, aes_key)
return img_bytes
except: # pylint: disable=bare-except
return None
@status_before_must_be("must_retry", "submitted", "approved", "denied")
def download_photo_id_image(self):
"""
Download the associated id image from storage
"""
if not settings.VERIFY_STUDENT["SOFTWARE_SECURE"].get("RSA_PRIVATE_KEY", None):
return None
path = self._get_path("photo_id")
byte_img_data = self._get_image_from_storage(path)
try:
# decode rsa encrypted aes key from base64
rsa_encrypted_aes_key = base64.urlsafe_b64decode(self.photo_id_key)
# decrypt aes key using rsa private key
rsa_private_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PRIVATE_KEY"]
decrypted_aes_key = rsa_decrypt(rsa_encrypted_aes_key, rsa_private_key_str)
img_bytes = decode_and_decrypt(byte_img_data, decrypted_aes_key)
return img_bytes
except: # pylint: disable=bare-except
return None
@status_before_must_be("must_retry", "ready", "submitted")
def submit(self, copy_id_photo_from=None):
"""

View File

@@ -6,6 +6,8 @@ Tests of verify_student views.
from datetime import timedelta
from uuid import uuid4
import base64
import codecs
import ddt
import httpretty
import mock
@@ -34,6 +36,7 @@ from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
from lms.djangoapps.verify_student.services import IDVerificationService
from lms.djangoapps.verify_student.views import PayAndVerifyView, checkout_with_ecommerce_service, render_to_response
from lms.djangoapps.verify_student.ssencrypt import encrypt_and_encode, rsa_encrypt
from openedx.core.djangoapps.embargo.test_utils import restrict_course
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
@@ -55,6 +58,43 @@ render_mock = Mock(side_effect=mock_render_to_response)
PAYMENT_DATA_KEYS = {'payment_processor_name', 'payment_page_url', 'payment_form_data'}
RSA_PUBLIC_KEY = b"""-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1hLVjP0oV0Uy/+jQ+Upz
c+eYc4Pyflb/WpfgYATggkoQdnsdplmvPtQr85+utgqKPxOh+PvYGW8QNUzjLIu4
5/GlmvBa82i1jRMgEAxGI95bz7j9DtH+7mnj+06zR5xHwT49jK0zMs5MjMaz5WRq
BUNkz7dxWzDrYJZQx230sPp6upy1Y5H5O8SnJVdghsh8sNciS4Bo4ZONQ3giBwxz
h5svjspz1MIsOoShjbAdfG+4VX7sVwYlw2rnQeRsMH5/xpnNeqtScyOMoz0N9UDG
dtRMNGa2MihAg7zh7/zckbUrtf+o5wQtlCJL1Kdj4EjshqYvCxzWnSM+MaYAjb3M
EQIDAQAB
-----END PUBLIC KEY-----"""
RSA_PRIVATE_KEY = b"""-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1hLVjP0oV0Uy/+jQ+Upzc+eYc4Pyflb/WpfgYATggkoQdnsd
plmvPtQr85+utgqKPxOh+PvYGW8QNUzjLIu45/GlmvBa82i1jRMgEAxGI95bz7j9
DtH+7mnj+06zR5xHwT49jK0zMs5MjMaz5WRqBUNkz7dxWzDrYJZQx230sPp6upy1
Y5H5O8SnJVdghsh8sNciS4Bo4ZONQ3giBwxzh5svjspz1MIsOoShjbAdfG+4VX7s
VwYlw2rnQeRsMH5/xpnNeqtScyOMoz0N9UDGdtRMNGa2MihAg7zh7/zckbUrtf+o
5wQtlCJL1Kdj4EjshqYvCxzWnSM+MaYAjb3MEQIDAQABAoIBAQCviuA87fdfoOoS
OerrEacc20QDLaby/QoGUtZ2RmmHzY40af7FQ3PWFIw6Ca5trrTwxnuivXnWWWG0
I2mCRM0Kvfgr1n7ubOW7WnyHTFlT3mnxK2Ov/HmNLZ36nO2cgkXA6/Xy3rBGMC9L
nUE1kSLzT/Fh965ntfS9zmVNNBhb6no0rVkGx5nK3vTI6kUmaa0m+E7KL/HweO4c
JodhN8CX4gpxSrkuwJ7IHEPYspqc0jInMYKLmD3d2g3BiOctjzFmaj3lV5AUlujW
z7/LVe5WAEaaxjwaMvwqrJLv9ogxWU3etJf22+Yy7r5gbPtqpqJrCZ5+WpGnUHws
3mMGP2QBAoGBAOc3pzLFgGUREVPSFQlJ06QFtfKYqg9fFHJCgWu/2B2aVZc2aO/t
Zhuoz+AgOdzsw+CWv7K0FH9sUkffk2VKPzwwwufLK3avD9gI0bhmBAYvdhS6A3nO
YM3W+lvmaJtFL00K6kdd+CzgRnBS9cZ70WbcbtqjdXI6+mV1WdGUTLhBAoGBAO0E
xhD4z+GjubSgfHYEZPgRJPqyUIfDH+5UmFGpr6zlvNN/depaGxsbhW8t/V6xkxsG
MCgic7GLMihEiUMx1+/snVs5bBUx7OT9API0d+vStHCFlTTe6aTdmiduFD4PbDsq
6E4DElVRqZhpIYusdDh7Z3fO2hm5ad4FfMlx65/RAoGAPYEfV7ETs06z9kEG2X6q
7pGaUZrsecRH8xDfzmKswUshg2S0y0WyCJ+CFFNeMPdGL4LKIWYnobGVvYqqcaIr
af5qijAQMrTkmQnXh56TaXXMijzk2czdEUQjOrjykIL5zxudMDi94GoUMqLOv+qF
zD/MuRoMDsPDgaOSrd4t/kECgYEAzwBNT8NOIz3P0Z4cNSJPYIvwpPaY+IkE2SyO
vzuYj0Mx7/Ew9ZTueXVGyzv6PfqOhJqZ8mNscZIlIyAAVWwxsHwRTfvPlo882xzP
97i1R4OFTYSNNFi+69sSZ/9utGjZ2K73pjJuj487tD2VK5xZAH9edTd2KeNSP7LB
MlpJNBECgYAmIswPdldm+G8SJd5j9O2fcDVTURjKAoSXCv2j4gEZzzfudpLWNHYu
l8N6+LEIVTMAytPk+/bImHvGHKZkCz5rEMSuYJWOmqKI92rUtI6fz5DUb3XSbrwT
3W+sdGFUK3GH1NAX71VxbAlFVLUetcMwai1+wXmGkRw6A7YezVFnhw==
-----END RSA PRIVATE KEY-----"""
def _mock_payment_processors():
"""
@@ -1259,14 +1299,7 @@ class TestSubmitPhotosForVerification(MockS3BotoMixin, TestVerificationBase):
"API_ACCESS_KEY": "dcf291b5572942f99adaab4c2090c006",
"API_SECRET_KEY": "c392efdcc0354c5f922dc39844ec0dc7",
"FACE_IMAGE_AES_KEY": "f82400259e3b4f88821cd89838758292",
"RSA_PUBLIC_KEY": (
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDkgtz3fQdiXshy/RfOHkoHlhx/"
"SSPZ+nNyE9JZXtwhlzsXjnu+e9GOuJzgh4kUqo73ePIG5FxVU+mnacvufq2cu1SOx"
"lRYGyBK7qDf9Ym67I5gmmcNhbzdKcluAuDCPmQ4ecKpICQQldrDQ9HWDxwjbbcqpVB"
"PYWkE1KrtypGThmcehLmabf6SPq1CTAGlXsHgUtbWCwV6mqR8yScV0nRLln0djLDm9d"
"L8tIVFFVpAfBaYYh2Cm5EExQZjxyfjWd8P5H+8/l0pmK2jP7Hc0wuXJemIZbsdm+DSD"
"FhCGY3AILGkMwr068dGRxfBtBy/U9U5W+nStvkDdMrSgQezS5+V test@example.com"
),
"RSA_PUBLIC_KEY": RSA_PUBLIC_KEY,
"AWS_ACCESS_KEY": "c987c7efe35c403caa821f7328febfa1",
"AWS_SECRET_KEY": "fc595fc657c04437bb23495d8fe64881",
"S3_BUCKET": "test.example.com",
@@ -1784,14 +1817,7 @@ class TestReverifyView(TestVerificationBase):
"API_ACCESS_KEY": "dcf291b5572942f99adaab4c2090c006",
"API_SECRET_KEY": "c392efdcc0354c5f922dc39844ec0dc7",
"FACE_IMAGE_AES_KEY": "f82400259e3b4f88821cd89838758292",
"RSA_PUBLIC_KEY": (
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDkgtz3fQdiXshy/RfOHkoHlhx/"
"SSPZ+nNyE9JZXtwhlzsXjnu+e9GOuJzgh4kUqo73ePIG5FxVU+mnacvufq2cu1SOx"
"lRYGyBK7qDf9Ym67I5gmmcNhbzdKcluAuDCPmQ4ecKpICQQldrDQ9HWDxwjbbcqpVB"
"PYWkE1KrtypGThmcehLmabf6SPq1CTAGlXsHgUtbWCwV6mqR8yScV0nRLln0djLDm9d"
"L8tIVFFVpAfBaYYh2Cm5EExQZjxyfjWd8P5H+8/l0pmK2jP7Hc0wuXJemIZbsdm+DSD"
"FhCGY3AILGkMwr068dGRxfBtBy/U9U5W+nStvkDdMrSgQezS5+V test@example.com"
),
"RSA_PUBLIC_KEY": RSA_PUBLIC_KEY,
"AWS_ACCESS_KEY": "c987c7efe35c403caa821f7328febfa1",
"AWS_SECRET_KEY": "fc595fc657c04437bb23495d8fe64881",
"CERT_VERIFICATION_PATH": False,
@@ -1803,9 +1829,9 @@ class TestReverifyView(TestVerificationBase):
},
},
)
class TestPhotoURLView(ModuleStoreTestCase, TestVerificationBase):
class TestPhotoURLView(TestVerificationBase):
"""
Tests for the results_callback view.
Tests for the photo url view.
"""
def setUp(self):
@@ -1853,3 +1879,155 @@ class TestPhotoURLView(ModuleStoreTestCase, TestVerificationBase):
url = reverse('verification_photo_urls', kwargs={'receipt_id': six.text_type(self.receipt_id)})
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
@override_settings(
VERIFY_STUDENT={
"SOFTWARE_SECURE": {
"API_URL": "https://verify.example.com/submit/",
"API_ACCESS_KEY": "dcf291b5572942f99adaab4c2090c006",
"API_SECRET_KEY": "c392efdcc0354c5f922dc39844ec0dc7",
"FACE_IMAGE_AES_KEY": b'32fe72aaf2abb44de9e161131b5435c8d37cbdb6f5df242ae860b283115f2dae',
"RSA_PUBLIC_KEY": RSA_PUBLIC_KEY,
"RSA_PRIVATE_KEY": RSA_PRIVATE_KEY,
"AWS_ACCESS_KEY": "c987c7efe35c403caa821f7328febfa1",
"AWS_SECRET_KEY": "fc595fc657c04437bb23495d8fe64881",
"S3_BUCKET": "test-idv",
"CERT_VERIFICATION_PATH": False,
},
"DAYS_GOOD_FOR": 10,
"STORAGE_CLASS": 'storages.backends.s3boto.S3BotoStorage',
"STORAGE_KWARGS": {
'bucket': 'test-idv',
},
}
)
@ddt.ddt
class TestDecodeImageViews(MockS3BotoMixin, TestVerificationBase):
"""
Test for both face and photo id image decoding views
"""
IMAGE_DATA = "abcd,1234"
def setUp(self):
super().setUp()
self.user = AdminFactory()
login_success = self.client.login(username=self.user.username, password='test')
self.assertTrue(login_success)
def _mock_submit_images(self):
"""
Mocks submitting images for IDV and saving to S3
"""
# create an attempt with a submitted status, and create a photo_id_key to use
# for decryption
attempt = SoftwareSecurePhotoVerification(
status="submitted",
user=self.user
)
rsa_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
rsa_encrypted_aes_key = rsa_encrypt(
codecs.decode(
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"],
"hex"
),
rsa_key_str
)
attempt.photo_id_key = codecs.encode(rsa_encrypted_aes_key, 'base64').decode('utf-8')
attempt.save()
def _decode_image(self, receipt_id, img_type):
"""
Test function used to call decoding endpoint
Arg:
receipt_id(str): receipt ID for endpoint url
img_type(str): 'face' or 'photo_id', used to determine which endpoint to use
"""
url_name = 'verification_decrypt_face_image'
if img_type == 'photo_id':
url_name = 'verification_decrypt_photo_id_image'
url = reverse(url_name, kwargs={'receipt_id': six.text_type(receipt_id)})
response = self.client.get(url)
return response
@ddt.data("face", "photo_id")
@patch.object(SoftwareSecurePhotoVerification, '_get_image_from_storage')
def test_download_image_response(self, img_type, _mock_get_storage):
_mock_get_storage.return_value = encrypt_and_encode(
b'\xd7m\xf8',
codecs.decode(settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"], "hex")
)
# upload 'images'
self._mock_submit_images()
attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user)
receipt_id = attempt.receipt_id
#mock downloading and decrypting images
response = self._decode_image(receipt_id, img_type)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, base64.b64decode(self.IMAGE_DATA.split(",")[1]))
@ddt.data("face", "photo_id")
def test_403_for_non_staff(self, img_type):
self.user = UserFactory()
login_success = self.client.login(username=self.user.username, password='test')
self.assertTrue(login_success)
self._mock_submit_images()
attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user)
receipt_id = attempt.receipt_id
# mock downloading and decrypting images
response = self._decode_image(receipt_id, img_type)
self.assertEqual(response.status_code, 403)
@override_settings(
VERIFY_STUDENT={
"SOFTWARE_SECURE": {
"API_URL": "https://verify.example.com/submit/",
"API_ACCESS_KEY": "dcf291b5572942f99adaab4c2090c006",
"API_SECRET_KEY": "c392efdcc0354c5f922dc39844ec0dc7",
"FACE_IMAGE_AES_KEY": b'32fe72aaf2abb44de9e161131b5435c8d37cbdb6f5df242ae860b283115f2dae',
"RSA_PUBLIC_KEY": RSA_PUBLIC_KEY,
"AWS_ACCESS_KEY": "c987c7efe35c403caa821f7328febfa1",
"AWS_SECRET_KEY": "fc595fc657c04437bb23495d8fe64881",
"S3_BUCKET": "test-idv",
"CERT_VERIFICATION_PATH": False,
},
"DAYS_GOOD_FOR": 10,
}
)
@ddt.data("face", "photo_id")
def test_403_for_non_staging(self, img_type):
self._mock_submit_images()
attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user)
receipt_id = attempt.receipt_id
# mock downloading and decrypting images
response = self._decode_image(receipt_id, img_type)
self.assertEqual(response.status_code, 403)
@ddt.data("face", "photo_id")
def test_404_if_invalid_receipt_id(self, img_type):
response = self._decode_image('00000000-0000-0000-0000-000000000000', img_type)
self.assertEqual(response.status_code, 404)
@ddt.data("face", "photo_id")
@patch.object(SoftwareSecurePhotoVerification, '_get_image_from_storage')
def test_404_for_decryption_error(self, img_type, _mock_get_storage):
_mock_get_storage.return_value = None
# create verification with no img data
attempt = SoftwareSecurePhotoVerification(
status="submitted",
user=self.user
)
attempt.save()
receipt_id = attempt.receipt_id
# mock downloading and decrypting images
response = self._decode_image(receipt_id, img_type)
self.assertEqual(response.status_code, 404)

View File

@@ -106,6 +106,18 @@ urlpatterns = [
views.PhotoUrlsView.as_view(),
name="verification_photo_urls"
),
url(
r'^decrypt-idv-images/face/{receipt_id}$'.format(receipt_id=IDV_RECEIPT_ID_PATTERN),
views.DecryptFaceImageView.as_view(),
name="verification_decrypt_face_image"
),
url(
r'^decrypt-idv-images/photo-id/{receipt_id}$'.format(receipt_id=IDV_RECEIPT_ID_PATTERN),
views.DecryptPhotoIDImageView.as_view(),
name="verification_decrypt_photo_id_image"
),
]
# Fake response page for incourse reverification ( software secure )

View File

@@ -12,7 +12,7 @@ from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles.storage import staticfiles_storage
from django.db import transaction
from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.decorators import method_decorator
@@ -24,7 +24,6 @@ from django.views.decorators.http import require_POST
from django.views.generic.base import View
from edx_rest_api_client.exceptions import SlumberBaseException
from ipware.ip import get_ip
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -1225,7 +1224,7 @@ class PhotoUrlsView(APIView):
def get(self, request, receipt_id):
"""
Endpoint for retrieving photo urls for IDV
GET /verify_student/photo_urls/{receipt_id}
GET /verify_student/photo-urls/{receipt_id}
Returns:
200 OK
@@ -1247,3 +1246,67 @@ class PhotoUrlsView(APIView):
log.warning(u"Could not find verification with receipt ID %s.", receipt_id)
raise Http404
class DecryptFaceImageView(APIView):
"""
Endpoint to retrieve decrypted IDV face image data. Can only be used on stage.
"""
@method_decorator(require_global_staff)
def get(self, request, receipt_id):
"""
Endpoint used for decrypting images on stage based on a given receipt ID
GET /verify_student/decrypt-idv-images/face/{receipt_id}
Returns:
200 OK
{
img
}
"""
# if this endpoint is not being accessed on stage, raise a 403. Only stage will have an RSA_PRIVATE_KEY
if not settings.VERIFY_STUDENT["SOFTWARE_SECURE"].get("RSA_PRIVATE_KEY", None):
log.warning(u"Cannot access image decryption outside of staging environment")
return HttpResponseForbidden()
verification = SoftwareSecurePhotoVerification.get_verification_from_receipt(receipt_id)
if verification:
user_photo = verification.download_face_image()
if user_photo:
return HttpResponse(user_photo, content_type="image/png")
log.warning(u"Could not decrypt face image for receipt ID %s.", receipt_id)
raise Http404
class DecryptPhotoIDImageView(APIView):
"""
Endpoint to retrieve decrypted IDV photo ID image data. Can only be used on stage.
"""
@method_decorator(require_global_staff)
def get(self, request, receipt_id):
"""
Endpoint used for decrypting images on stage based on a given receipt ID
GET /verify_student/decrypt-idv-images/photo-id/{receipt_id}
Returns:
200 OK
{
img
}
"""
# if this endpoint is not being accessed on stage, raise a 403. Only stage will have an RSA_PRIVATE_KEY
if not settings.VERIFY_STUDENT["SOFTWARE_SECURE"].get("RSA_PRIVATE_KEY", None):
log.warning(u"Cannot access image decryption outside of staging environment")
return HttpResponseForbidden()
verification = SoftwareSecurePhotoVerification.get_verification_from_receipt(receipt_id)
if verification:
id_photo = verification.download_photo_id_image()
if id_photo:
return HttpResponse(id_photo, content_type="image/png")
log.warning(u"Could not decrypt photo ID image for receipt ID %s.", receipt_id)
raise Http404