Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions lms/djangoapps/shoppingcart/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from student.views import course_from_id
from student.models import CourseEnrollment
from statsd import statsd
from verify_student.models import SoftwareSecurePhotoVerification
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor

Expand Down Expand Up @@ -369,6 +370,14 @@ def purchased_callback(self):
"""
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.save()
self.course_enrollment.activate()
Expand Down
4 changes: 4 additions & 0 deletions lms/djangoapps/verify_student/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from ratelimitbackend import admin
from verify_student.models import SoftwareSecurePhotoVerification

admin.site.register(SoftwareSecurePhotoVerification)
169 changes: 140 additions & 29 deletions lms/djangoapps/verify_student/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,30 @@
photo verification process as generic as possible.
"""
from datetime import datetime, timedelta
from email.utils import formatdate
from hashlib import md5
import base64
import functools
import json
import logging
import uuid

from boto.s3.connection import S3Connection
from boto.s3.key import Key
import pytz
import requests

from django.conf import settings
from django.core.urlresolvers import reverse
from django.db import models
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from model_utils.models import StatusModel
from model_utils import Choices

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__)
Expand Down Expand Up @@ -86,6 +94,9 @@ class PhotoVerification(StatusModel):
`submitted`
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.
`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`
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.
Expand All @@ -106,7 +117,7 @@ class PhotoVerification(StatusModel):

######################## Fields Set During Creation ########################
# 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)

# They can change their name later on, so we want to copy the value here so
Expand Down Expand Up @@ -183,7 +194,7 @@ def user_has_valid_or_pending(cls, user, earliest_allowed_date=None):
"""
TODO: eliminate duplication with user_is_verified
"""
valid_statuses = ['ready', 'submitted', 'approved']
valid_statuses = ['must_retry', 'submitted', 'approved']
earliest_allowed_date = (
earliest_allowed_date or
datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR)
Expand All @@ -205,7 +216,7 @@ def active_for_user(cls, user):
"""
# 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.
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:
return active_attempts[0]
else:
Expand Down Expand Up @@ -246,10 +257,10 @@ def mark_ready(self):
they uploaded are good. Note that we don't actually do a submission
anywhere yet.
"""
if not self.face_image_url:
raise VerificationException("No face image was uploaded.")
if not self.photo_id_image_url:
raise VerificationException("No photo ID image was uploaded.")
# if not self.face_image_url:
# raise VerificationException("No face image was uploaded.")
# if not self.photo_id_image_url:
# raise VerificationException("No photo ID image was uploaded.")

# 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
Expand All @@ -258,18 +269,11 @@ def mark_ready(self):
self.status = "ready"
self.save()

@status_before_must_be("ready", "submit")
def submit(self, reviewing_service=None):
if self.status == "submitted":
return

if reviewing_service:
reviewing_service.submit(self)
self.submitted_at = datetime.now(pytz.UTC)
self.status = "submitted"
self.save()
@status_before_must_be("must_retry", "ready", "submitted")
def submit(self):
raise NotImplementedError

@status_before_must_be("submitted", "approved", "denied")
@status_before_must_be("must_retry", "submitted", "approved", "denied")
def approve(self, user_id=None, service=""):
"""
Approve this attempt. `user_id`
Expand Down Expand Up @@ -309,7 +313,7 @@ def approve(self, user_id=None, service=""):
self.status = "approved"
self.save()

@status_before_must_be("submitted", "approved", "denied")
@status_before_must_be("must_retry", "submitted", "approved", "denied")
def deny(self,
error_msg,
error_code="",
Expand Down Expand Up @@ -384,25 +388,132 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
# encode that. The result is saved here. Actual expected length is 344.
photo_id_key = models.TextField(max_length=1024)

IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds

@status_before_must_be("created")
def upload_face_image(self, img_data):
aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
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")
def upload_photo_id_image(self, img_data):
aes_key = random_aes_key()
encrypted_img_data = self._encrypt_image_data(img_data, aes_key)
b64_encoded_img_data = base64.encodestring(encrypted_img_data)
rsa_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
rsa_encrypted_aes_key = rsa_encrypt(aes_key, rsa_key_str)

# 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"]

scheme = "https" if settings.HTTPS == "on" else "http"
callback_url = "{}://{}{}".format(
scheme, settings.SITE_NAME, reverse('verify_student_results_callback')
)

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

rsa_key = RSA.importKey(
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
def request_message_txt(self):
headers, body = self.create_request()

header_txt = "\n".join(
"{}: {}".format(h, v) for h,v in sorted(headers.items())
)
rsa_cipher = PKCS1_OAEP.new(key)
rsa_encrypted_aes_key = rsa_cipher.encrypt(aes_key)
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
79 changes: 78 additions & 1 deletion lms/djangoapps/verify_student/ssencrypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,22 @@
according to a certain pass phrase. Only OpenSSL-compatible pass phrases are
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 binascii
import json
import hmac
import logging
import sys

from Crypto import Random
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA

log = logging.getLogger(__name__)

def encrypt_and_encode(data, key):
return base64.urlsafe_b64encode(aes_encrypt(data, key))
Expand Down Expand Up @@ -88,3 +97,71 @@ def rsa_decrypt(data, rsa_priv_key_str):
key = RSA.importKey(rsa_priv_key_str)
cipher = PKCS1_OAEP.new(key)
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

Loading