diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index cf4dadd1a0c6baec234c01d9faba441d60dde391..02934f3650968e54aacd965787257040084b6145 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -1,12 +1,15 @@ +""" +Views for the course_mode module +""" + import decimal from django.core.urlresolvers import reverse from django.http import ( - HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, Http404 + HttpResponseBadRequest, Http404 ) from django.shortcuts import redirect from django.views.generic.base import View from django.utils.translation import ugettext as _ -from django.utils.http import urlencode from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator @@ -18,10 +21,19 @@ from student.models import CourseEnrollment from student.views import course_from_id from verify_student.models import SoftwareSecurePhotoVerification -class ChooseModeView(View): +class ChooseModeView(View): + """ + View used when the user is asked to pick a mode + + When a get request is used, shows the selection page. + When a post request is used, assumes that it is a form submission + from the selection page, parses the response, and then sends user + to the next step in the flow + """ @method_decorator(login_required) def get(self, request, course_id, error=None): + """ Displays the course mode choice page """ if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified': return redirect(reverse('dashboard')) modes = CourseMode.modes_for_course_dict(course_id) @@ -34,8 +46,8 @@ class ChooseModeView(View): "course_id": course_id, "modes": modes, "course_name": course.display_name_with_default, - "course_org" : course.display_org_with_default, - "course_num" : course.display_number_with_default, + "course_org": course.display_org_with_default, + "course_num": course.display_number_with_default, "chosen_price": chosen_price, "error": error, } @@ -48,6 +60,7 @@ class ChooseModeView(View): @method_decorator(login_required) def post(self, request, course_id): + """ Takes the form submission from the page and parses it """ user = request.user # This is a bit redundant with logic in student.views.change_enrollement, @@ -102,6 +115,10 @@ class ChooseModeView(View): ) def get_requested_mode(self, user_choice): + """ + Given the text of `user_choice`, return the + corresponding course mode slug + """ choices = { "Select Audit": "audit", "Select Certificate": "verified" diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 5a1821de5417f4f77c058c66d1157d98e8b12110..80e05d09b13a8f828548d117910f44e573763f61 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -26,12 +26,11 @@ 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, encrypt_and_encode, generate_signed_message, rsa_encrypt ) @@ -57,15 +56,18 @@ def status_before_must_be(*valid_start_statuses): distracting boilerplate when looking at a Model that needs to go through a workflow process. """ - def decorator_func(fn): - @functools.wraps(fn) + def decorator_func(func): + """ + Decorator function that gets returned + """ + @functools.wraps(func) def with_status_check(obj, *args, **kwargs): if obj.status not in valid_start_statuses: exception_msg = ( u"Error calling {} {}: status is '{}', must be one of: {}" - ).format(fn, obj, obj.status, valid_start_statuses) + ).format(func, obj, obj.status, valid_start_statuses) raise VerificationException(exception_msg) - return fn(obj, *args, **kwargs) + return func(obj, *args, **kwargs) return with_status_check @@ -367,7 +369,7 @@ class PhotoVerification(StatusModel): Status should be moved to `must_retry`. """ if self.status in ["approved", "denied"]: - return # If we were already approved or denied, just leave it. + return # If we were already approved or denied, just leave it. self.error_msg = error_msg self.error_code = error_code @@ -408,7 +410,7 @@ 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 + IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds @status_before_must_be("created") def upload_face_image(self, img_data): @@ -444,8 +446,8 @@ class SoftwareSecurePhotoVerification(PhotoVerification): self.status = "must_retry" self.error_msg = response.text self.save() - except Exception as e: - log.exception(e) + except Exception as error: + log.exception(error) def image_url(self, name): """ @@ -466,7 +468,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): bucket = conn.get_bucket(settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["S3_BUCKET"]) key = Key(bucket) - key.key = "{}/{}".format(prefix, self.receipt_id); + key.key = "{}/{}".format(prefix, self.receipt_id) return key @@ -507,7 +509,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): "Content-Type": "application/json", "Date": formatdate(timeval=None, localtime=False, usegmt=True) } - message, _, authorization = generate_signed_message( + _message, _sig, authorization = generate_signed_message( "POST", headers, body, access_key, secret_key ) headers['Authorization'] = authorization @@ -515,16 +517,18 @@ class SoftwareSecurePhotoVerification(PhotoVerification): return headers, body def request_message_txt(self): + """ This is the body of the request we send across """ headers, body = self.create_request() header_txt = "\n".join( - "{}: {}".format(h, v) for h,v in sorted(headers.items()) + "{}: {}".format(h, v) for h, v in sorted(headers.items()) ) body_txt = json.dumps(body, indent=2, sort_keys=True, ensure_ascii=False).encode('utf-8') return header_txt + "\n\n" + body_txt def send_request(self): + """ sends the request across to the endpoint """ headers, body = self.create_request() response = requests.post( settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"], @@ -538,4 +542,4 @@ class SoftwareSecurePhotoVerification(PhotoVerification): 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 + return response diff --git a/lms/djangoapps/verify_student/ssencrypt.py b/lms/djangoapps/verify_student/ssencrypt.py index aefb4292a0c02fd6afdf38faf7c5ea3a14b7940a..bbf32d7a8be3e51a5e87932d5af3d45b2b0083ab 100644 --- a/lms/djangoapps/verify_student/ssencrypt.py +++ b/lms/djangoapps/verify_student/ssencrypt.py @@ -22,16 +22,11 @@ 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 supported. """ -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 @@ -39,12 +34,17 @@ from Crypto.PublicKey import RSA log = logging.getLogger(__name__) + def encrypt_and_encode(data, key): + """ Encrypts and endcodes `data` using `key' """ return base64.urlsafe_b64encode(aes_encrypt(data, key)) + def decode_and_decrypt(encoded_data, key): + """ Decrypts and decodes `data` using `key' """ return aes_decrypt(base64.urlsafe_b64decode(encoded_data), key) + def aes_encrypt(data, key): """ Return a version of the `data` that has been encrypted to @@ -53,11 +53,16 @@ def aes_encrypt(data, key): padded_data = pad(data) return cipher.encrypt(padded_data) + def aes_decrypt(encrypted_data, key): + """ + Decrypt `encrypted_data` using `key` + """ cipher = aes_cipher_from_key(key) padded_data = cipher.decrypt(encrypted_data) return unpad(padded_data) + def aes_cipher_from_key(key): """ Given an AES key, return a Cipher object that has `encrypt()` and @@ -66,6 +71,7 @@ def aes_cipher_from_key(key): """ return AES.new(key, AES.MODE_CBC, generate_aes_iv(key)) + def generate_aes_iv(key): """ Return the initialization vector Software Secure expects for a given AES @@ -73,17 +79,23 @@ def generate_aes_iv(key): """ return md5(key + md5(key).hexdigest()).hexdigest()[:AES.block_size] + def random_aes_key(): return Random.new().read(32) + def pad(data): + """ Pad the given `data` such that it fits into the proper AES block size """ bytes_to_pad = AES.block_size - len(data) % AES.block_size return data + (bytes_to_pad * chr(bytes_to_pad)) + def unpad(padded_data): + """ remove all padding from `padded_data` """ num_padded_bytes = ord(padded_data[-1]) return padded_data[:-num_padded_bytes] + def rsa_encrypt(data, rsa_pub_key_str): """ `rsa_pub_key` is a string with the public key @@ -93,11 +105,16 @@ def rsa_encrypt(data, rsa_pub_key_str): encrypted_data = cipher.encrypt(data) return encrypted_data + def rsa_decrypt(data, rsa_priv_key_str): + """ + When given some `data` and an RSA private key, decrypt the data + """ 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 @@ -123,6 +140,7 @@ def has_valid_signature(method, headers_dict, body_dict, access_key, secret_key) return True + def generate_signed_message(method, headers_dict, body_dict, access_key, secret_key): """ Returns a (message, signature) pair. @@ -137,6 +155,7 @@ def generate_signed_message(method, headers_dict, body_dict, access_key, secret_ message += '\n' return message, signature, authorization_header + def signing_format_message(method, headers_dict, body_dict): """ Given a dictionary of headers and a dictionary of the JSON for the body, @@ -149,6 +168,7 @@ def signing_format_message(method, headers_dict, body_dict): return message + def header_string(headers_dict): """Given a dictionary of headers, return a canonical string representation.""" header_list = [] @@ -160,7 +180,8 @@ def header_string(headers_dict): 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 + return "".join(header_list) # Note that trailing \n's are important + def body_string(body_dict, prefix=""): """ @@ -183,5 +204,5 @@ def body_string(body_dict, prefix=""): value = "null" body_list.append(u"{}{}:{}\n".format(prefix, key, value).encode('utf-8')) - return "".join(body_list) # Note that trailing \n's are important + return "".join(body_list) # Note that trailing \n's are important diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 52c55ad452d4494119810639119a89091e20b7aa..15c2cc5f6b293363dca24c109020b5090d706a16 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -35,11 +35,4 @@ urlpatterns = patterns( name="verify_student_results_callback", ), - url( - r'^show_verification_page/(?P<course_id>[^/]+/[^/]+/[^/]+)$', - views.show_verification_page, - name="verify_student/show_verification_page" - ), - - ) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 329e7efa102f6c7554000a836125180089c9b7f5..b220ff6a97d1db94c5c36332a9001bbb20d3cdda 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -1,5 +1,5 @@ """ - +Views for the verification flow """ import json @@ -37,6 +37,12 @@ class VerifyView(View): @method_decorator(login_required) def get(self, request, course_id): """ + Displays the main verification view, which contains three separate steps: + - Taking the standard face photo + - Taking the id photo + - Confirming that the photos and payment price are correct + before proceeding to payment + """ # If the user has already been verified within the given time period, # redirect straight to the payment -- no need to verify again. @@ -69,8 +75,8 @@ class VerifyView(View): "user_full_name": request.user.profile.name, "course_id": course_id, "course_name": course.display_name_with_default, - "course_org" : course.display_org_with_default, - "course_num" : course.display_number_with_default, + "course_org": course.display_org_with_default, + "course_num": course.display_number_with_default, "purchase_endpoint": get_purchase_endpoint(), "suggested_prices": [ decimal.Decimal(price) @@ -106,8 +112,8 @@ class VerifiedView(View): context = { "course_id": course_id, "course_name": course.display_name_with_default, - "course_org" : course.display_org_with_default, - "course_num" : course.display_number_with_default, + "course_org": course.display_org_with_default, + "course_num": course.display_number_with_default, "purchase_endpoint": get_purchase_endpoint(), "currency": verify_mode.currency.upper(), "chosen_price": chosen_price, @@ -162,8 +168,9 @@ def create_order(request): 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 +@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 @@ -194,7 +201,7 @@ def results_callback(request): settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_SECRET_KEY"] ) - _, access_key_and_sig = headers["Authorization"].split(" ") + _response, access_key_and_sig = headers["Authorization"].split(" ") access_key = access_key_and_sig.split(":")[0] # This is what we should be doing... @@ -234,10 +241,11 @@ def results_callback(request): return HttpResponse("OK!") + @login_required def show_requirements(request, course_id): """ - Show the requirements necessary for + Show the requirements necessary for the verification flow. """ if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified': return redirect(reverse('dashboard')) @@ -246,69 +254,8 @@ def show_requirements(request, course_id): context = { "course_id": course_id, "course_name": course.display_name_with_default, - "course_org" : course.display_org_with_default, - "course_num" : course.display_number_with_default, + "course_org": course.display_org_with_default, + "course_num": course.display_number_with_default, "is_not_active": not request.user.is_active, } return render_to_response("verify_student/show_requirements.html", context) - - -def show_verification_page(request): - pass - -def enroll(user, course_id, mode_slug): - """ - Enroll the user in a course for a certain mode. - - This is the view you send folks to when they click on the enroll button. - This does NOT cover changing enrollment modes -- it's intended for new - enrollments only, and will just redirect to the dashboard if it detects - that an enrollment already exists. - """ - # If the user is already enrolled, jump to the dashboard. Yeah, we could - # do upgrades here, but this method is complicated enough. - if CourseEnrollment.is_enrolled(user, course_id): - return HttpResponseRedirect(reverse('dashboard')) - - available_modes = CourseModes.modes_for_course(course_id) - - # If they haven't chosen a mode... - if not mode_slug: - # Does this course support multiple modes of Enrollment? If so, redirect - # to a page that lets them choose which mode they want. - if len(available_modes) > 1: - return HttpResponseRedirect( - reverse('choose_enroll_mode', kwargs={'course_id': course_id}) - ) - # Otherwise, we use the only mode that's supported... - else: - mode_slug = available_modes[0].slug - - # If the mode is one of the simple, non-payment ones, do the enrollment and - # send them to their dashboard. - if mode_slug in ("honor", "audit"): - CourseEnrollment.enroll(user, course_id, mode=mode_slug) - return HttpResponseRedirect(reverse('dashboard')) - - if mode_slug == "verify": - if SoftwareSecurePhotoVerification.has_submitted_recent_request(user): - # Capture payment info - # Create an order - # Create a VerifiedCertificate order item - return HttpResponse.Redirect(reverse('verified')) - - # There's always at least one mode available (default is "honor"). If they - # haven't specified a mode, we just assume it's - if not mode: - mode = available_modes[0] - - elif len(available_modes) == 1: - if mode != available_modes[0]: - raise Exception() - - mode = available_modes[0] - - if mode == "honor": - CourseEnrollment.enroll(user, course_id) - return HttpResponseRedirect(reverse('dashboard')) -