models.py 36 KB
Newer Older
1
# -*- coding: utf-8 -*-
2 3 4
"""
Models for Student Identity Verification

5 6
This is where we put any models relating to establishing the real-life identity
of a student over a period of time. Right now, the only models are the abstract
7 8
`PhotoVerification`, and its one concrete implementation
`SoftwareSecurePhotoVerification`. The hope is to keep as much of the
9
photo verification process as generic as possible.
10
"""
11
from datetime import datetime, timedelta
12
from email.utils import formatdate
13
import functools
14
import json
15 16 17
import logging
import uuid

18 19
from boto.s3.connection import S3Connection
from boto.s3.key import Key
20
import pytz
21
import requests
22

23
from django.conf import settings
24
from django.core.urlresolvers import reverse
25 26
from django.db import models
from django.contrib.auth.models import User
27
from django.utils.translation import ugettext as _
28 29 30
from model_utils.models import StatusModel
from model_utils import Choices

31
from verify_student.ssencrypt import (
32
    random_aes_key, encrypt_and_encode,
33
    generate_signed_message, rsa_encrypt
34 35
)

36 37
from reverification.models import MidcourseReverificationWindow

38 39
log = logging.getLogger(__name__)

40

41
def generateUUID():  # pylint: disable=invalid-name
42
    """ Utility function; generates UUIDs """
43
    return str(uuid.uuid4())
44

45

46 47 48 49 50 51
class VerificationException(Exception):
    pass


def status_before_must_be(*valid_start_statuses):
    """
52
    Helper decorator with arguments to make sure that an object with a `status`
53 54 55 56 57 58 59 60 61 62 63 64
    attribute is in one of a list of acceptable status states before a method
    is called. You could use it in a class definition like:

        @status_before_must_be("submitted", "approved", "denied")
        def refund_user(self, user_id):
            # Do logic here...

    If the object has a status that is not listed when the `refund_user` method
    is invoked, it will throw a `VerificationException`. This is just to avoid
    distracting boilerplate when looking at a Model that needs to go through a
    workflow process.
    """
65 66 67 68 69
    def decorator_func(func):
        """
        Decorator function that gets returned
        """
        @functools.wraps(func)
70 71 72 73
        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: {}"
74
                ).format(func, obj, obj.status, valid_start_statuses)
75
                raise VerificationException(exception_msg)
76
            return func(obj, *args, **kwargs)
77 78 79 80 81 82

        return with_status_check

    return decorator_func


83
class PhotoVerification(StatusModel):
84
    """
85
    Each PhotoVerification represents a Student's attempt to establish
86 87 88
    their identity by uploading a photo of themselves and a picture ID. An
    attempt actually has a number of fields that need to be filled out at
    different steps of the approval process. While it's useful as a Django Model
89 90 91 92
    for the querying facilities, **you should only edit a `PhotoVerification`
    object through the methods provided**. Initialize them with a user:

    attempt = PhotoVerification(user=user)
93 94 95 96 97 98 99 100 101 102 103 104

    We track this attempt through various states:

    `created`
        Initial creation and state we're in after uploading the images.
    `ready`
        The user has uploaded their images and checked that they can read the
        images. There's a separate state here because it may be the case that we
        don't actually submit this attempt for review until payment is made.
    `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.
105 106 107
    `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)
108 109 110 111 112
    `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.
    `denied`
        The request has been denied. See `error_msg` for details on why. An
113
        admin might later override this and change to `approved`, but the
114 115 116 117
        student cannot re-open this attempt -- they have to create another
        attempt and submit it instead.

    Because this Model inherits from StatusModel, we can also do things like::
118

119
        attempt.status == PhotoVerification.STATUS.created
120
        attempt.status == "created"
121
        pending_requests = PhotoVerification.submitted.all()
122 123 124
    """
    ######################## Fields Set During Creation ########################
    # See class docstring for description of status states
125
    STATUS = Choices('created', 'ready', 'submitted', 'must_retry', 'approved', 'denied')
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
    user = models.ForeignKey(User, db_index=True)

    # They can change their name later on, so we want to copy the value here so
    # we always preserve what it was at the time they requested. We only copy
    # this value during the mark_ready() step. Prior to that, you should be
    # displaying the user's name from their user.profile.name.
    name = models.CharField(blank=True, max_length=255)

    # Where we place the uploaded image files (e.g. S3 URLs)
    face_image_url = models.URLField(blank=True, max_length=255)
    photo_id_image_url = models.URLField(blank=True, max_length=255)

    # Randomly generated UUID so that external services can post back the
    # results of checking a user's photo submission without use exposing actual
    # user IDs or something too easily guessable.
    receipt_id = models.CharField(
        db_index=True,
143
        default=lambda: generateUUID(),
144 145 146 147 148 149
        max_length=255,
    )

    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
    updated_at = models.DateTimeField(auto_now=True, db_index=True)

150 151 152 153 154
    # Indicates whether or not a user wants to see the verification status
    # displayed on their dash.  Right now, only relevant for allowing students
    # to "dismiss" a failed midcourse reverification message
    display = models.BooleanField(db_index=True, default=True)

155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
    ######################## Fields Set When Submitting ########################
    submitted_at = models.DateTimeField(null=True, db_index=True)

    #################### Fields Set During Approval/Denial #####################
    # If the review was done by an internal staff member, mark who it was.
    reviewing_user = models.ForeignKey(
        User,
        db_index=True,
        default=None,
        null=True,
        related_name="photo_verifications_reviewed"
    )

    # Mark the name of the service used to evaluate this attempt (e.g
    # Software Secure).
    reviewing_service = models.CharField(blank=True, max_length=255)

    # If status is "denied", this should contain text explaining why.
    error_msg = models.TextField(blank=True)

    # Non-required field. External services can add any arbitrary codes as time
    # goes on. We don't try to define an exhuastive list -- this is just
    # capturing it so that we can later query for the common problems.
    error_code = models.CharField(blank=True, max_length=50)

stv committed
180
    class Meta(object):  # pylint: disable=missing-docstring
181
        abstract = True
182
        ordering = ['-created_at']
183 184 185

    ##### Methods listed in the order you'd typically call them
    @classmethod
186 187 188 189 190
    def _earliest_allowed_date(cls):
        """
        Returns the earliest allowed date given the settings

        """
191 192
        days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]
        return datetime.now(pytz.UTC) - timedelta(days=days_good_for)
193 194

    @classmethod
195
    def user_is_verified(cls, user, earliest_allowed_date=None, window=None):
196
        """
Julia Hansbrough committed
197 198 199 200 201 202 203
        Return whether or not a user has satisfactorily proved their identity.
        Depending on the policy, this can expire after some period of time, so
        a user might have to renew periodically.

        If window=None, then this will check for the user's *initial* verification.
        If window is set to anything else, it will check for the reverification
        associated with that window.
204 205 206 207
        """
        return cls.objects.filter(
            user=user,
            status="approved",
208
            created_at__gte=(earliest_allowed_date
209 210
                             or cls._earliest_allowed_date()),
            window=window
211 212 213
        ).exists()

    @classmethod
214
    def user_has_valid_or_pending(cls, user, earliest_allowed_date=None, window=None, queryset=None):
215
        """
216 217 218 219 220
        Return whether the user has a complete verification attempt that is or
        *might* be good. This means that it's approved, been submitted, or would
        have been submitted but had an non-user error when it was being
        submitted. It's basically any situation in which the user has signed off
        on the contents of the attempt, and we have not yet received a denial.
Julia Hansbrough committed
221 222 223 224

        If window=None, this will check for the user's *initial* verification.  If
        window is anything else, this will check for the reverification associated
        with that window.
225 226

        If a queryset is provided, that will be used instead of hitting the database.
227
        """
228 229 230
        valid_statuses = ['submitted', 'approved']
        if not window:
            valid_statuses.append('must_retry')
231 232 233 234
        if queryset is None:
            queryset = cls.objects.filter(user=user)

        return queryset.filter(
235
            status__in=valid_statuses,
236 237 238 239
            created_at__gte=(
                earliest_allowed_date
                or cls._earliest_allowed_date()
            ),
240
            window=window,
241
        ).exists()
242 243

    @classmethod
244
    def active_for_user(cls, user, window=None):
245
        """
Julia Hansbrough committed
246
        Return the most recent PhotoVerification that is marked ready (i.e. the
247
        user has said they're set, but we haven't submitted anything yet).
Julia Hansbrough committed
248 249 250

        If window=None, this checks for the original verification.  If window is set to
        anything else, this will check for the reverification associated with that window.
251
        """
252 253
        # 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.
254
        active_attempts = cls.objects.filter(user=user, status='ready', window=window).order_by('-created_at')
255 256 257 258
        if active_attempts:
            return active_attempts[0]
        else:
            return None
259

260
    @classmethod
261
    def user_status(cls, user, window=None):
262
        """
263
        Returns the status of the user based on their past verification attempts
264 265 266

        If no such verification exists, returns 'none'
        If verification has expired, returns 'expired'
267 268 269
        If the verification has been approved, returns 'approved'
        If the verification process is still ongoing, returns 'pending'
        If the verification has been denied and the user must resubmit photos, returns 'must_reverify'
Julia Hansbrough committed
270 271 272

        If window=None, this checks initial verifications
        If window is set, this checks for the reverification associated with that window
273
        """
274 275 276
        status = 'none'
        error_msg = ''

277
        if cls.user_is_verified(user, window=window):
278
            status = 'approved'
279 280

        elif cls.user_has_valid_or_pending(user, window=window):
281 282 283
            # user_has_valid_or_pending does include 'approved', but if we are
            # here, we know that the attempt is still pending
            status = 'pending'
284

285 286 287 288
        else:
            # we need to check the most recent attempt to see if we need to ask them to do
            # a retry
            try:
289
                attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at')
290 291
                attempt = attempts[0]
            except IndexError:
292 293 294 295 296 297 298 299 300

                # If no verification exists for a *midcourse* reverification, then that just
                # means the student still needs to reverify.  For *original* verifications,
                # we return 'none'
                if(window):
                    return('must_reverify', error_msg)
                else:
                    return ('none', error_msg)

301
            if attempt.created_at < cls._earliest_allowed_date():
302 303 304 305
                return (
                    'expired',
                    _("Your {platform_name} verification has expired.").format(platform_name=settings.PLATFORM_NAME)
                )
306

307 308
            # If someone is denied their original verification attempt, they can try to reverify.
            # However, if a midcourse reverification is denied, that denial is permanent.
309
            if attempt.status == 'denied':
310 311 312 313
                if window is None:
                    status = 'must_reverify'
                else:
                    status = 'denied'
314 315 316 317 318
            if attempt.error_msg:
                error_msg = attempt.parsed_error_msg()

        return (status, error_msg)

319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378
    @classmethod
    def verification_for_datetime(cls, deadline, candidates):
        """Find a verification in a set that applied during a particular datetime.

        A verification is considered "active" during a datetime if:
        1) The verification was created before the datetime, and
        2) The verification is set to expire after the datetime.

        Note that verification status is *not* considered here,
        just the start/expire dates.

        If multiple verifications were active at the deadline,
        returns the most recently created one.

        Arguments:
            deadline (datetime): The datetime at which the verification applied.
                If `None`, then return the most recently created candidate.
            candidates (list of `PhotoVerification`s): Potential verifications to search through.

        Returns:
            PhotoVerification: A photo verification that was active at the deadline.
                If no verification was active, return None.

        """
        if len(candidates) == 0:
            return None

        # If there's no deadline, then return the most recently created verification
        if deadline is None:
            return candidates[0]

        # Otherwise, look for a verification that was in effect at the deadline,
        # preferring recent verifications.
        # If no such verification is found, implicitly return `None`
        for verification in candidates:
            if verification.active_at_datetime(deadline):
                return verification

    @property
    def expiration_datetime(self):
        """Datetime that the verification will expire. """
        days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]
        return self.created_at + timedelta(days=days_good_for)

    def active_at_datetime(self, deadline):
        """Check whether the verification was active at a particular datetime.

        Arguments:
            deadline (datetime): The date at which the verification was active
                (created before and expired after).

        Returns:
            bool

        """
        return (
            self.created_at < deadline and
            self.expiration_datetime > deadline
        )

379
    def parsed_error_msg(self):
380 381 382 383 384 385 386 387
        """
        Sometimes, the error message we've received needs to be parsed into
        something more human readable

        The default behavior is to return the current error message as is.
        """
        return self.error_msg

388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429
    @status_before_must_be("created")
    def upload_face_image(self, img):
        raise NotImplementedError

    @status_before_must_be("created")
    def upload_photo_id_image(self, img):
        raise NotImplementedError

    @status_before_must_be("created")
    def mark_ready(self):
        """
        Mark that the user data in this attempt is correct. In order to
        succeed, the user must have uploaded the necessary images
        (`face_image_url`, `photo_id_image_url`). This method will also copy
        their name from their user profile. Prior to marking it ready, we read
        this value directly from their profile, since they're free to change it.
        This often happens because people put in less formal versions of their
        name on signup, but realize they want something different to go on a
        formal document.

        Valid attempt statuses when calling this method:
            `created`

        Status after method completes: `ready`

        Other fields that will be set by this method:
            `name`

        State Transitions:

        `created` → `ready`
            This is what happens when the user confirms to us that the pictures
            they uploaded are good. Note that we don't actually do a submission
            anywhere yet.
        """
        # 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
        # attempt.
        self.name = self.user.profile.name
        self.status = "ready"
        self.save()

430
    @status_before_must_be("must_retry", "submitted", "approved", "denied")
431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461
    def approve(self, user_id=None, service=""):
        """
        Approve this attempt. `user_id`

        Valid attempt statuses when calling this method:
            `submitted`, `approved`, `denied`

        Status after method completes: `approved`

        Other fields that will be set by this method:
            `reviewed_by_user_id`, `reviewed_by_service`, `error_msg`

        State Transitions:

        `submitted` → `approved`
            This is the usual flow, whether initiated by a staff user or an
            external validation service.
        `approved` → `approved`
            No-op. First one to approve it wins.
        `denied` → `approved`
            This might happen if a staff member wants to override a decision
            made by an external service or another staff member (say, in
            response to a support request). In this case, the previous values
            of `reviewed_by_user_id` and `reviewed_by_service` will be changed
            to whoever is doing the approving, and `error_msg` will be reset.
            The only record that this record was ever denied would be in our
            logs. This should be a relatively rare occurence.
        """
        # If someone approves an outdated version of this, the first one wins
        if self.status == "approved":
            return
462

463 464 465
        log.info(u"Verification for user '{user_id}' approved by '{reviewer}'.".format(
            user_id=self.user, reviewer=user_id
        ))
466
        self.error_msg = ""  # reset, in case this attempt was denied before
467
        self.error_code = ""  # reset, in case this attempt was denied before
468 469 470 471 472
        self.reviewing_user = user_id
        self.reviewing_service = service
        self.status = "approved"
        self.save()

473
    @status_before_must_be("must_retry", "submitted", "approved", "denied")
474 475 476 477 478 479 480 481 482 483 484 485 486 487
    def deny(self,
             error_msg,
             error_code="",
             reviewing_user=None,
             reviewing_service=""):
        """
        Deny this attempt.

        Valid attempt statuses when calling this method:
            `submitted`, `approved`, `denied`

        Status after method completes: `denied`

        Other fields that will be set by this method:
488 489
            `reviewed_by_user_id`, `reviewed_by_service`, `error_msg`,
            `error_code`
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508

        State Transitions:

        `submitted` → `denied`
            This is the usual flow, whether initiated by a staff user or an
            external validation service.
        `approved` → `denied`
            This might happen if a staff member wants to override a decision
            made by an external service or another staff member, or just correct
            a mistake made during the approval process. In this case, the
            previous values of `reviewed_by_user_id` and `reviewed_by_service`
            will be changed to whoever is doing the denying. The only record
            that this record was ever approved would be in our logs. This should
            be a relatively rare occurence.
        `denied` → `denied`
            Update the error message and reviewing_user/reviewing_service. Just
            lets you amend the error message in case there were additional
            details to be made.
        """
509 510 511
        log.info(u"Verification for user '{user_id}' denied by '{reviewer}'.".format(
            user_id=self.user, reviewer=reviewing_user
        ))
512 513 514 515 516 517 518
        self.error_msg = error_msg
        self.error_code = error_code
        self.reviewing_user = reviewing_user
        self.reviewing_service = reviewing_service
        self.status = "denied"
        self.save()

519 520 521 522 523 524 525 526
    @status_before_must_be("must_retry", "submitted", "approved", "denied")
    def system_error(self,
                     error_msg,
                     error_code="",
                     reviewing_user=None,
                     reviewing_service=""):
        """
        Mark that this attempt could not be completed because of a system error.
527 528 529
        Status should be moved to `must_retry`. For example, if Software Secure
        reported to us that they couldn't process our submission because they
        couldn't decrypt the image we sent.
530 531
        """
        if self.status in ["approved", "denied"]:
532
            return  # If we were already approved or denied, just leave it.
533 534 535 536 537 538 539 540

        self.error_msg = error_msg
        self.error_code = error_code
        self.reviewing_user = reviewing_user
        self.reviewing_service = reviewing_service
        self.status = "must_retry"
        self.save()

541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562
    @classmethod
    def display_off(cls, user_id):
        """
        Find all failed PhotoVerifications for a user, and sets those verifications' `display`
        property to false, so the notification banner can be switched off.
        """
        user = User.objects.get(id=user_id)
        cls.objects.filter(user=user, status="denied").exclude(window=None).update(display=False)

    @classmethod
    def display_status(cls, user, window):
        """
        Finds the `display` property for the PhotoVerification associated with
        (user, window). Default is True
        """
        attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at')
        try:
            attempt = attempts[0]
            return attempt.display
        except IndexError:
            return True

563

564
class SoftwareSecurePhotoVerification(PhotoVerification):
565 566
    """
    Model to verify identity using a service provided by Software Secure. Much
567
    of the logic is inherited from `PhotoVerification`, but this class
568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587
    encrypts the photos.

    Software Secure (http://www.softwaresecure.com/) is a remote proctoring
    service that also does identity verification. A student uses their webcam
    to upload two images: one of their face, one of a photo ID. Due to the
    sensitive nature of the data, the following security precautions are taken:

    1. The snapshot of their face is encrypted using AES-256 in CBC mode. All
       face photos are encypted with the same key, and this key is known to
       both Software Secure and edx-platform.

    2. The snapshot of a user's photo ID is also encrypted using AES-256, but
       the key is randomly generated using pycrypto's Random. Every verification
       attempt has a new key. The AES key is then encrypted using a public key
       provided by Software Secure. We store only the RSA-encryped AES key.
       Since edx-platform does not have Software Secure's private RSA key, it
       means that we can no longer even read photo ID.

    3. The encrypted photos are base64 encoded and stored in an S3 bucket that
       edx-platform does not have read access to.
588 589 590 591 592 593

    Note: this model handles both *inital* verifications (which you must perform
    at the time you register for a verified cert), and *midcourse reverifications*.
    To distinguish between the two, check the value of the property window:
    intial verifications of a window of None, whereas midcourse reverifications
    * must always be linked to a specific window*.
594 595 596 597 598 599 600
    """
    # This is a base64.urlsafe_encode(rsa_encrypt(photo_id_aes_key), ss_pub_key)
    # So first we generate a random AES-256 key to encrypt our photo ID with.
    # Then we RSA encrypt it with Software Secure's public key. Then we base64
    # encode that. The result is saved here. Actual expected length is 344.
    photo_id_key = models.TextField(max_length=1024)

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

603 604 605 606 607 608 609 610 611 612 613 614 615
    window = models.ForeignKey(MidcourseReverificationWindow, db_index=True, null=True)

    @classmethod
    def user_is_reverified_for_all(cls, course_id, user):
        """
        Checks to see if the student has successfully reverified for all of the
        mandatory re-verification windows associated with a course.

        This is used primarily by the certificate generation code... if the user is
        not re-verified for all windows, then they cannot receive a certificate.
        """
        all_windows = MidcourseReverificationWindow.objects.filter(course_id=course_id)
        # if there are no windows for a course, then return True right off
616
        if (not all_windows.exists()):
617 618 619 620 621 622 623 624 625 626
            return True

        for window in all_windows:
            try:
                # The status of the most recent reverification for each window must be "approved"
                # for a student to count as completely reverified
                attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at')
                attempt = attempts[0]
                if attempt.status != "approved":
                    return False
627
            except Exception:  # pylint: disable=broad-except
628 629 630
                log.exception(
                    u"An error occurred while checking re-verification for user '{user_id}'".format(user_id=user)
                )
631 632 633 634 635 636 637 638 639 640 641 642
                return False

        return True

    @classmethod
    def original_verification(cls, user):
        """
        Returns the most current SoftwareSecurePhotoVerification object associated with the user.
        """
        query = cls.objects.filter(user=user, window=None).order_by('-updated_at')
        return query[0]

643 644
    @status_before_must_be("created")
    def upload_face_image(self, img_data):
645 646 647 648 649 650 651 652 653 654 655 656
        """
        Upload an image of the user's face to S3. `img_data` should be a raw
        bytestream of a PNG image. This method will take the data, encrypt it
        using our FACE_IMAGE_AES_KEY, encode it with base64 and save it to S3.

        Yes, encoding it to base64 adds compute and disk usage without much real
        benefit, but that's what the other end of this API is expecting to get.
        """
        # Skip this whole thing if we're running acceptance tests or if we're
        # developing and aren't interested in working on student identity
        # verification functionality. If you do want to work on it, you have to
        # explicitly enable these in your private settings.
657
        if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
658 659
            return

660 661 662
        aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
        aes_key = aes_key_str.decode("hex")

663
        s3_key = self._generate_s3_key("face")
664
        s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key))
665 666

    @status_before_must_be("created")
667 668 669 670 671 672 673 674 675 676 677 678 679
    def fetch_photo_id_image(self):
        """
        Find the user's photo ID image, which was submitted with their original verification.
        The image has already been encrypted and stored in s3, so we just need to find that
        location
        """
        if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
            return

        self.photo_id_key = self.original_verification(self.user).photo_id_key
        self.save()

    @status_before_must_be("created")
680
    def upload_photo_id_image(self, img_data):
681 682 683 684 685 686 687 688 689 690 691 692 693 694
        """
        Upload an the user's photo ID image to S3. `img_data` should be a raw
        bytestream of a PNG image. This method will take the data, encrypt it
        using a randomly generated AES key, encode it with base64 and save it to
        S3. The random key is also encrypted using Software Secure's public RSA
        key and stored in our `photo_id_key` field.

        Yes, encoding it to base64 adds compute and disk usage without much real
        benefit, but that's what the other end of this API is expecting to get.
        """
        # Skip this whole thing if we're running acceptance tests or if we're
        # developing and aren't interested in working on student identity
        # verification functionality. If you do want to work on it, you have to
        # explicitly enable these in your private settings.
695
        if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
696 697
            return

698
        aes_key = random_aes_key()
699 700
        rsa_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
        rsa_encrypted_aes_key = rsa_encrypt(aes_key, rsa_key_str)
701 702

        # Upload this to S3
703
        s3_key = self._generate_s3_key("photo_id")
704 705 706 707
        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')
708
        self.save()
709 710 711

    @status_before_must_be("must_retry", "ready", "submitted")
    def submit(self):
712 713 714 715 716
        """
        Submit our verification attempt to Software Secure for validation. This
        will set our status to "submitted" if the post is successful, and
        "must_retry" if the post fails.
        """
717 718 719 720 721 722 723 724 725 726
        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()
727 728
        except Exception as error:
            log.exception(error)
729 730
            self.status = "must_retry"
            self.save()
731

732
    def parsed_error_msg(self):
733 734 735 736 737 738 739 740 741
        """
        Parse the error messages we receive from SoftwareSecure

        Error messages are written in the form:

            `[{"photoIdReasons": ["Not provided"]}]`

        Returns a list of error messages
        """
742 743 744
        # Translates the category names and messages into something more human readable
        message_dict = {
            ("photoIdReasons", "Not provided"): _("No photo ID was provided."),
745
            ("photoIdReasons", "Text not clear"): _("We couldn't read your name from your photo ID image."),
746 747
            ("generalReasons", "Name mismatch"): _("The name associated with your account and the name on your ID do not match."),
            ("userPhotoReasons", "Image not clear"): _("The image of your face was not clear."),
748
            ("userPhotoReasons", "Face out of view"): _("Your face was not visible in your self-photo."),
749 750 751 752 753 754 755 756
        }

        try:
            msg_json = json.loads(self.error_msg)
            msg_dict = msg_json[0]

            msg = []
            for category in msg_dict:
757 758 759 760
                # find the messages associated with this category
                category_msgs = msg_dict[category]
                for category_msg in category_msgs:
                    msg.append(message_dict[(category, category_msg)])
761 762 763 764
            return u", ".join(msg)
        except (ValueError, KeyError):
            # if we can't parse the message as JSON or the category doesn't
            # match one of our known categories, show a generic error
765
            log.error('PhotoVerification: Error parsing this error message: %s', self.error_msg)
766 767
            return _("There was an error verifying your ID photos.")

768 769 770 771 772
    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.
        """
773
        s3_key = self._generate_s3_key(name)
774 775
        return s3_key.generate_url(self.IMAGE_LINK_DURATION)

776
    def _generate_s3_key(self, prefix):
777
        """
778 779
        Generates a key for an s3 bucket location

780
        Example: face/4dd1add9-6719-42f7-bea0-115c008c4fca
781 782 783 784 785 786 787 788
        """
        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)
789
        key.key = "{}/{}".format(prefix, self.receipt_id)
790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818

        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),
819
            "ExpectedName": self.name,
820 821 822 823 824 825 826 827 828 829
            "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)
        }
830
        _message, _sig, authorization = generate_signed_message(
831 832 833 834 835
            "POST", headers, body, access_key, secret_key
        )
        headers['Authorization'] = authorization

        return headers, body
836

837
    def request_message_txt(self):
838 839 840 841 842 843 844
        """
        This is the body of the request we send across. This is never actually
        used in the code, but exists for debugging purposes -- you can call
        `print attempt.request_message_txt()` on the console and get a readable
        rendering of the request that would be sent across, without actually
        sending anything.
        """
845 846 847
        headers, body = self.create_request()

        header_txt = "\n".join(
848
            "{}: {}".format(h, v) for h, v in sorted(headers.items())
849
        )
850
        body_txt = json.dumps(body, indent=2, sort_keys=True, ensure_ascii=False).encode('utf-8')
851 852 853 854

        return header_txt + "\n\n" + body_txt

    def send_request(self):
855 856 857 858 859 860 861 862 863 864
        """
        Assembles a submission to Software Secure and sends it via HTTPS.

        Returns a request.Response() object with the reply we get from SS.
        """
        # If AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING is True, we want to
        # skip posting anything to Software Secure. We actually don't even
        # create the message because that would require encryption and message
        # signing that rely on settings.VERIFY_STUDENT values that aren't set
        # in dev. So we just pretend like we successfully posted
865
        if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
866 867 868 869
            fake_response = requests.Response()
            fake_response.status_code = 200
            return fake_response

870 871 872 873
        headers, body = self.create_request()
        response = requests.post(
            settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"],
            headers=headers,
874 875
            data=json.dumps(body, indent=2, sort_keys=True, ensure_ascii=False).encode('utf-8'),
            verify=False
876 877 878 879 880 881 882
        )
        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))

883
        return response