Esempio n. 1
0
async def test_lti_13_login_handler_invokes_redirect_method(
    monkeypatch, lti13_auth_params, make_mock_request_handler
):
    """
    Does the LTI13LoginHandler call the redirect function once it
    receiving the post request?
    """
    monkeypatch.setenv(
        "LTI13_AUTHORIZE_URL", "http://my.lms.platform/api/lti/authorize_redirect"
    )
    local_handler = make_mock_request_handler(LTI13LoginHandler)
    local_utils = LTIUtils()
    local_utils.convert_request_to_dict(lti13_auth_params)
    with patch.object(
        LTIUtils,
        "convert_request_to_dict",
        return_value=local_utils.convert_request_to_dict(lti13_auth_params),
    ):
        with patch.object(
            LTI13LaunchValidator, "validate_login_request", return_value=True
        ):
            with patch.object(
                LTI13LoginHandler, "authorize_redirect", return_value=None
            ) as mock_redirect:
                LTI13LoginHandler(
                    local_handler.application, local_handler.request
                ).post()
                assert mock_redirect.called
async def test_authenticator_returns_auth_state_with_other_lms_vendor(
    lti11_authenticator, ):
    '''
    Do we get a valid username with lms vendors other than canvas?
    '''
    utils = LTIUtils()
    utils.convert_request_to_dict = MagicMock(name='convert_request_to_dict')
    utils.convert_request_to_dict(3, 4, 5, key='value')
    with patch.object(LTI11LaunchValidator,
                      'validate_launch_request',
                      return_value=True):
        authenticator = LTI11Authenticator()
        handler = Mock(
            spec=RequestHandler,
            get_secure_cookie=Mock(return_value=json.dumps(['key', 'secret'])),
            request=Mock(
                arguments=mock_lti11_args('moodle'),
                headers={},
                items=[],
            ),
        )
        result = await authenticator.authenticate(handler, None)
        expected = {
            'name': 'foo',
            'auth_state': {
                'course_id': 'intro101',
                'lms_user_id': '185d6c59731a553009ca9b59ca3a885100000',
                'user_role': 'Learner',
            },
        }
        assert result == expected
Esempio n. 3
0
 def post(self):
     """
     Validates required login arguments sent from platform and then uses the authorize_redirect() method
     to redirect users to the authorization url.
     """
     lti_utils = LTIUtils()
     validator = LTI13LaunchValidator()
     args = lti_utils.convert_request_to_dict(self.request.arguments)
     self.log.debug('Initial login request args are %s' % args)
     if validator.validate_login_request(args):
         login_hint = args['login_hint']
         self.log.debug('login_hint is %s' % login_hint)
         lti_message_hint = args['lti_message_hint']
         self.log.debug('lti_message_hint is %s' % lti_message_hint)
         client_id = args['client_id']
         self.log.debug('client_id is %s' % client_id)
         redirect_uri = guess_callback_uri('https', self.request.host, self.hub.server.base_url)
         self.log.info('redirect_uri: %r', redirect_uri)
         state = self.get_state()
         self.set_state_cookie(state)
         # TODO: validate that received nonces haven't been received before
         # and that they are within the time-based tolerance window
         nonce_raw = hashlib.sha256(state.encode())
         nonce = nonce_raw.hexdigest()
         self.authorize_redirect(
             client_id=client_id,
             login_hint=login_hint,
             lti_message_hint=lti_message_hint,
             nonce=nonce,
             redirect_uri=redirect_uri,
             state=state,
         )
Esempio n. 4
0
def lti13_login_params_dict(lti13_login_params):
    """
    Return the initial LTI 1.3 authorization request as a dict
    """
    utils = LTIUtils()
    args = utils.convert_request_to_dict(lti13_login_params)
    return args
Esempio n. 5
0
def test_convert_request_arguments_with_one_encoded_item_to_dict():
    """
    Do the items from a request object with one item per encoded value convert to a dict with decoded values?
    """
    utils = LTIUtils()
    arguments = {
        'key1': [b'value1'],
        'key2': [b'value2'],
        'key3': [b'value3'],
    }
    expected = {
        'key1': 'value1',
        'key2': 'value2',
        'key3': 'value3',
    }
    result = utils.convert_request_to_dict(arguments)

    assert expected == result
Esempio n. 6
0
def test_convert_request_arguments_with_one_encoded_item_to_dict():
    """
    Do the items from a request object with one item per encoded value convert to a dict with decoded values?
    """
    utils = LTIUtils()
    arguments = {
        "key1": [b"value1"],
        "key2": [b"value2"],
        "key3": [b"value3"],
    }
    expected = {
        "key1": "value1",
        "key2": "value2",
        "key3": "value3",
    }
    result = utils.convert_request_to_dict(arguments)

    assert expected == result
Esempio n. 7
0
def test_convert_request_arguments_with_more_than_one_encoded_item_to_dict():
    """
    Do the items from a request object with more than one item per encoded value convert to a dict with decoded values
    where the dict has one value per item?
    """
    utils = LTIUtils()
    arguments = {
        'key1': [b'value1', b'valueA'],
        'key2': [b'value2', b'valueB'],
        'key3': [b'value3', b'valueC'],
    }
    expected = {
        'key1': 'value1',
        'key2': 'value2',
        'key3': 'value3',
    }
    result = utils.convert_request_to_dict(arguments)

    assert expected == result
Esempio n. 8
0
def test_convert_request_arguments_with_more_than_one_encoded_item_to_dict():
    """
    Do the items from a request object with more than one item per encoded value convert to a dict with decoded values
    where the dict has one value per item?
    """
    utils = LTIUtils()
    arguments = {
        "key1": [b"value1", b"valueA"],
        "key2": [b"value2", b"valueB"],
        "key3": [b"value3", b"valueC"],
    }
    expected = {
        "key1": "value1",
        "key2": "value2",
        "key3": "value3",
    }
    result = utils.convert_request_to_dict(arguments)

    assert expected == result
Esempio n. 9
0
    async def authenticate(self, handler: BaseHandler, data: Dict[str, str] = None) -> Dict[str, str]:  # noqa: C901
        """
        LTI 1.1 authenticator which overrides authenticate function from base LTIAuthenticator.
        After validating the LTI 1.1 signuature, this function decodes the dictionary object
        from the request payload to set normalized strings for the course_id, username,
        user role, and lms user id. Once those values are set they are added to the auth_state
        and returned as a dictionary for further processing by hooks defined in jupyterhub_config.

        One or more consumer keys/values must be set in the jupyterhub config with the
        LTIAuthenticator.consumers dict.

        Args:
            handler: JupyterHub's Authenticator handler object. For LTI 1.1 requests, the handler is
              an instance of LTIAuthenticateHandler.
            data: optional data object

        Returns:
            Authentication's auth_state dictionary

        Raises:
            HTTPError if the required values are not in the request
        """
        validator = LTI11LaunchValidator(self.consumers)
        lti_utils = LTIUtils()

        self.log.debug('Original arguments received in request: %s' % handler.request.arguments)

        # extract the request arguments to a dict
        args = lti_utils.convert_request_to_dict(handler.request.arguments)
        self.log.debug('Decoded args from request: %s' % args)

        # get the origin protocol
        protocol = lti_utils.get_client_protocol(handler)
        self.log.debug('Origin protocol is: %s' % protocol)

        # build the full launch url value required for oauth1 signatures
        launch_url = f'{protocol}://{handler.request.host}{handler.request.uri}'
        self.log.debug('Launch url is: %s' % launch_url)

        if validator.validate_launch_request(launch_url, handler.request.headers, args):
            # get the lms vendor to implement optional logic for said vendor
            lms_vendor = ''
            if 'tool_consumer_info_product_family_code' in args and args['tool_consumer_info_product_family_code']:
                lms_vendor = args['tool_consumer_info_product_family_code']

            # We use the course_id to setup the grader service notebook. Since this service
            # runs as a container we need to normalize the string so we can use it
            # as a container name.
            if 'context_label' in args and args['context_label']:
                course_id = args['context_label']
                self.log.debug('Course context_label normalized to: %s' % course_id)
            elif 'context_title' in args and args['context_title']:
                course_id = args['context_title']
                self.log.debug('Course context_title normalized to: %s' % course_id)
            else:
                raise HTTPError(400, 'course_label or course_title not included in the LTI request')

            # Get the user's role, assign to Learner role by default. Roles are sent as institution
            # roles, where the roles' value is <handle>,<full URN>.
            # https://www.imsglobal.org/specs/ltiv1p0/implementation-guide#toc-16
            user_role = 'Learner'
            if 'roles' in args and args['roles']:
                user_role = args['roles'].split(',')[0]
                self.log.debug('User LTI role is: %s' % user_role)
            else:
                raise HTTPError(400, 'User role not included in the LTI request')

            # Assign the user_id. Check the tool consumer (lms) vendor. If canvas use their
            # custom user id extension by default, else use standar lti values.
            username = ''
            if lms_vendor == 'canvas':
                login_id = ''
                user_id = ''
                self.log.debug('TC is a Canvas LMS instance')
                if (
                    'custom_canvas_user_login_id' in args
                    and args['custom_canvas_user_login_id']
                    and 'custom_canvas_user_id' in args
                    and args['custom_canvas_user_id']
                ):
                    custom_canvas_user_login_id = args['custom_canvas_user_login_id']
                    login_id = lti_utils.email_to_username(custom_canvas_user_login_id)
                    self.log.debug('using custom_canvas_user_login_id for username')
                if 'custom_canvas_user_id' in args and args['custom_canvas_user_id']:
                    custom_canvas_user_id = args['custom_canvas_user_id']
                    user_id = lti_utils.email_to_username(custom_canvas_user_id)
                    self.log.debug('using custom_canvas_user_id for username')
                username = f'{login_id}-{user_id}'
            if (
                not username
                and 'lis_person_contact_email_primary' in args
                and args['lis_person_contact_email_primary']
            ):
                email = args['lis_person_contact_email_primary']
                username = lti_utils.email_to_username(email)
                self.log.debug('using lis_person_contact_email_primary for username')
            elif not username and 'lis_person_name_given' in args and args['lis_person_name_given']:
                username = args['lis_person_name_given']
                self.log.debug('using lis_person_name_given for username')
            elif not username and 'lis_person_sourcedid' in args and args['lis_person_sourcedid']:
                username = args['lis_person_sourcedid']
                self.log.debug('using lis_person_sourcedid for username')
            elif not username and 'lis_person_name_family' in args and args['lis_person_name_family']:
                username = args['lis_person_name_family']
                self.log.debug('using lis_person_name_family for username')
            elif not username and 'lis_person_name_full' in args and args['lis_person_name_full']:
                username = args['lis_person_name_full']
                self.log.debug('using lis_person_name_full for username')
            elif not username and 'user_id' in args and args['user_id']:
                username = args['user_id']
            elif not username:
                raise HTTPError(400, 'Unable to get username from request arguments')

            # use the user_id to identify the unique user id, if its not sent with the request
            # then default to the username
            lms_user_id = args['user_id'] if 'user_id' in args else username

            # GRADES-SENDER: fetch the information needed to register assignments within the control file
            # retrieve assignment_name from standard property vs custom lms properties
            assignment_name = ''
            # the next fields must come in args
            if 'custom_canvas_assignment_title' in args and args['custom_canvas_assignment_title']:
                assignment_name = lti_utils.normalize_string(args['custom_canvas_assignment_title'])
            # this requires adding a the assignment_title as a custom parameter in the tool consumer (lms)
            elif 'custom_assignment_title' in args and args['custom_assignment_title']:
                assignment_name = lti_utils.normalize_string(args['custom_assignment_title'])
            elif 'resource_link_title' in args and args['resource_link_title']:
                assignment_name = lti_utils.normalize_string(args['resource_link_title'])
            elif 'resource_link_id' in args and args['resource_link_id']:
                assignment_name = lti_utils.normalize_string(args['resource_link_id'])

            # Get lis_outcome_service_url and lis_result_sourcedid values that will help us to submit grades later
            lis_outcome_service_url = ''
            lis_result_sourcedid = ''

            # the next fields must come in args
            if 'lis_outcome_service_url' in args and args['lis_outcome_service_url']:
                lis_outcome_service_url = args['lis_outcome_service_url']
            if 'lis_result_sourcedid' in args and args['lis_result_sourcedid']:
                lis_result_sourcedid = args['lis_result_sourcedid']
            # only if both values exist we can register them to submit grades later
            if lis_outcome_service_url and lis_result_sourcedid:
                control_file = LTIGradesSenderControlFile(f'/home/grader-{course_id}/{course_id}')
                control_file.register_data(assignment_name, lis_outcome_service_url, lms_user_id, lis_result_sourcedid)
            # Assignment creation
            if assignment_name:
                nbgrader_service = NbGraderServiceHelper(course_id, True)
                self.log.debug(
                    'Creating a new assignment from the Authentication flow with title %s' % assignment_name
                )
                nbgrader_service.register_assignment(assignment_name)
            # ensure the user name is normalized
            username_normalized = lti_utils.normalize_string(username)
            self.log.debug('Assigned username is: %s' % username_normalized)

            return {
                'name': username_normalized,
                'auth_state': {
                    'course_id': course_id,
                    'lms_user_id': lms_user_id,
                    'user_role': user_role,
                },  # noqa: E231
            }
Esempio n. 10
0
    async def authenticate(
            self,
            handler: BaseHandler,
            data: Dict[str, str] = None) -> Dict[str, str]:  # noqa: C901
        """
        LTI 1.1 authenticator which overrides authenticate function from base LTIAuthenticator.
        After validating the LTI 1.1 signuature, this function decodes the dictionary object
        from the request payload to set normalized strings for the course_id, username,
        user role, and lms user id. Once those values are set they are added to the auth_state
        and returned as a dictionary for further processing by hooks defined in jupyterhub_config.

        One or more consumer keys/values must be set in the jupyterhub config with the
        LTIAuthenticator.consumers dict.

        Args:
            handler: JupyterHub's Authenticator handler object. For LTI 1.1 requests, the handler is
              an instance of LTIAuthenticateHandler.
            data: optional data object

        Returns:
            Authentication's auth_state dictionary

        Raises:
            HTTPError if the required values are not in the request
        """
        validator = LTI11LaunchValidator(self.consumers)
        lti_utils = LTIUtils()

        self.log.debug('Original arguments received in request: %s' %
                       handler.request.arguments)

        # extract the request arguments to a dict
        args = lti_utils.convert_request_to_dict(handler.request.arguments)
        self.log.debug('Decoded args from request: %s' % args)

        # get the origin protocol
        protocol = lti_utils.get_client_protocol(handler)
        self.log.debug('Origin protocol is: %s' % protocol)

        # build the full launch url value required for oauth1 signatures
        launch_url = f'{protocol}://{handler.request.host}{handler.request.uri}'
        self.log.debug('Launch url is: %s' % launch_url)

        if validator.validate_launch_request(launch_url,
                                             handler.request.headers, args):
            # get the lms vendor to implement optional logic for said vendor
            lms_vendor = ''
            if ('tool_consumer_info_product_family_code' in args
                    and args['tool_consumer_info_product_family_code']
                    is not None):
                lms_vendor = args['tool_consumer_info_product_family_code']

            # We use the course_id to setup the grader service notebook. Since this service
            # runs as a docker container we need to normalize the string so we can use it
            # as a container name.
            if 'context_label' in args and args['context_label'] is not None:
                course_id = args['context_label']
                self.log.debug('Course context_label normalized to: %s' %
                               course_id)
            else:
                raise HTTPError(
                    400, 'Course label not included in the LTI request')

            # Users have the option to initiate a launch request with the workspace_type they would like
            # to launch. edX prepends arguments with custom_*, so we need to check for those too.
            workspace_type = ''
            if 'custom_workspace_type' in args or 'workspace_type' in args:
                workspace_type = (args['custom_workspace_type']
                                  if 'custom_workspace_type' in args else
                                  args['workspace_type'])
            if workspace_type is None or workspace_type not in WORKSPACE_TYPES:
                workspace_type = 'notebook'
            self.log.debug('Workspace type assigned as: %s' % workspace_type)

            # Get the user's role, assign to Learner role by default. Roles are sent as institution
            # roles, where the roles' value is <handle>,<full URN>.
            # https://www.imsglobal.org/specs/ltiv1p0/implementation-guide#toc-16
            user_role = 'Learner'
            if 'roles' in args and args['roles'] is not None:
                user_role = args['roles'].split(',')[0]
                self.log.debug('User LTI role is: %s' % user_role)
            else:
                raise HTTPError(400,
                                'User role not included in the LTI request')

            # Assign the user_id. Check the tool consumer (lms) vendor. If canvas use their
            # custom user id extension by default, else use standar lti values.
            username = ''
            # GRADES-SENDER: retrieve assignment_name from standard property vs custom lms
            # properties, such as custom_canvas_...
            assignment_name = args[
                'resource_link_title'] if 'resource_link_title' in args else 'unknown'
            if lms_vendor == 'canvas':
                self.log.debug('TC is a Canvas LMS instance')
                if 'custom_canvas_user_login_id' in args and args[
                        'custom_canvas_user_login_id'] is not None:
                    custom_canvas_user_id = args['custom_canvas_user_login_id']
                    username = lti_utils.email_to_username(
                        custom_canvas_user_id)
                    self.log.debug('using custom_canvas_user_id for username')
                # GRADES-SENDER >>>> retrieve assignment_name from custom property
                assignment_name = (args['custom_canvas_assignment_title']
                                   if 'custom_canvas_assignment_title' in args
                                   else 'unknown')
            else:
                if (username == ''
                        and 'lis_person_contact_email_primary' in args and
                        args['lis_person_contact_email_primary'] is not None):
                    email = args['lis_person_contact_email_primary']
                    username = lti_utils.email_to_username(email)
                    self.log.debug(
                        'using lis_person_contact_email_primary for username')
                elif 'lis_person_name_given' in args and args[
                        'lis_person_name_given'] is not None:
                    username = args['lis_person_name_given']
                    self.log.debug('using lis_person_name_given for username')
                elif 'lis_person_sourcedid' in args and args[
                        'lis_person_sourcedid'] is not None:
                    username = args['lis_person_sourcedid']
                    self.log.debug('using lis_person_sourcedid for username')
            if username == '':
                self.log.debug('using user_id for username')
                if 'user_id' in args and args['user_id'] is not None:
                    username = args['user_id']
                else:
                    raise HTTPError(
                        400, 'Unable to get username from request arguments')
            self.log.debug('Assigned username is: %s' % username)

            # use the user_id to identify the unique user id, if its not sent with the request
            # then default to the username
            lms_user_id = args['user_id'] if 'user_id' in args else username

            # with all info extracted from lms request, register info for grades sender only if the user has
            # the Learner role
            lis_outcome_service_url = None
            lis_result_sourcedid = None
            if user_role == 'Learner' or user_role == 'Student':
                # the next fields must come in args
                if 'lis_outcome_service_url' in args and args[
                        'lis_outcome_service_url'] is not None:
                    lis_outcome_service_url = args['lis_outcome_service_url']
                if 'lis_result_sourcedid' in args and args[
                        'lis_result_sourcedid'] is not None:
                    lis_result_sourcedid = args['lis_result_sourcedid']
                # only if both values exist we can register them to submit grades later
                if lis_outcome_service_url and lis_result_sourcedid:
                    control_file = LTIGradesSenderControlFile(
                        f'/home/grader-{course_id}/{course_id}')
                    control_file.register_data(assignment_name,
                                               lis_outcome_service_url,
                                               lms_user_id,
                                               lis_result_sourcedid)

            return {
                'name': username,
                'auth_state': {
                    'course_id': course_id,
                    'lms_user_id': lms_user_id,
                    'user_role': user_role,
                    'workspace_type': workspace_type,
                },  # noqa: E231
            }