diff --git a/cms/envs/common.py b/cms/envs/common.py index 886ab13887..512ab943b7 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -989,3 +989,8 @@ CREDIT_TASK_DEFAULT_RETRY_DELAY = 30 # Maximum number of retries per task for errors that are not related # to throttling. CREDIT_TASK_MAX_RETRIES = 5 + +# Maximum age in seconds of timestamps we will accept +# when a credit provider notifies us that a student has been approved +# or denied for credit. +CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60 diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 4154cd5394..06e9febead 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -637,3 +637,7 @@ else: EDXNOTES_PUBLIC_API = ENV_TOKENS.get('EDXNOTES_PUBLIC_API', EDXNOTES_PUBLIC_API) EDXNOTES_INTERNAL_API = ENV_TOKENS.get('EDXNOTES_INTERNAL_API', EDXNOTES_INTERNAL_API) + +##### Credit Provider Integration ##### + +CREDIT_PROVIDER_SECRET_KEYS = AUTH_TOKENS.get("CREDIT_PROVIDER_SECRET_KEYS", {}) diff --git a/lms/envs/common.py b/lms/envs/common.py index cb28ac68bb..9e8b1c2222 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -403,6 +403,8 @@ FEATURES = { # Enable OpenBadge support. See the BADGR_* settings later in this file. 'ENABLE_OPENBADGES': False, + # Credit course API + 'ENABLE_CREDIT_API': False, } # Ignore static asset files on import which match this pattern @@ -2461,7 +2463,7 @@ PREVIEW_DOMAIN = 'preview' # If set to None, all courses will be listed on the homepage HOMEPAGE_COURSE_MAX = None -################################ Settings for Credit Course Requirements ################################ +################################ Settings for Credit Courses ################################ # Initial delay used for retrying tasks. # Additional retries use longer delays. # Value is in seconds. @@ -2470,3 +2472,15 @@ CREDIT_TASK_DEFAULT_RETRY_DELAY = 30 # Maximum number of retries per task for errors that are not related # to throttling. CREDIT_TASK_MAX_RETRIES = 5 + +# Secret keys shared with credit providers. +# Used to digitally sign credit requests (us --> provider) +# and validate responses (provider --> us). +# Each key in the dictionary is a credit provider ID, and +# the value is the 32-character key. +CREDIT_PROVIDER_SECRET_KEYS = {} + +# Maximum age in seconds of timestamps we will accept +# when a credit provider notifies us that a student has been approved +# or denied for credit. +CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60 diff --git a/lms/urls.py b/lms/urls.py index d9d11d851b..1fecef03d8 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -93,6 +93,7 @@ urlpatterns = ( # Video Abstraction Layer used to allow video teams to manage video assets # independently of courseware. https://github.com/edx/edx-val url(r'^api/val/v0/', include('edxval.urls')), + ) if settings.FEATURES["ENABLE_COMBINED_LOGIN_REGISTRATION"]: @@ -113,6 +114,12 @@ else: url(r'^accounts/login$', 'student.views.accounts_login', name="accounts_login"), ) +if settings.FEATURES.get("ENABLE_CREDIT_API"): + # Credit API end-points + urlpatterns += ( + url(r'^api/credit/', include('openedx.core.djangoapps.credit.urls', app_name="credit", namespace='credit')), + ) + if settings.FEATURES["ENABLE_MOBILE_REST_API"]: urlpatterns += ( url(r'^api/mobile/v0.5/', include('mobile_api.urls')), diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py index 8622d4a914..df7d0de56d 100644 --- a/openedx/core/djangoapps/credit/api.py +++ b/openedx/core/djangoapps/credit/api.py @@ -1,6 +1,7 @@ """ Contains the APIs for course credit requirements """ import logging import uuid + from django.db import transaction from student.models import User @@ -9,6 +10,7 @@ from .exceptions import ( InvalidCreditRequirements, InvalidCreditCourse, UserIsNotEligible, + CreditProviderNotConfigured, RequestAlreadyCompleted, CreditRequestNotFound, InvalidCreditStatus, @@ -20,6 +22,7 @@ from .models import ( CreditRequest, CreditEligibility, ) +from .signature import signature, get_shared_secret_key log = logging.getLogger(__name__) @@ -143,6 +146,16 @@ def create_credit_request(course_key, provider_id, username): Only users who are eligible for credit (have satisfied all credit requirements) are allowed to make requests. + A provider can be configured either with *integration enabled* or not. + If automatic integration is disabled, this method will simply return + a URL to the credit provider and method set to "GET", so the student can + visit the URL and request credit directly. No database record will be created + to track these requests. + + If automatic integration *is* enabled, then this will also return the parameters + that the user's browser will need to POST to the credit provider. + These parameters will be digitally signed using a secret key shared with the credit provider. + A database record will be created to track the request with a 32-character UUID. The returned dictionary can be used by the user's browser to send a POST request to the credit provider. @@ -162,23 +175,29 @@ def create_credit_request(course_key, provider_id, username): Raises: UserIsNotEligible: The user has not satisfied eligibility requirements for credit. + CreditProviderNotConfigured: The credit provider has not been configured for this course. RequestAlreadyCompleted: The user has already submitted a request and received a response from the credit provider. Example Usage: >>> create_credit_request(course.id, "hogwarts", "ron") { - "uuid": "557168d0f7664fe59097106c67c3f847", - "timestamp": "2015-05-04T20:57:57.987119+00:00", - "course_org": "HogwartsX", - "course_num": "Potions101", - "course_run": "1T2015", - "final_grade": 0.95, - "user_username": "ron", - "user_email": "ron@example.com", - "user_full_name": "Ron Weasley", - "user_mailing_address": "", - "user_country": "US", + "url": "https://credit.example.com/request", + "method": "POST", + "parameters": { + "request_uuid": "557168d0f7664fe59097106c67c3f847", + "timestamp": "2015-05-04T20:57:57.987119+00:00", + "course_org": "HogwartsX", + "course_num": "Potions101", + "course_run": "1T2015", + "final_grade": 0.95, + "user_username": "ron", + "user_email": "ron@example.com", + "user_full_name": "Ron Weasley", + "user_mailing_address": "", + "user_country": "US", + "signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI=" + } } """ @@ -191,8 +210,33 @@ def create_credit_request(course_key, provider_id, username): credit_course = user_eligibility.course credit_provider = user_eligibility.provider except CreditEligibility.DoesNotExist: + log.warning(u'User tried to initiate a request for credit, but the user is not eligible for credit') raise UserIsNotEligible + # Check if we've enabled automatic integration with the credit + # provider. If not, we'll show the user a link to a URL + # where the user can request credit directly from the provider. + # Note that we do NOT track these requests in our database, + # since the state would always be "pending" (we never hear back). + if not credit_provider.enable_integration: + return { + "url": credit_provider.provider_url, + "method": "GET", + "parameters": {} + } + else: + # If automatic credit integration is enabled, then try + # to retrieve the shared signature *before* creating the request. + # That way, if there's a misconfiguration, we won't have requests + # in our system that we know weren't sent to the provider. + shared_secret_key = get_shared_secret_key(credit_provider.provider_id) + if shared_secret_key is None: + msg = u'Credit provider with ID "{provider_id}" does not have a secret key configured.'.format( + provider_id=credit_provider.provider_id + ) + log.error(msg) + raise CreditProviderNotConfigured(msg) + # Initiate a new request if one has not already been created credit_request, created = CreditRequest.objects.get_or_create( course=credit_course, @@ -204,6 +248,12 @@ def create_credit_request(course_key, provider_id, username): # If so, we're not allowed to issue any further requests. # Skip checking the status if we know that we just created this record. if not created and credit_request.status != "pending": + log.warning( + ( + u'Cannot initiate credit request because the request with UUID "%s" ' + u'exists with status "%s"' + ), credit_request.uuid, credit_request.status + ) raise RequestAlreadyCompleted if created: @@ -229,7 +279,7 @@ def create_credit_request(course_key, provider_id, username): raise UserIsNotEligible parameters = { - "uuid": credit_request.uuid, + "request_uuid": credit_request.uuid, "timestamp": credit_request.timestamp.isoformat(), "course_org": course_key.org, "course_num": course_key.course, @@ -253,10 +303,25 @@ def create_credit_request(course_key, provider_id, username): credit_request.parameters = parameters credit_request.save() - return parameters + if created: + log.info(u'Created new request for credit with UUID "%s"', credit_request.uuid) + else: + log.info( + u'Updated request for credit with UUID "%s" so the user can re-issue the request', + credit_request.uuid + ) + + # Sign the parameters using a secret key we share with the credit provider. + parameters["signature"] = signature(parameters, shared_secret_key) + + return { + "url": credit_provider.provider_url, + "method": "POST", + "parameters": parameters + } -def update_credit_request_status(request_uuid, status): +def update_credit_request_status(request_uuid, provider_id, status): """ Update the status of a credit request. @@ -272,12 +337,13 @@ def update_credit_request_status(request_uuid, status): Arguments: request_uuid (str): The unique identifier for the credit request. + provider_id (str): Identifier for the credit provider. status (str): Either "approved" or "rejected" Returns: None Raises: - CreditRequestNotFound: The request does not exist. + CreditRequestNotFound: No request exists that is associated with the given provider. InvalidCreditStatus: The status is not either "approved" or "rejected". """ @@ -285,11 +351,23 @@ def update_credit_request_status(request_uuid, status): raise InvalidCreditStatus try: - request = CreditRequest.objects.get(uuid=request_uuid) + request = CreditRequest.objects.get(uuid=request_uuid, provider__provider_id=provider_id) + old_status = request.status request.status = status request.save() + + log.info( + u'Updated request with UUID "%s" from status "%s" to "%s" for provider with ID "%s".', + request_uuid, old_status, status, provider_id + ) except CreditRequest.DoesNotExist: - raise CreditRequestNotFound + msg = ( + u'Credit provider with ID "{provider_id}" attempted to ' + u'update request with UUID "{request_uuid}", but no request ' + u'with this UUID is associated with the provider.' + ).format(provider_id=provider_id, request_uuid=request_uuid) + log.warning(msg) + raise CreditRequestNotFound(msg) def get_credit_requests_for_user(username): diff --git a/openedx/core/djangoapps/credit/exceptions.py b/openedx/core/djangoapps/credit/exceptions.py index e2ea783a80..cffa47e7d1 100644 --- a/openedx/core/djangoapps/credit/exceptions.py +++ b/openedx/core/djangoapps/credit/exceptions.py @@ -1,42 +1,57 @@ """Exceptions raised by the credit API. """ -class InvalidCreditRequirements(Exception): +class CreditApiBadRequest(Exception): + """ + Could not complete a request to the credit API because + there was a problem with the request (as opposed to an internal error). + """ + pass + + +class InvalidCreditRequirements(CreditApiBadRequest): """ The requirement dictionary provided has invalid format. """ pass -class InvalidCreditCourse(Exception): +class InvalidCreditCourse(CreditApiBadRequest): """ The course is not configured for credit. """ pass -class UserIsNotEligible(Exception): +class UserIsNotEligible(CreditApiBadRequest): """ The user has not satisfied eligibility requirements for credit. """ pass -class RequestAlreadyCompleted(Exception): +class CreditProviderNotConfigured(CreditApiBadRequest): + """ + The requested credit provider is not configured correctly for the course. + """ + pass + + +class RequestAlreadyCompleted(CreditApiBadRequest): """ The user has already submitted a request and received a response from the credit provider. """ pass -class CreditRequestNotFound(Exception): +class CreditRequestNotFound(CreditApiBadRequest): """ The request does not exist. """ pass -class InvalidCreditStatus(Exception): +class InvalidCreditStatus(CreditApiBadRequest): """ The status is not either "approved" or "rejected". """ diff --git a/openedx/core/djangoapps/credit/migrations/0007_auto__add_field_creditprovider_enable_integration__chg_field_creditpro.py b/openedx/core/djangoapps/credit/migrations/0007_auto__add_field_creditprovider_enable_integration__chg_field_creditpro.py new file mode 100644 index 0000000000..7e6ef2e773 --- /dev/null +++ b/openedx/core/djangoapps/credit/migrations/0007_auto__add_field_creditprovider_enable_integration__chg_field_creditpro.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Removing unique constraint on 'CreditProvider', fields ['provider_url'] + db.delete_unique('credit_creditprovider', ['provider_url']) + + # Adding field 'CreditProvider.enable_integration' + db.add_column('credit_creditprovider', 'enable_integration', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + + # Changing field 'CreditProvider.provider_url' + db.alter_column('credit_creditprovider', 'provider_url', self.gf('django.db.models.fields.URLField')(max_length=200)) + + def backwards(self, orm): + # Deleting field 'CreditProvider.enable_integration' + db.delete_column('credit_creditprovider', 'enable_integration') + + + # Changing field 'CreditProvider.provider_url' + db.alter_column('credit_creditprovider', 'provider_url', self.gf('django.db.models.fields.URLField')(max_length=255, unique=True)) + # Adding unique constraint on 'CreditProvider', fields ['provider_url'] + db.create_unique('credit_creditprovider', ['provider_url']) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'credit.creditcourse': { + 'Meta': {'object_name': 'CreditCourse'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'providers': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['credit.CreditProvider']", 'symmetrical': 'False'}) + }, + 'credit.crediteligibility': { + 'Meta': {'unique_together': "(('username', 'course'),)", 'object_name': 'CreditEligibility'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'eligibilities'", 'to': "orm['credit.CreditProvider']"}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.creditprovider': { + 'Meta': {'object_name': 'CreditProvider'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'eligibility_duration': ('django.db.models.fields.PositiveIntegerField', [], {'default': '31556970'}), + 'enable_integration': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'provider_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'provider_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '200'}) + }, + 'credit.creditrequest': { + 'Meta': {'unique_together': "(('username', 'course', 'provider'),)", 'object_name': 'CreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requests'", 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}) + }, + 'credit.creditrequirement': { + 'Meta': {'unique_together': "(('namespace', 'name', 'course'),)", 'object_name': 'CreditRequirement'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'course': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credit_requirements'", 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'criteria': ('jsonfield.fields.JSONField', [], {}), + 'display_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'credit.creditrequirementstatus': { + 'Meta': {'object_name': 'CreditRequirementStatus'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'reason': ('jsonfield.fields.JSONField', [], {'default': '{}'}), + 'requirement': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'statuses'", 'to': "orm['credit.CreditRequirement']"}), + 'status': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'credit.historicalcreditrequest': { + 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCreditRequest'}, + 'course': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditCourse']"}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + u'history_date': ('django.db.models.fields.DateTimeField', [], {}), + u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'parameters': ('jsonfield.fields.JSONField', [], {}), + 'provider': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['credit.CreditProvider']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '255'}), + 'timestamp': ('django.db.models.fields.DateTimeField', [], {'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}) + } + } + + complete_apps = ['credit'] diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index b0f65bb58d..8aff5cfcf1 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -9,6 +9,7 @@ successful completion of a course on EdX import logging from django.db import models +from django.core.validators import RegexValidator from simple_history.models import HistoricalRecords @@ -29,10 +30,53 @@ class CreditProvider(TimeStampedModel): get credit for course. Eligibility duration will be use to set duration for which credit eligible message appears on dashboard. """ + provider_id = models.CharField( + max_length=255, + unique=True, + validators=[ + RegexValidator( + regex=r"^[a-z,A-Z,0-9,\-]+$", + message="Only alphanumeric characters and hyphens (-) are allowed", + code="invalid_provider_id", + ) + ], + help_text=ugettext_lazy( + "Unique identifier for this credit provider. " + "Only alphanumeric characters and hyphens (-) are allowed. " + "The identifier is case-sensitive." + ) + ) - provider_id = models.CharField(max_length=255, db_index=True, unique=True) - display_name = models.CharField(max_length=255) - provider_url = models.URLField(max_length=255, unique=True, default="") + active = models.BooleanField( + default=True, + help_text=ugettext_lazy("Whether the credit provider is currently enabled.") + ) + + display_name = models.CharField( + max_length=255, + help_text=ugettext_lazy("Name of the credit provider displayed to users") + ) + + enable_integration = models.BooleanField( + default=False, + help_text=ugettext_lazy( + "When true, automatically notify the credit provider " + "when a user requests credit. " + "In order for this to work, a shared secret key MUST be configured " + "for the credit provider in secure auth settings." + ) + ) + + provider_url = models.URLField( + default="", + help_text=ugettext_lazy( + "URL of the credit provider. If automatic integration is " + "enabled, this will the the end-point that we POST to " + "to notify the provider of a credit request. Otherwise, the " + "user will be shown a link to this URL, so the user can " + "request credit from the provider directly." + ) + ) # Default is one year DEFAULT_ELIGIBILITY_DURATION = 31556970 @@ -41,7 +85,6 @@ class CreditProvider(TimeStampedModel): help_text=ugettext_lazy(u"Number of seconds to show eligibility message"), default=DEFAULT_ELIGIBILITY_DURATION ) - active = models.BooleanField(default=True) class CreditCourse(models.Model): diff --git a/openedx/core/djangoapps/credit/signature.py b/openedx/core/djangoapps/credit/signature.py new file mode 100644 index 0000000000..799d56d27f --- /dev/null +++ b/openedx/core/djangoapps/credit/signature.py @@ -0,0 +1,49 @@ +""" +Calculate digital signatures for messages sent to/from credit providers, +using a shared secret key. + +The signature is calculated as follows: + + 1) Encode all parameters of the request (except the signature) in a string. + 2) Encode each key/value pair as a string of the form "{key}:{value}". + 3) Concatenate key/value pairs in ascending alphabetical order by key. + 4) Calculate the HMAC-SHA256 digest of the encoded request parameters, using a 32-character shared secret key. + 5) Encode the digest in hexadecimal. + +It is the responsibility of the credit provider to check the signature of messages +we send them, and it is our responsibility to check the signature of messages +we receive from the credit provider. + +""" + +import hashlib +import hmac + +from django.conf import settings + + +def get_shared_secret_key(provider_id): + """ + Retrieve the shared secret key for a particular credit provider. + """ + return getattr(settings, "CREDIT_PROVIDER_SECRET_KEYS", {}).get(provider_id) + + +def signature(params, shared_secret): + """ + Calculate the digital signature for parameters using a shared secret. + + Arguments: + params (dict): Parameters to sign. Ignores the "signature" key if present. + + Returns: + str: The 32-character signature. + + """ + encoded_params = "".join([ + "{key}:{value}".format(key=key, value=params[key]) + for key in sorted(params.keys()) + if key != "signature" + ]) + hasher = hmac.new(shared_secret, encoded_params, hashlib.sha256) + return hasher.hexdigest() diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index 194e9918ff..57474f485b 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -7,6 +7,7 @@ import ddt import pytz import dateutil.parser as date_parser from django.test import TestCase +from django.test.utils import override_settings from django.db import connection, transaction from opaque_keys.edx.keys import CourseKey @@ -30,6 +31,12 @@ from openedx.core.djangoapps.credit.models import ( ) +TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6" + + +@override_settings(CREDIT_PROVIDER_SECRET_KEYS={ + "hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY +}) class CreditApiTestBase(TestCase): """ Base class for test cases of the credit API. @@ -37,6 +44,7 @@ class CreditApiTestBase(TestCase): PROVIDER_ID = "hogwarts" PROVIDER_NAME = "Hogwarts School of Witchcraft and Wizardry" + PROVIDER_URL = "https://credit.example.com/request" def setUp(self, **kwargs): super(CreditApiTestBase, self).setUp() @@ -47,7 +55,12 @@ class CreditApiTestBase(TestCase): credit_course = CreditCourse.objects.create(course_key=self.course_key, enabled=enabled) # Associate a credit provider with the course. - credit_provider = CreditProvider.objects.create(provider_id=self.PROVIDER_ID, display_name=self.PROVIDER_NAME) + credit_provider = CreditProvider.objects.create( + provider_id=self.PROVIDER_ID, + display_name=self.PROVIDER_NAME, + provider_url=self.PROVIDER_URL, + enable_integration=True, + ) credit_course.providers.add(credit_provider) return credit_course @@ -228,40 +241,63 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): # Initiate a credit request request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + # Validate the URL and method + self.assertIn('url', request) + self.assertEqual(request['url'], self.PROVIDER_URL) + self.assertIn('method', request) + self.assertEqual(request['method'], "POST") + + self.assertIn('parameters', request) + parameters = request['parameters'] + # Validate the UUID - self.assertIn('uuid', request) - self.assertEqual(len(request['uuid']), 32) + self.assertIn('request_uuid', parameters) + self.assertEqual(len(parameters['request_uuid']), 32) # Validate the timestamp - self.assertIn('timestamp', request) - parsed_date = date_parser.parse(request['timestamp']) + self.assertIn('timestamp', parameters) + parsed_date = date_parser.parse(parameters['timestamp']) self.assertTrue(parsed_date < datetime.datetime.now(pytz.UTC)) # Validate course information - self.assertIn('course_org', request) - self.assertEqual(request['course_org'], self.course_key.org) - self.assertIn('course_num', request) - self.assertEqual(request['course_num'], self.course_key.course) - self.assertIn('course_run', request) - self.assertEqual(request['course_run'], self.course_key.run) - self.assertIn('final_grade', request) - self.assertEqual(request['final_grade'], self.FINAL_GRADE) + self.assertIn('course_org', parameters) + self.assertEqual(parameters['course_org'], self.course_key.org) + self.assertIn('course_num', parameters) + self.assertEqual(parameters['course_num'], self.course_key.course) + self.assertIn('course_run', parameters) + self.assertEqual(parameters['course_run'], self.course_key.run) + self.assertIn('final_grade', parameters) + self.assertEqual(parameters['final_grade'], self.FINAL_GRADE) # Validate user information for key in self.USER_INFO.keys(): - request_key = 'user_{key}'.format(key=key) - self.assertIn(request_key, request) - self.assertEqual(request[request_key], self.USER_INFO[key]) + param_key = 'user_{key}'.format(key=key) + self.assertIn(param_key, parameters) + self.assertEqual(parameters[param_key], self.USER_INFO[key]) + + def test_credit_request_disable_integration(self): + CreditProvider.objects.all().update(enable_integration=False) + + # Initiate a request with automatic integration disabled + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + + # We get a URL and a GET method, so we can provide students + # with a link to the credit provider, where they can request + # credit directly. + self.assertIn("url", request) + self.assertEqual(request["url"], self.PROVIDER_URL) + self.assertIn("method", request) + self.assertEqual(request["method"], "GET") @ddt.data("approved", "rejected") def test_credit_request_status(self, status): - request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"]) # Initial status should be "pending" self._assert_credit_status("pending") # Update the status - api.update_credit_request_status(request['uuid'], status) + api.update_credit_request_status(request["parameters"]["request_uuid"], self.PROVIDER_ID, status) self._assert_credit_status(status) def test_query_counts(self): @@ -277,34 +313,38 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): # - 3 queries: Retrieve and update the request # - 1 query: Update the history table for the request. + uuid = request["parameters"]["request_uuid"] with self.assertNumQueries(4): - api.update_credit_request_status(request['uuid'], "approved") + api.update_credit_request_status(uuid, self.PROVIDER_ID, "approved") with self.assertNumQueries(1): - api.get_credit_requests_for_user(self.USER_INFO['username']) + api.get_credit_requests_for_user(self.USER_INFO["username"]) def test_reuse_credit_request(self): # Create the first request - first_request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + first_request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"]) # Update the user's profile information, then attempt a second request self.user.profile.name = "Bobby" self.user.profile.save() - second_request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + second_request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"]) # Request UUID should be the same - self.assertEqual(first_request['uuid'], second_request['uuid']) + self.assertEqual( + first_request["parameters"]["request_uuid"], + second_request["parameters"]["request_uuid"] + ) # Request should use the updated information - self.assertEqual(second_request['user_full_name'], "Bobby") + self.assertEqual(second_request["parameters"]["user_full_name"], "Bobby") @ddt.data("approved", "rejected") def test_cannot_make_credit_request_after_response(self, status): # Create the first request - request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"]) # Provider updates the status - api.update_credit_request_status(request['uuid'], status) + api.update_credit_request_status(request["parameters"]["request_uuid"], self.PROVIDER_ID, status) # Attempting a second request raises an exception with self.assertRaises(RequestAlreadyCompleted): @@ -328,8 +368,8 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): self.user.profile.save() # Request should include an empty mailing address field - request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) - self.assertEqual(request["user_mailing_address"], "") + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"]) + self.assertEqual(request["parameters"]["user_mailing_address"], "") def test_create_request_null_country(self): # Simulate users who registered accounts before the country field was introduced. @@ -340,8 +380,8 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): transaction.commit_unless_managed() # Request should include an empty country field - request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) - self.assertEqual(request["user_country"], "") + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"]) + self.assertEqual(request["parameters"]["user_country"], "") def test_user_has_no_final_grade(self): # Simulate an error condition that should never happen: @@ -356,21 +396,21 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): grade_status.save() with self.assertRaises(UserIsNotEligible): - api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"]) def test_update_invalid_credit_status(self): # The request status must be either "approved" or "rejected" - request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO['username']) + request = api.create_credit_request(self.course_key, self.PROVIDER_ID, self.USER_INFO["username"]) with self.assertRaises(InvalidCreditStatus): - api.update_credit_request_status(request['uuid'], "invalid") + api.update_credit_request_status(request["parameters"]["request_uuid"], self.PROVIDER_ID, "invalid") def test_update_credit_request_not_found(self): # The request UUID must exist with self.assertRaises(CreditRequestNotFound): - api.update_credit_request_status("invalid_uuid", "approved") + api.update_credit_request_status("invalid_uuid", self.PROVIDER_ID, "approved") def test_get_credit_requests_no_requests(self): - requests = api.get_credit_requests_for_user(self.USER_INFO['username']) + requests = api.get_credit_requests_for_user(self.USER_INFO["username"]) self.assertEqual(requests, []) def _configure_credit(self): @@ -389,7 +429,7 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): active=True ) status = CreditRequirementStatus.objects.create( - username=self.USER_INFO['username'], + username=self.USER_INFO["username"], requirement=requirement, ) status.status = "satisfied" @@ -397,12 +437,12 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): status.save() CreditEligibility.objects.create( - username=self.USER_INFO['username'], + username=self.USER_INFO["username"], course=CreditCourse.objects.get(course_key=self.course_key), provider=CreditProvider.objects.get(provider_id=self.PROVIDER_ID) ) def _assert_credit_status(self, expected_status): """Check the user's credit status. """ - statuses = api.get_credit_requests_for_user(self.USER_INFO['username']) + statuses = api.get_credit_requests_for_user(self.USER_INFO["username"]) self.assertEqual(statuses[0]["status"], expected_status) diff --git a/openedx/core/djangoapps/credit/tests/test_views.py b/openedx/core/djangoapps/credit/tests/test_views.py new file mode 100644 index 0000000000..558b7a2a0b --- /dev/null +++ b/openedx/core/djangoapps/credit/tests/test_views.py @@ -0,0 +1,332 @@ +""" +Tests for credit app views. +""" +import unittest +import json +import datetime +import pytz + +import ddt +from mock import patch +from django.test import TestCase +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from django.conf import settings + +from student.tests.factories import UserFactory +from util.testing import UrlResetMixin +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.credit import api +from openedx.core.djangoapps.credit.signature import signature +from openedx.core.djangoapps.credit.models import ( + CreditCourse, + CreditProvider, + CreditRequirement, + CreditRequirementStatus, + CreditEligibility, + CreditRequest, +) + + +TEST_CREDIT_PROVIDER_SECRET_KEY = "931433d583c84ca7ba41784bad3232e6" + + +@ddt.ddt +@override_settings(CREDIT_PROVIDER_SECRET_KEYS={ + "hogwarts": TEST_CREDIT_PROVIDER_SECRET_KEY +}) +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class CreditProviderViewTests(UrlResetMixin, TestCase): + """ + Tests for HTTP end-points used to issue requests to credit providers + and receive responses approving or denying requests. + """ + + USERNAME = "ron" + USER_FULL_NAME = "Ron Weasley" + PASSWORD = "password" + PROVIDER_ID = "hogwarts" + PROVIDER_URL = "https://credit.example.com/request" + COURSE_KEY = CourseKey.from_string("edX/DemoX/Demo_Course") + FINAL_GRADE = 0.95 + + @patch.dict(settings.FEATURES, {"ENABLE_CREDIT_API": True}) + def setUp(self): + """ + Configure a credit course. + """ + super(CreditProviderViewTests, self).setUp() + + # Create the test user and log in + self.user = UserFactory(username=self.USERNAME, password=self.PASSWORD) + self.user.profile.name = self.USER_FULL_NAME + self.user.profile.save() + + success = self.client.login(username=self.USERNAME, password=self.PASSWORD) + self.assertTrue(success, msg="Could not log in") + + # Enable the course for credit + credit_course = CreditCourse.objects.create( + course_key=self.COURSE_KEY, + enabled=True, + ) + + # Configure a credit provider for the course + credit_provider = CreditProvider.objects.create( + provider_id=self.PROVIDER_ID, + enable_integration=True, + provider_url=self.PROVIDER_URL, + ) + credit_course.providers.add(credit_provider) + credit_course.save() + + # Add a single credit requirement (final grade) + requirement = CreditRequirement.objects.create( + course=credit_course, + namespace="grade", + name="grade", + ) + + # Mark the user as having satisfied the requirement + # and eligible for credit. + CreditRequirementStatus.objects.create( + username=self.USERNAME, + requirement=requirement, + status="satisfied", + reason={"final_grade": self.FINAL_GRADE} + ) + CreditEligibility.objects.create( + username=self.USERNAME, + course=credit_course, + provider=credit_provider, + ) + + def test_credit_request_and_response(self): + # Initiate a request + response = self._create_credit_request(self.USERNAME, self.COURSE_KEY) + self.assertEqual(response.status_code, 200) + + # Check that the user's request status is pending + requests = api.get_credit_requests_for_user(self.USERNAME) + self.assertEqual(len(requests), 1) + self.assertEqual(requests[0]["status"], "pending") + + # Check request parameters + content = json.loads(response.content) + self.assertEqual(content["url"], self.PROVIDER_URL) + self.assertEqual(content["method"], "POST") + self.assertEqual(len(content["parameters"]["request_uuid"]), 32) + self.assertEqual(content["parameters"]["course_org"], "edX") + self.assertEqual(content["parameters"]["course_num"], "DemoX") + self.assertEqual(content["parameters"]["course_run"], "Demo_Course") + self.assertEqual(content["parameters"]["final_grade"], self.FINAL_GRADE) + self.assertEqual(content["parameters"]["user_username"], self.USERNAME) + self.assertEqual(content["parameters"]["user_full_name"], self.USER_FULL_NAME) + self.assertEqual(content["parameters"]["user_mailing_address"], "") + self.assertEqual(content["parameters"]["user_country"], "") + + # The signature is going to change each test run because the request + # is assigned a different UUID each time. + # For this reason, we use the signature function directly + # (the "signature" parameter will be ignored when calculating the signature). + # Other unit tests verify that the signature function is working correctly. + self.assertEqual( + content["parameters"]["signature"], + signature(content["parameters"], TEST_CREDIT_PROVIDER_SECRET_KEY) + ) + + # Simulate a response from the credit provider + response = self._credit_provider_callback( + content["parameters"]["request_uuid"], + "approved" + ) + self.assertEqual(response.status_code, 200) + + # Check that the user's status is approved + requests = api.get_credit_requests_for_user(self.USERNAME) + self.assertEqual(len(requests), 1) + self.assertEqual(requests[0]["status"], "approved") + + def test_request_credit_anonymous_user(self): + self.client.logout() + response = self._create_credit_request(self.USERNAME, self.COURSE_KEY) + self.assertEqual(response.status_code, 403) + + def test_request_credit_for_another_user(self): + response = self._create_credit_request("another_user", self.COURSE_KEY) + self.assertEqual(response.status_code, 403) + + @ddt.data( + # Invalid JSON + "{", + + # Missing required parameters + json.dumps({"username": USERNAME}), + json.dumps({"course_key": unicode(COURSE_KEY)}), + + # Invalid course key format + json.dumps({"username": USERNAME, "course_key": "invalid"}), + ) + def test_create_credit_request_invalid_parameters(self, request_data): + url = reverse("credit:create_request", args=[self.PROVIDER_ID]) + response = self.client.post(url, data=request_data, content_type="application/json") + self.assertEqual(response.status_code, 400) + + def test_credit_provider_callback_validates_signature(self): + request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY) + + # Simulate a callback from the credit provider with an invalid signature + # Since the signature is invalid, we respond with a 403 Not Authorized. + response = self._credit_provider_callback(request_uuid, "approved", sig="invalid") + self.assertEqual(response.status_code, 403) + + def test_credit_provider_callback_validates_timestamp(self): + request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY) + + # Simulate a callback from the credit provider with a timestamp too far in the past + # (slightly more than 15 minutes) + # Since the message isn't timely, respond with a 403. + timestamp = datetime.datetime.now(pytz.UTC) - datetime.timedelta(0, 60 * 15 + 1) + response = self._credit_provider_callback(request_uuid, "approved", timestamp=timestamp.isoformat()) + self.assertEqual(response.status_code, 403) + + def test_credit_provider_callback_is_idempotent(self): + request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY) + + # Initially, the status should be "pending" + self._assert_request_status(request_uuid, "pending") + + # First call sets the status to approved + self._credit_provider_callback(request_uuid, "approved") + self._assert_request_status(request_uuid, "approved") + + # Second call succeeds as well; status is still approved + self._credit_provider_callback(request_uuid, "approved") + self._assert_request_status(request_uuid, "approved") + + @ddt.data( + # Invalid JSON + "{", + + # Not a dictionary + "4", + + # Invalid timestamp format + json.dumps({ + "request_uuid": "557168d0f7664fe59097106c67c3f847", + "status": "approved", + "timestamp": "invalid", + "signature": "7685ae1c8f763597ee7ce526685c5ac24353317dbfe087f0ed32a699daf7dc63", + }), + ) + def test_credit_provider_callback_invalid_parameters(self, request_data): + url = reverse("credit:provider_callback", args=[self.PROVIDER_ID]) + response = self.client.post(url, data=request_data, content_type="application/json") + self.assertEqual(response.status_code, 400) + + def test_credit_provider_invalid_status(self): + response = self._credit_provider_callback("557168d0f7664fe59097106c67c3f847", "invalid") + self.assertEqual(response.status_code, 400) + + def test_credit_provider_key_not_configured(self): + # Cannot initiate a request because we can't sign it + with override_settings(CREDIT_PROVIDER_SECRET_KEYS={}): + response = self._create_credit_request(self.USERNAME, self.COURSE_KEY) + self.assertEqual(response.status_code, 400) + + # Create the request with the secret key configured + request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY) + + # Callback from the provider is not authorized, because + # the shared secret isn't configured. + with override_settings(CREDIT_PROVIDER_SECRET_KEYS={}): + response = self._credit_provider_callback(request_uuid, "approved") + self.assertEqual(response.status_code, 403) + + def test_request_associated_with_another_provider(self): + other_provider_id = "other_provider" + other_provider_secret_key = "1d01f067a5a54b0b8059f7095a7c636d" + + # Create an additional credit provider and associate it with the course. + credit_course = CreditCourse.objects.get(course_key=self.COURSE_KEY) + credit_provider = CreditProvider.objects.create(provider_id=other_provider_id, enable_integration=True) + credit_course.providers.add(credit_provider) + credit_course.save() + + # Initiate a credit request with the first provider + request_uuid = self._create_credit_request_and_get_uuid(self.USERNAME, self.COURSE_KEY) + + # Attempt to update the request status for a different provider + with override_settings(CREDIT_PROVIDER_SECRET_KEYS={other_provider_id: other_provider_secret_key}): + response = self._credit_provider_callback( + request_uuid, + "approved", + provider_id=other_provider_id, + secret_key=other_provider_secret_key, + ) + + # Response should be a 404 to avoid leaking request UUID values to other providers. + self.assertEqual(response.status_code, 404) + + # Request status should still be "pending" + self._assert_request_status(request_uuid, "pending") + + def _create_credit_request(self, username, course_key): + """ + Initiate a request for credit. + """ + url = reverse("credit:create_request", args=[self.PROVIDER_ID]) + return self.client.post( + url, + data=json.dumps({ + "username": username, + "course_key": unicode(course_key), + }), + content_type="application/json", + ) + + def _create_credit_request_and_get_uuid(self, username, course_key): + """ + Initiate a request for credit and return the request UUID. + """ + response = self._create_credit_request(username, course_key) + self.assertEqual(response.status_code, 200) + return json.loads(response.content)["parameters"]["request_uuid"] + + def _credit_provider_callback(self, request_uuid, status, **kwargs): + """ + Simulate a response from the credit provider approving + or rejecting the credit request. + + Arguments: + request_uuid (str): The UUID of the credit request. + status (str): The status of the credit request. + + Keyword Arguments: + provider_id (str): Identifier for the credit provider. + secret_key (str): Shared secret key for signing messages. + timestamp (datetime): Timestamp of the message. + sig (str): Digital signature to use on messages. + + """ + provider_id = kwargs.get("provider_id", self.PROVIDER_ID) + secret_key = kwargs.get("secret_key", TEST_CREDIT_PROVIDER_SECRET_KEY) + timestamp = kwargs.get("timestamp", datetime.datetime.now(pytz.UTC).isoformat()) + + url = reverse("credit:provider_callback", args=[provider_id]) + + parameters = { + "request_uuid": request_uuid, + "status": status, + "timestamp": timestamp, + } + parameters["signature"] = kwargs.get("sig", signature(parameters, secret_key)) + + return self.client.post(url, data=json.dumps(parameters), content_type="application/json") + + def _assert_request_status(self, uuid, expected_status): + """ + Check the status of a credit request. + """ + request = CreditRequest.objects.get(uuid=uuid) + self.assertEqual(request.status, expected_status) diff --git a/openedx/core/djangoapps/credit/urls.py b/openedx/core/djangoapps/credit/urls.py new file mode 100644 index 0000000000..7d7282c91d --- /dev/null +++ b/openedx/core/djangoapps/credit/urls.py @@ -0,0 +1,22 @@ +""" +URLs for the credit app. +""" +from django.conf.urls import patterns, url + +from .views import create_credit_request, credit_provider_callback + +urlpatterns = patterns( + '', + + url( + r"^v1/provider/(?P[^/]+)/request/$", + create_credit_request, + name="create_request" + ), + + url( + r"^v1/provider/(?P[^/]+)/callback/?$", + credit_provider_callback, + name="provider_callback" + ), +) diff --git a/openedx/core/djangoapps/credit/views.py b/openedx/core/djangoapps/credit/views.py new file mode 100644 index 0000000000..de9a86d1b6 --- /dev/null +++ b/openedx/core/djangoapps/credit/views.py @@ -0,0 +1,292 @@ +""" +Views for the credit Django app. +""" +import json +import datetime +import logging + +import dateutil +import pytz + +from django.http import ( + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + Http404 +) +from django.views.decorators.http import require_POST +from django.conf import settings + +from opaque_keys.edx.keys import CourseKey +from opaque_keys import InvalidKeyError + +from util.json_request import JsonResponse +from openedx.core.djangoapps.credit import api +from openedx.core.djangoapps.credit.signature import signature, get_shared_secret_key +from openedx.core.djangoapps.credit.exceptions import CreditApiBadRequest, CreditRequestNotFound + + +log = logging.getLogger(__name__) + + +@require_POST +def create_credit_request(request, provider_id): + """ + Initiate a request for credit in a course. + + This end-point will get-or-create a record in the database to track + the request. It will then calculate the parameters to send to + the credit provider and digitially sign the parameters, using a secret + key shared with the credit provider. + + The user's browser is responsible for POSTing these parameters + directly to the credit provider. + + **Example Usage:** + + POST /api/credit/v1/provider/hogwarts/request/ + { + "username": "ron", + "course_key": "edX/DemoX/Demo_Course" + } + + Response: 200 OK + Content-Type: application/json + { + "url": "http://example.com/request-credit", + "method": "POST", + "parameters": { + request_uuid: "557168d0f7664fe59097106c67c3f847" + timestamp: "2015-05-04T20:57:57.987119+00:00" + course_org: "ASUx" + course_num: "DemoX" + course_run: "1T2015" + final_grade: 0.95, + user_username: "john", + user_email: "john@example.com" + user_full_name: "John Smith" + user_mailing_address: "", + user_country: "US", + signature: "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI=" + } + } + + **Parameters:** + + * username (unicode): The username of the user requesting credit. + + * course_key (unicode): The identifier for the course for which the user + is requesting credit. + + **Responses:** + + * 200 OK: The request was created successfully. Returned content + is a JSON-encoded dictionary describing what the client should + send to the credit provider. + + * 400 Bad Request: + - The provided course key did not correspond to a valid credit course. + - The user already has a completed credit request for this course and provider. + + * 403 Not Authorized: + - The username does not match the name of the logged in user. + - The user is not eligible for credit in the course. + + * 404 Not Found: + - The provider does not exist. + + """ + response, parameters = _validate_json_parameters(request.body, ["username", "course_key"]) + if response is not None: + return response + + try: + course_key = CourseKey.from_string(parameters["course_key"]) + except InvalidKeyError: + return HttpResponseBadRequest( + u'Could not parse "{course_key}" as a course key'.format( + course_key=parameters["course_key"] + ) + ) + + # Check user authorization + if not (request.user and request.user.username == parameters["username"]): + log.warning( + u'User with ID %s attempted to initiate a credit request for user with username "%s"', + request.user.id if request.user else "[Anonymous]", + parameters["username"] + ) + return HttpResponseForbidden("Users are not allowed to initiate credit requests for other users.") + + # Initiate the request + try: + credit_request = api.create_credit_request(course_key, provider_id, parameters["username"]) + except CreditApiBadRequest as ex: + return HttpResponseBadRequest(ex) + else: + return JsonResponse(credit_request) + + +@require_POST +def credit_provider_callback(request, provider_id): + """ + Callback end-point used by credit providers to approve or reject + a request for credit. + + **Example Usage:** + + POST /api/credit/v1/provider/{provider-id}/callback + { + "request_uuid": "557168d0f7664fe59097106c67c3f847", + "status": "approved", + "timestamp": "2015-05-04T20:57:57.987119+00:00", + "signature": "cRCNjkE4IzY+erIjRwOQCpRILgOvXx4q2qvx141BCqI=" + } + + Response: 200 OK + + **Parameters:** + + * request_uuid (string): The UUID of the request. + + * status (string): Either "approved" or "rejected". + + * timestamp (string): The datetime at which the POST request was made, in ISO 8601 format. + This will always include time-zone information. + + * signature (string): A digital signature of the request parameters, + created using a secret key shared with the credit provider. + + **Responses:** + + * 200 OK: The user's status was updated successfully. + + * 400 Bad request: The provided parameters were not valid. + Response content will be a JSON-encoded string describing the error. + + * 403 Forbidden: Signature was invalid or timestamp was too far in the past. + + * 404 Not Found: Could not find a request with the specified UUID associated with this provider. + + """ + response, parameters = _validate_json_parameters(request.body, [ + "request_uuid", "status", "timestamp", "signature" + ]) + if response is not None: + return response + + # Validate the digital signature of the request. + # This ensures that the message came from the credit provider + # and hasn't been tampered with. + response = _validate_signature(parameters, provider_id) + if response is not None: + return response + + # Validate the timestamp to ensure that the request is timely. + response = _validate_timestamp(parameters["timestamp"], provider_id) + if response is not None: + return response + + # Update the credit request status + try: + api.update_credit_request_status(parameters["request_uuid"], provider_id, parameters["status"]) + except CreditRequestNotFound: + raise Http404 + except CreditApiBadRequest as ex: + return HttpResponseBadRequest(ex) + else: + return HttpResponse() + + +def _validate_json_parameters(params_string, expected_parameters): + """ + Load the request parameters as a JSON dictionary and check that + all required paramters are present. + + Arguments: + params_string (unicode): The JSON-encoded parameter dictionary. + expected_parameters (list): Required keys of the parameters dictionary. + + Returns: tuple of (HttpResponse, dict) + + """ + try: + parameters = json.loads(params_string) + except (TypeError, ValueError): + return HttpResponseBadRequest("Could not parse the request body as JSON."), None + + if not isinstance(parameters, dict): + return HttpResponseBadRequest("Request parameters must be a JSON-encoded dictionary."), None + + missing_params = set(expected_parameters) - set(parameters.keys()) + if missing_params: + msg = u"Required parameters are missing: {missing}".format(missing=u", ".join(missing_params)) + return HttpResponseBadRequest(msg), None + + return None, parameters + + +def _validate_signature(parameters, provider_id): + """ + Check that the signature from the credit provider is valid. + + Arguments: + parameters (dict): Parameters received from the credit provider. + provider_id (unicode): Identifier for the credit provider. + + Returns: + HttpResponseForbidden or None + + """ + secret_key = get_shared_secret_key(provider_id) + if secret_key is None: + log.error( + ( + u'Could not retrieve secret key for credit provider with ID "%s". ' + u'Since no key has been configured, we cannot validate requests from the credit provider.' + ), provider_id + ) + return HttpResponseForbidden("Credit provider credentials have not been configured.") + + if signature(parameters, secret_key) != parameters["signature"]: + log.warning(u'Request from credit provider with ID "%s" had an invalid signature', parameters["signature"]) + return HttpResponseForbidden("Invalid signature.") + + +def _validate_timestamp(timestamp_str, provider_id): + """ + Check that the timestamp of the request is recent. + + Arguments: + timestamp_str (str): ISO-8601 datetime formatted string. + provider_id (unicode): Identifier for the credit provider. + + Returns: + HttpResponse or None + + """ + # If we can't parse the datetime string, reject the request. + try: + # dateutil's parser has some counter-intuitive behavior: + # for example, given an empty string or "a" it always returns the current datetime. + # It is the responsibility of the credit provider to send a valid ISO-8601 datetime + # so we can validate it; otherwise, this check might not take effect. + # (Note that the signature check ensures that the timestamp we receive hasn't + # been tampered with after being issued by the credit provider). + timestamp = dateutil.parser.parse(timestamp_str) + except ValueError: + msg = u'"{timestamp}" is not an ISO-8601 formatted datetime'.format(timestamp=timestamp_str) + log.warning(msg) + return HttpResponseBadRequest(msg) + + # Check that the timestamp is recent + elapsed_seconds = (datetime.datetime.now(pytz.UTC) - timestamp).total_seconds() + if elapsed_seconds > settings.CREDIT_PROVIDER_TIMESTAMP_EXPIRATION: + log.warning( + ( + u'Timestamp %s is too far in the past (%s seconds), ' + u'so we are rejecting the notification from the credit provider "%s".' + ), + timestamp_str, elapsed_seconds, provider_id, + ) + return HttpResponseForbidden(u"Timestamp is too far in the past.")