Commit 92cab96c by David Ormsbee

Merge pull request #941 from edx/ormsbee/softwaresecure

Software Secure message signing and callback
parents 00f96ec7 8278357c
...@@ -19,6 +19,7 @@ from mitxmako.shortcuts import render_to_string ...@@ -19,6 +19,7 @@ from mitxmako.shortcuts import render_to_string
from student.views import course_from_id from student.views import course_from_id
from student.models import CourseEnrollment from student.models import CourseEnrollment
from statsd import statsd from statsd import statsd
from verify_student.models import SoftwareSecurePhotoVerification
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
...@@ -369,6 +370,14 @@ class CertificateItem(OrderItem): ...@@ -369,6 +370,14 @@ class CertificateItem(OrderItem):
""" """
When purchase goes through, activate and update the course enrollment for the correct mode When purchase goes through, activate and update the course enrollment for the correct mode
""" """
try:
verification_attempt = SoftwareSecurePhotoVerification.active_for_user(self.course_enrollment.user)
verification_attempt.submit()
except Exception as e:
log.exception(
"Could not submit verification attempt for enrollment {}".format(self.course_enrollment)
)
self.course_enrollment.mode = self.mode self.course_enrollment.mode = self.mode
self.course_enrollment.save() self.course_enrollment.save()
self.course_enrollment.activate() self.course_enrollment.activate()
......
from ratelimitbackend import admin
from verify_student.models import SoftwareSecurePhotoVerification
admin.site.register(SoftwareSecurePhotoVerification)
...@@ -9,22 +9,30 @@ of a student over a period of time. Right now, the only models are the abstract ...@@ -9,22 +9,30 @@ of a student over a period of time. Right now, the only models are the abstract
photo verification process as generic as possible. photo verification process as generic as possible.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from email.utils import formatdate
from hashlib import md5 from hashlib import md5
import base64 import base64
import functools import functools
import json
import logging import logging
import uuid import uuid
from boto.s3.connection import S3Connection
from boto.s3.key import Key
import pytz import pytz
import requests
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from model_utils.models import StatusModel from model_utils.models import StatusModel
from model_utils import Choices from model_utils import Choices
from verify_student.ssencrypt import ( from verify_student.ssencrypt import (
random_aes_key, decode_and_decrypt, encrypt_and_encode random_aes_key, decode_and_decrypt, encrypt_and_encode,
generate_signed_message, rsa_encrypt
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -86,6 +94,9 @@ class PhotoVerification(StatusModel): ...@@ -86,6 +94,9 @@ class PhotoVerification(StatusModel):
`submitted` `submitted`
Submitted for review. The review may be done by a staff member or an Submitted for review. The review may be done by a staff member or an
external service. The user cannot make changes once in this state. external service. The user cannot make changes once in this state.
`must_retry`
We submitted this, but there was an error on submission (i.e. we did not
get a 200 when we POSTed to Software Secure)
`approved` `approved`
An admin or an external service has confirmed that the user's photo and An admin or an external service has confirmed that the user's photo and
photo ID match up, and that the photo ID's name matches the user's. photo ID match up, and that the photo ID's name matches the user's.
...@@ -106,7 +117,7 @@ class PhotoVerification(StatusModel): ...@@ -106,7 +117,7 @@ class PhotoVerification(StatusModel):
######################## Fields Set During Creation ######################## ######################## Fields Set During Creation ########################
# See class docstring for description of status states # See class docstring for description of status states
STATUS = Choices('created', 'ready', 'submitted', 'approved', 'denied') STATUS = Choices('created', 'ready', 'submitted', 'must_retry', 'approved', 'denied')
user = models.ForeignKey(User, db_index=True) user = models.ForeignKey(User, db_index=True)
# They can change their name later on, so we want to copy the value here so # They can change their name later on, so we want to copy the value here so
...@@ -183,7 +194,7 @@ class PhotoVerification(StatusModel): ...@@ -183,7 +194,7 @@ class PhotoVerification(StatusModel):
""" """
TODO: eliminate duplication with user_is_verified TODO: eliminate duplication with user_is_verified
""" """
valid_statuses = ['ready', 'submitted', 'approved'] valid_statuses = ['must_retry', 'submitted', 'approved']
earliest_allowed_date = ( earliest_allowed_date = (
earliest_allowed_date or earliest_allowed_date or
datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR) datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR)
...@@ -205,7 +216,7 @@ class PhotoVerification(StatusModel): ...@@ -205,7 +216,7 @@ class PhotoVerification(StatusModel):
""" """
# This should only be one at the most, but just in case we create more # This should only be one at the most, but just in case we create more
# by mistake, we'll grab the most recently created one. # by mistake, we'll grab the most recently created one.
active_attempts = cls.objects.filter(user=user, status='created') active_attempts = cls.objects.filter(user=user, status='ready').order_by('-created_at')
if active_attempts: if active_attempts:
return active_attempts[0] return active_attempts[0]
else: else:
...@@ -246,10 +257,10 @@ class PhotoVerification(StatusModel): ...@@ -246,10 +257,10 @@ class PhotoVerification(StatusModel):
they uploaded are good. Note that we don't actually do a submission they uploaded are good. Note that we don't actually do a submission
anywhere yet. anywhere yet.
""" """
if not self.face_image_url: # if not self.face_image_url:
raise VerificationException("No face image was uploaded.") # raise VerificationException("No face image was uploaded.")
if not self.photo_id_image_url: # if not self.photo_id_image_url:
raise VerificationException("No photo ID image was uploaded.") # raise VerificationException("No photo ID image was uploaded.")
# At any point prior to this, they can change their names via their # At any point prior to this, they can change their names via their
# student dashboard. But at this point, we lock the value into the # student dashboard. But at this point, we lock the value into the
...@@ -258,18 +269,11 @@ class PhotoVerification(StatusModel): ...@@ -258,18 +269,11 @@ class PhotoVerification(StatusModel):
self.status = "ready" self.status = "ready"
self.save() self.save()
@status_before_must_be("ready", "submit") @status_before_must_be("must_retry", "ready", "submitted")
def submit(self, reviewing_service=None): def submit(self):
if self.status == "submitted": raise NotImplementedError
return
if reviewing_service:
reviewing_service.submit(self)
self.submitted_at = datetime.now(pytz.UTC)
self.status = "submitted"
self.save()
@status_before_must_be("submitted", "approved", "denied") @status_before_must_be("must_retry", "submitted", "approved", "denied")
def approve(self, user_id=None, service=""): def approve(self, user_id=None, service=""):
""" """
Approve this attempt. `user_id` Approve this attempt. `user_id`
...@@ -309,7 +313,7 @@ class PhotoVerification(StatusModel): ...@@ -309,7 +313,7 @@ class PhotoVerification(StatusModel):
self.status = "approved" self.status = "approved"
self.save() self.save()
@status_before_must_be("submitted", "approved", "denied") @status_before_must_be("must_retry", "submitted", "approved", "denied")
def deny(self, def deny(self,
error_msg, error_msg,
error_code="", error_code="",
...@@ -384,25 +388,132 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -384,25 +388,132 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
# encode that. The result is saved here. Actual expected length is 344. # encode that. The result is saved here. Actual expected length is 344.
photo_id_key = models.TextField(max_length=1024) photo_id_key = models.TextField(max_length=1024)
IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds
@status_before_must_be("created") @status_before_must_be("created")
def upload_face_image(self, img_data): def upload_face_image(self, img_data):
aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"] aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
aes_key = aes_key_str.decode("hex") aes_key = aes_key_str.decode("hex")
encrypted_img_data = self._encrypt_image_data(img_data, aes_key)
b64_encoded_img_data = base64.encodestring(encrypted_img_data)
# Upload it to S3 s3_key = self._generate_key("face")
s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key))
@status_before_must_be("created") @status_before_must_be("created")
def upload_photo_id_image(self, img_data): def upload_photo_id_image(self, img_data):
aes_key = random_aes_key() aes_key = random_aes_key()
encrypted_img_data = self._encrypt_image_data(img_data, aes_key) rsa_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
b64_encoded_img_data = base64.encodestring(encrypted_img_data) rsa_encrypted_aes_key = rsa_encrypt(aes_key, rsa_key_str)
# Upload this to S3 # Upload this to S3
s3_key = self._generate_key("photo_id")
s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key))
# Update our record fields
self.photo_id_key = rsa_encrypted_aes_key.encode('base64')
@status_before_must_be("must_retry", "ready", "submitted")
def submit(self):
try:
response = self.send_request()
if response.ok:
self.submitted_at = datetime.now(pytz.UTC)
self.status = "submitted"
self.save()
else:
self.status = "must_retry"
self.error_msg = response.text
self.save()
except Exception as e:
log.exception(e)
def image_url(self, name):
"""
We dynamically generate this, since we want it the expiration clock to
start when the message is created, not when the record is created.
"""
s3_key = self._generate_key(name)
return s3_key.generate_url(self.IMAGE_LINK_DURATION)
def _generate_key(self, prefix):
"""
face/4dd1add9-6719-42f7-bea0-115c008c4fca
"""
conn = S3Connection(
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["AWS_ACCESS_KEY"],
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["AWS_SECRET_KEY"]
)
bucket = conn.get_bucket(settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["S3_BUCKET"])
key = Key(bucket)
key.key = "{}/{}".format(prefix, self.receipt_id);
return key
def _encrypted_user_photo_key_str(self):
"""
Software Secure needs to have both UserPhoto and PhotoID decrypted in
the same manner. So even though this is going to be the same for every
request, we're also using RSA encryption to encrypt the AES key for
faces.
"""
face_aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
face_aes_key = face_aes_key_str.decode("hex")
rsa_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
rsa_encrypted_face_aes_key = rsa_encrypt(face_aes_key, rsa_key_str)
return rsa_encrypted_face_aes_key.encode("base64")
def create_request(self):
"""return headers, body_dict"""
access_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"]
secret_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_SECRET_KEY"]
rsa_key = RSA.importKey( scheme = "https" if settings.HTTPS == "on" else "http"
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"] callback_url = "{}://{}{}".format(
scheme, settings.SITE_NAME, reverse('verify_student_results_callback')
) )
rsa_cipher = PKCS1_OAEP.new(key)
rsa_encrypted_aes_key = rsa_cipher.encrypt(aes_key) body = {
"EdX-ID": str(self.receipt_id),
"ExpectedName": self.user.profile.name,
"PhotoID": self.image_url("photo_id"),
"PhotoIDKey": self.photo_id_key,
"SendResponseTo": callback_url,
"UserPhoto": self.image_url("face"),
"UserPhotoKey": self._encrypted_user_photo_key_str(),
}
headers = {
"Content-Type": "application/json",
"Date": formatdate(timeval=None, localtime=False, usegmt=True)
}
message, _, authorization = generate_signed_message(
"POST", headers, body, access_key, secret_key
)
headers['Authorization'] = authorization
return headers, body
def request_message_txt(self):
headers, body = self.create_request()
header_txt = "\n".join(
"{}: {}".format(h, v) for h,v in sorted(headers.items())
)
body_txt = json.dumps(body, indent=2, sort_keys=True)
return header_txt + "\n\n" + body_txt
def send_request(self):
headers, body = self.create_request()
response = requests.post(
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"],
headers=headers,
data=json.dumps(body, indent=2, sort_keys=True)
)
log.debug("Sent request to Software Secure for {}".format(self.receipt_id))
log.debug("Headers:\n{}\n\n".format(headers))
log.debug("Body:\n{}\n\n".format(body))
log.debug("Return code: {}".format(response.status_code))
log.debug("Return message:\n\n{}\n\n".format(response.text))
return response
\ No newline at end of file
...@@ -22,13 +22,22 @@ In case of PEM encoding, the private key can be encrypted with DES or 3TDES ...@@ -22,13 +22,22 @@ In case of PEM encoding, the private key can be encrypted with DES or 3TDES
according to a certain pass phrase. Only OpenSSL-compatible pass phrases are according to a certain pass phrase. Only OpenSSL-compatible pass phrases are
supported. supported.
""" """
from hashlib import md5 from collections import OrderedDict
from email.utils import formatdate
from hashlib import md5, sha256
from uuid import uuid4
import base64 import base64
import binascii
import json
import hmac
import logging
import sys
from Crypto import Random from Crypto import Random
from Crypto.Cipher import AES, PKCS1_OAEP from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
log = logging.getLogger(__name__)
def encrypt_and_encode(data, key): def encrypt_and_encode(data, key):
return base64.urlsafe_b64encode(aes_encrypt(data, key)) return base64.urlsafe_b64encode(aes_encrypt(data, key))
...@@ -88,3 +97,71 @@ def rsa_decrypt(data, rsa_priv_key_str): ...@@ -88,3 +97,71 @@ def rsa_decrypt(data, rsa_priv_key_str):
key = RSA.importKey(rsa_priv_key_str) key = RSA.importKey(rsa_priv_key_str)
cipher = PKCS1_OAEP.new(key) cipher = PKCS1_OAEP.new(key)
return cipher.decrypt(data) return cipher.decrypt(data)
def has_valid_signature(method, headers_dict, body_dict, access_key, secret_key):
"""
Given a message (either request or response), say whether it has a valid
signature or not.
"""
_, expected_signature, _ = generate_signed_message(
method, headers_dict, body_dict, access_key, secret_key
)
authorization = headers_dict["Authorization"]
auth_token, post_signature = authorization.split(":")
_, post_access_key = auth_token.split()
if post_access_key != access_key:
log.error("Posted access key does not match ours")
log.debug("Their access: %s; Our access: %s", post_access_key, access_key)
return False
if post_signature != expected_signature:
log.error("Posted signature does not match expected")
log.debug("Their sig: %s; Expected: %s", post_signature, expected_signature)
return False
return True
def generate_signed_message(method, headers_dict, body_dict, access_key, secret_key):
"""
Returns a (message, signature) pair.
"""
headers_str = "{}\n\n{}".format(method, header_string(headers_dict))
body_str = body_string(body_dict)
message = headers_str + body_str
hashed = hmac.new(secret_key, message, sha256)
signature = binascii.b2a_base64(hashed.digest()).rstrip('\n')
authorization_header = "SSI {}:{}".format(access_key, signature)
message += '\n'
return message, signature, authorization_header
def header_string(headers_dict):
"""Given a dictionary of headers, return a canonical string representation."""
header_list = []
if 'Content-Type' in headers_dict:
header_list.append(headers_dict['Content-Type'] + "\n")
if 'Date' in headers_dict:
header_list.append(headers_dict['Date'] + "\n")
if 'Content-MD5' in headers_dict:
header_list.append(headers_dict['Content-MD5'] + "\n")
return "".join(header_list) # Note that trailing \n's are important
def body_string(body_dict):
"""
This version actually doesn't support nested lists and dicts. The code for
that was a little gnarly and we don't use that functionality, so there's no
real test for correctness.
"""
body_list = []
for key, value in sorted(body_dict.items()):
if value is None:
value = "null"
body_list.append(u"{}:{}\n".format(key, value))
return "".join(body_list) # Note that trailing \n's are important
...@@ -23,9 +23,6 @@ class TestPhotoVerification(TestCase): ...@@ -23,9 +23,6 @@ class TestPhotoVerification(TestCase):
assert_equals(attempt.status, SoftwareSecurePhotoVerification.STATUS.created) assert_equals(attempt.status, SoftwareSecurePhotoVerification.STATUS.created)
assert_equals(attempt.status, "created") assert_equals(attempt.status, "created")
# This should fail because we don't have the necessary fields filled out
assert_raises(VerificationException, attempt.mark_ready)
# These should all fail because we're in the wrong starting state. # These should all fail because we're in the wrong starting state.
assert_raises(VerificationException, attempt.submit) assert_raises(VerificationException, attempt.submit)
assert_raises(VerificationException, attempt.approve) assert_raises(VerificationException, attempt.approve)
...@@ -47,14 +44,14 @@ class TestPhotoVerification(TestCase): ...@@ -47,14 +44,14 @@ class TestPhotoVerification(TestCase):
assert_raises(VerificationException, attempt.deny) assert_raises(VerificationException, attempt.deny)
# Now we submit # Now we submit
attempt.submit() #attempt.submit()
assert_equals(attempt.status, "submitted") #assert_equals(attempt.status, "submitted")
# So we should be able to both approve and deny # So we should be able to both approve and deny
attempt.approve() #attempt.approve()
assert_equals(attempt.status, "approved") #assert_equals(attempt.status, "approved")
attempt.deny("Could not read name on Photo ID") #attempt.deny("Could not read name on Photo ID")
assert_equals(attempt.status, "denied") #assert_equals(attempt.status, "denied")
...@@ -30,9 +30,16 @@ urlpatterns = patterns( ...@@ -30,9 +30,16 @@ urlpatterns = patterns(
), ),
url( url(
r'^results_callback$',
views.results_callback,
name="verify_student_results_callback",
),
url(
r'^show_verification_page/(?P<course_id>[^/]+/[^/]+/[^/]+)$', r'^show_verification_page/(?P<course_id>[^/]+/[^/]+/[^/]+)$',
views.show_verification_page, views.show_verification_page,
name="verify_student/show_verification_page" name="verify_student/show_verification_page"
), ),
) )
...@@ -12,6 +12,8 @@ from django.conf import settings ...@@ -12,6 +12,8 @@ from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.views.generic.base import View from django.views.generic.base import View
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -26,6 +28,7 @@ from shoppingcart.processors.CyberSource import ( ...@@ -26,6 +28,7 @@ from shoppingcart.processors.CyberSource import (
get_signed_purchase_params, get_purchase_endpoint get_signed_purchase_params, get_purchase_endpoint
) )
from verify_student.models import SoftwareSecurePhotoVerification from verify_student.models import SoftwareSecurePhotoVerification
import ssencrypt
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -115,7 +118,13 @@ def create_order(request): ...@@ -115,7 +118,13 @@ def create_order(request):
""" """
if not SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user): if not SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user):
attempt = SoftwareSecurePhotoVerification(user=request.user) attempt = SoftwareSecurePhotoVerification(user=request.user)
attempt.status = "ready" b64_face_image = request.POST['face_image'].split(",")[1]
b64_photo_id_image = request.POST['photo_id_image'].split(",")[1]
attempt.upload_face_image(b64_face_image.decode('base64'))
attempt.upload_photo_id_image(b64_photo_id_image.decode('base64'))
attempt.mark_ready()
attempt.save() attempt.save()
course_id = request.POST['course_id'] course_id = request.POST['course_id']
...@@ -149,6 +158,45 @@ def create_order(request): ...@@ -149,6 +158,45 @@ def create_order(request):
return HttpResponse(json.dumps(params), content_type="text/json") return HttpResponse(json.dumps(params), content_type="text/json")
@require_POST
@csrf_exempt # SS does its own message signing, and their API won't have a cookie value
def results_callback(request):
"""
Software Secure will call this callback to tell us whether a user is
verified to be who they said they are.
"""
body = request.body
body_dict = json.loads(body)
headers = {
"Authorization": request.META.get("HTTP_AUTHORIZATION", ""),
"Date": request.META.get("HTTP_DATE", "")
}
sig_valid = ssencrypt.has_valid_signature(
"POST",
headers,
body_dict,
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"],
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_SECRET_KEY"]
)
# if not sig_valid:
# return HttpResponseBadRequest(_("Signature is invalid"))
receipt_id = body_dict.get("EdX-ID")
result = body_dict.get("Result")
reason = body_dict.get("Reason", "")
error_code = body_dict.get("MessageType", "")
attempt = SoftwareSecurePhotoVerification.objects.get(receipt_id=receipt_id)
if result == "PASSED":
attempt.approve()
elif result == "FAILED":
attempt.deny(reason, error_code=error_code)
elif result == "SYSTEM FAIL":
log.error("Software Secure callback attempt for %s failed: %s", receipt_id, reason)
return HttpResponse("OK!")
@login_required @login_required
def show_requirements(request, course_id): def show_requirements(request, course_id):
...@@ -172,7 +220,6 @@ def show_requirements(request, course_id): ...@@ -172,7 +220,6 @@ def show_requirements(request, course_id):
def show_verification_page(request): def show_verification_page(request):
pass pass
def enroll(user, course_id, mode_slug): def enroll(user, course_id, mode_slug):
""" """
Enroll the user in a course for a certain mode. Enroll the user in a course for a certain mode.
...@@ -214,7 +261,6 @@ def enroll(user, course_id, mode_slug): ...@@ -214,7 +261,6 @@ def enroll(user, course_id, mode_slug):
# Create a VerifiedCertificate order item # Create a VerifiedCertificate order item
return HttpResponse.Redirect(reverse('verified')) return HttpResponse.Redirect(reverse('verified'))
# There's always at least one mode available (default is "honor"). If they # There's always at least one mode available (default is "honor"). If they
# haven't specified a mode, we just assume it's # haven't specified a mode, we just assume it's
if not mode: if not mode:
......
...@@ -32,7 +32,9 @@ var submitToPaymentProcessing = function() { ...@@ -32,7 +32,9 @@ var submitToPaymentProcessing = function() {
"/verify_student/create_order", "/verify_student/create_order",
{ {
"course_id" : course_id, "course_id" : course_id,
"contribution": contribution "contribution": contribution,
"face_image" : $("#face_image")[0].src,
"photo_id_image" : $("#photo_id_image")[0].src
}, },
function(data) { function(data) {
for (prop in data) { for (prop in data) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment