Example #1
0
    def get_institutions_to_add_remove(self, institutions, new_institutions):
        diff = relationship_diff(
            current_items={inst._id: inst for inst in institutions.all()},
            new_items={inst['_id']: inst for inst in new_institutions}
        )

        insts_to_add = []
        for inst_id in diff['add']:
            inst = Institution.load(inst_id)
            if not inst:
                raise exceptions.NotFound(detail='Institution with id "{}" was not found'.format(inst_id))
            insts_to_add.append(inst)

        return insts_to_add, diff['remove'].values()
Example #2
0
    def get_institutions_to_add_remove(self, institutions, new_institutions):
        diff = relationship_diff(
            current_items={inst._id: inst
                           for inst in institutions.all()},
            new_items={inst['_id']: inst
                       for inst in new_institutions})

        insts_to_add = []
        for inst_id in diff['add']:
            inst = Institution.load(inst_id)
            if not inst:
                raise exceptions.NotFound(
                    detail='Institution with id "{}" was not found'.format(
                        inst_id))
            insts_to_add.append(inst)

        return insts_to_add, diff['remove'].values()
    def authenticate(self, request):
        """
        Handle CAS institution authentication request.

        The JWT `data` payload is expected in the following structure:
        {
            "provider": {
                "idp":  "",
                "id":   "",
                "user": {
                    "username":     "",
                    "fullname":     "",
                    "familyName":   "",
                    "givenName":    "",
                    "middleNames":  "",
                    "suffix":       "",
                }
            }
        }

        :param request: the POST request
        :return: user, None if authentication succeed
        :raises: AuthenticationFailed if authentication fails
        """

        try:
            payload = jwt.decode(
                jwe.decrypt(request.body, settings.JWE_SECRET),
                settings.JWT_SECRET,
                options={'verify_exp': False},
                algorithm='HS256',
            )
        except (jwt.InvalidTokenError, TypeError):
            raise AuthenticationFailed

        data = json.loads(payload['data'])
        provider = data['provider']

        institution = Institution.load(provider['id'])
        if not institution:
            raise AuthenticationFailed('Invalid institution id specified "{}"'.format(provider['id']))

        username = provider['user'].get('username')
        fullname = provider['user'].get('fullname')
        given_name = provider['user'].get('givenName')
        family_name = provider['user'].get('familyName')
        middle_names = provider['user'].get('middleNames')
        suffix = provider['user'].get('suffix')

        # use given name and family name to build full name if not provided
        if given_name and family_name and not fullname:
            fullname = given_name + ' ' + family_name

        # institution must provide `fullname`, otherwise we fail the authentication and inform sentry
        if not fullname:
            message = 'Institution login failed: fullname required' \
                      ' for user {} from institution {}'.format(username, provider['id'])
            sentry.log_message(message)
            raise AuthenticationFailed(message)

        # `get_or_create_user()` guesses names from fullname
        # replace the guessed ones if the names are provided from the authentication
        user, created = get_or_create_user(fullname, username, reset_password=False)
        if created:
            if given_name:
                user.given_name = given_name
            if family_name:
                user.family_name = family_name
            if middle_names:
                user.middle_names = middle_names
            if suffix:
                user.suffix = suffix
            user.update_date_last_login()

            # Relying on front-end validation until `accepted_tos` is added to the JWT payload
            user.accepted_terms_of_service = timezone.now()

            # save and register user
            user.save()
            user.register(username)

            # send confirmation email
            send_mail(
                to_addr=user.username,
                mail=WELCOME_OSF4I,
                mimetype='html',
                user=user,
                domain=DOMAIN,
                osf_support_email=OSF_SUPPORT_EMAIL,
                storage_flag_is_active=waffle.flag_is_active(request, features.STORAGE_I18N),
            )

        if not user.is_affiliated_with_institution(institution):
            user.affiliated_institutions.add(institution)
            user.save()

        return user, None
Example #4
0
    def authenticate(self, request):
        """
        Handle CAS institution authentication request.

        The JWT `data` payload is expected in the following structure:
        {
            "provider": {
                "idp":  "",
                "id":   "",
                "user": {
                    "username":     "",
                    "fullname":     "",
                    "familyName":   "",
                    "givenName":    "",
                    "middleNames":  "",
                    "suffix":       "",
                }
            }
        }

        :param request: the POST request
        :return: user, None if authentication succeed
        :raises: AuthenticationFailed if authentication fails
        """

        try:
            payload = jwt.decode(
                jwe.decrypt(request.body, settings.JWE_SECRET),
                settings.JWT_SECRET,
                options={'verify_exp': False},
                algorithm='HS256',
            )
        except (jwt.InvalidTokenError, TypeError,
                jwe.exceptions.MalformedData):
            raise AuthenticationFailed

        data = json.loads(payload['data'])
        provider = data['provider']

        institution = Institution.load(provider['id'])
        if not institution:
            raise AuthenticationFailed(
                'Invalid institution id specified "{}"'.format(provider['id']))

        username = provider['user'].get('username')
        fullname = provider['user'].get('fullname')
        given_name = provider['user'].get('givenName')
        family_name = provider['user'].get('familyName')
        middle_names = provider['user'].get('middleNames')
        suffix = provider['user'].get('suffix')

        # use given name and family name to build full name if not provided
        if given_name and family_name and not fullname:
            fullname = given_name + ' ' + family_name

        # institution must provide `fullname`, otherwise we fail the authentication and inform sentry
        if not fullname:
            message = 'Institution login failed: fullname required' \
                      ' for user {} from institution {}'.format(username, provider['id'])
            sentry.log_message(message)
            raise AuthenticationFailed(message)

        # `get_or_create_user()` guesses names from fullname
        # replace the guessed ones if the names are provided from the authentication
        user, created = get_or_create_user(fullname,
                                           username,
                                           reset_password=False)
        if created:
            if given_name:
                user.given_name = given_name
            if family_name:
                user.family_name = family_name
            if middle_names:
                user.middle_names = middle_names
            if suffix:
                user.suffix = suffix
            user.update_date_last_login()

            # Relying on front-end validation until `accepted_tos` is added to the JWT payload
            user.accepted_terms_of_service = timezone.now()

            # save and register user
            user.save()
            user.register(username)

            # send confirmation email
            send_mail(
                to_addr=user.username,
                mail=WELCOME_OSF4I,
                mimetype='html',
                user=user,
                domain=DOMAIN,
                osf_support_email=OSF_SUPPORT_EMAIL,
                storage_flag_is_active=waffle.flag_is_active(
                    request, features.STORAGE_I18N),
            )

        if not user.is_affiliated_with_institution(institution):
            user.affiliated_institutions.add(institution)
            user.save()

        return user, None
Example #5
0
    def authenticate(self, request):
        """
        Handle CAS institution authentication request.

        The JWT `data` payload is expected in the following structure:
        {
            "provider": {
                "idp": "",
                "id": "",
                "user": {
                    "username": "",
                    "fullname": "",
                    "familyName": "",
                    "givenName": "",
                    "middleNames": "",
                    "suffix": "",
                    "department": "",
                    "isMemberOf": "",  # Shared SSO
                    "selectiveSsoFilter": "",  # Selective SSO
                }
            }
        }

        Note that if authentication failed, HTTP 403 Forbidden is returned no matter what type of
        exception is raised. In this method, we use `AuthenticationFailed` when the payload is not
        correctly encrypted/encoded since it is the "authentication" between CAS and this endpoint.
        We use `PermissionDenied` for all other exceptions that happened afterwards.

        :param request: the POST request
        :return: user, None if authentication succeed
        :raises: AuthenticationFailed or PermissionDenied if authentication fails
        """

        # Verify / decrypt / decode the payload
        try:
            payload = jwt.decode(
                jwe.decrypt(request.body, settings.JWE_SECRET),
                settings.JWT_SECRET,
                options={'verify_exp': False},
                algorithm='HS256',
            )
        except (jwt.InvalidTokenError, TypeError,
                jwe.exceptions.MalformedData):
            raise AuthenticationFailed(
                detail='InstitutionSsoRequestNotAuthorized')

        # Load institution and user data
        data = json.loads(payload['data'])
        provider = data['provider']
        institution = Institution.load(provider['id'])
        if not institution:
            message = 'Institution SSO Error: invalid institution ID [{}]'.format(
                provider['id'])
            logger.error(message)
            sentry.log_message(message)
            raise PermissionDenied(detail='InstitutionSsoInvalidInstitution')
        username = provider['user'].get('username')
        fullname = provider['user'].get('fullname')
        given_name = provider['user'].get('givenName')
        family_name = provider['user'].get('familyName')
        middle_names = provider['user'].get('middleNames')
        suffix = provider['user'].get('suffix')
        department = provider['user'].get('department')
        selective_sso_filter = provider['user'].get('selectiveSsoFilter')

        # Check selective login first
        if provider['id'] in INSTITUTION_SELECTIVE_SSO_MAP:
            if selective_sso_filter != INSTITUTION_SELECTIVE_SSO_MAP[
                    provider['id']]:
                message = f'Institution SSO Error: user [email={username}] is not allowed for ' \
                          f'institution SSO [id={institution._id}] due to selective SSO rules'
                logger.error(message)
                sentry.log_message(message)
                raise PermissionDenied(
                    detail='InstitutionSsoSelectiveNotAllowed')
            logger.info(
                f'Institution SSO: selective SSO verified for user [email={username}] '
                f'at institution [id={institution._id}]', )

        # Check secondary institutions which uses the SSO of primary ones
        secondary_institution = None
        if provider['id'] in INSTITUTION_SHARED_SSO_MAP:
            switch_map = INSTITUTION_SHARED_SSO_MAP[provider['id']]
            criteria_type = switch_map.get('criteria')
            if criteria_type == 'attribute':
                attribute_name = switch_map.get('attribute')
                attribute_value = provider['user'].get(attribute_name)
                if attribute_value:
                    secondary_institution_id = switch_map.get(
                        'institutions',
                        {},
                    ).get(attribute_value)
                    logger.info(
                        'Institution SSO: primary=[{}], secondary=[{}], '
                        'username=[{}]'.format(provider['id'],
                                               secondary_institution_id,
                                               username))
                    secondary_institution = Institution.load(
                        secondary_institution_id)
                    if not secondary_institution:
                        # Log errors and inform Sentry but do not raise an exception if OSF fails
                        # to load the secondary institution from database
                        message = 'Institution SSO Error: invalid secondary institution [{}]; ' \
                                  'primary=[{}], username=[{}]'.format(attribute_value, provider['id'], username)
                        logger.error(message)
                        sentry.log_message(message)
                else:
                    # SSO from primary institution only
                    logger.info(
                        'Institution SSO: primary=[{}], secondary=[None], '
                        'username=[{}]'.format(provider['id'], username))
            else:
                message = 'Institution SSO Error: invalid criteria [{}]; ' \
                          'primary=[{}], username=[{}]'.format(criteria_type, provider['id'], username)
                logger.error(message)
                sentry.log_message(message)

        # Use given name and family name to build full name if it is not provided
        if given_name and family_name and not fullname:
            fullname = given_name + ' ' + family_name

        # Non-empty full name is required. Fail the auth and inform sentry if not provided.
        if not fullname:
            message = 'Institution SSO Error: missing fullname ' \
                      'for user [{}] from institution [{}]'.format(username, provider['id'])
            logger.error(message)
            sentry.log_message(message)
            raise PermissionDenied(detail='InstitutionSsoMissingUserNames')

        # Get an existing user or create a new one. If a new user is created, the user object is
        # confirmed but not registered,which is temporarily of an inactive status. If an existing
        # user is found, it is also possible that the user is inactive (e.g. unclaimed, disabled,
        # unconfirmed, etc.).
        user, created = get_or_create_user(fullname,
                                           username,
                                           reset_password=False)

        # Existing but inactive users need to be either "activated" or failed the auth
        activation_required = False
        new_password_required = False
        if not created:
            try:
                drf.check_user(user)
                logger.info(
                    'Institution SSO: active user [{}]'.format(username))
            except exceptions.UnclaimedAccountError:
                # Unclaimed user (i.e. a user that has been added as an unregistered contributor)
                user.unclaimed_records = {}
                activation_required = True
                # Unclaimed users have an unusable password when being added as an unregistered
                # contributor. Thus a random usable password must be assigned during activation.
                new_password_required = True
                logger.warning(
                    'Institution SSO: unclaimed contributor [{}]'.format(
                        username))
            except exceptions.UnconfirmedAccountError:
                if user.has_usable_password():
                    # Unconfirmed user from default username / password signup
                    user.email_verifications = {}
                    activation_required = True
                    # Unconfirmed users already have a usable password set by the creator during
                    # sign-up. However, it must be overwritten by a new random one so the creator
                    # (if he is not the real person) can not access the account after activation.
                    new_password_required = True
                    logger.warning(
                        'Institution SSO: unconfirmed user [{}]'.format(
                            username))
                else:
                    # Login take-over has not been implemented for unconfirmed user created via
                    # external IdP login (ORCiD).
                    message = 'Institution SSO Error: SSO is not eligible for an unconfirmed account [{}] ' \
                              'created via IdP login'.format(username)
                    sentry.log_message(message)
                    logger.error(message)
                    raise PermissionDenied(
                        detail='InstitutionSsoAccountNotConfirmed')
            except exceptions.DeactivatedAccountError:
                # Deactivated user: login is not allowed for deactivated users
                message = 'Institution SSO Error: SSO is not eligible for a deactivated account: [{}]'.format(
                    username)
                sentry.log_message(message)
                logger.error(message)
                raise PermissionDenied(detail='InstitutionSsoAccountDisabled')
            except exceptions.MergedAccountError:
                # Merged user: this shouldn't happen since merged users do not have an email
                message = 'Institution SSO Error: SSO is not eligible for a merged account: [{}]'.format(
                    username)
                sentry.log_message(message)
                logger.error(message)
                raise PermissionDenied(detail='InstitutionSsoAccountMerged')
            except exceptions.InvalidAccountError:
                # Other invalid status: this shouldn't happen unless the user happens to be in a
                # temporary state. Such state requires more updates before the user can be saved
                # to the database. (e.g. `get_or_create_user()` creates a temporary-state user.)
                message = 'Institution SSO Error: SSO is not eligible for an inactive account [{}] ' \
                          'with an unknown or invalid status'.format(username)
                sentry.log_message(message)
                logger.error(message)
                raise PermissionDenied(detail='InstitutionSsoInvalidAccount')
        else:
            logger.info('Institution SSO: new user [{}]'.format(username))

        # The `department` field is updated each login when it was changed.
        user_guid = user.guids.first()._id
        if department:
            if user.department != department:
                user.department = department
                user.save()
            logger.info(
                'Institution SSO: user w/ dept: user=[{}], email=[{}], inst=[{}], '
                'dept=[{}]'.format(user_guid, username, institution._id,
                                   department))
        else:
            logger.info(
                'Institution SSO: user w/o dept: user=[{}], email=[{}], '
                'inst=[{}]'.format(user_guid, username, institution._id))

        # Both created and activated accounts need to be updated and registered
        if created or activation_required:

            if given_name:
                user.given_name = given_name
            if family_name:
                user.family_name = family_name
            if middle_names:
                user.middle_names = middle_names
            if suffix:
                user.suffix = suffix

            # Users claimed or confirmed via institution SSO should have their full name updated
            if activation_required:
                user.fullname = fullname

            user.update_date_last_login()

            # Register and save user
            password = str(uuid.uuid4()) if new_password_required else None
            user.register(username, password=password)
            user.save()

            # Send confirmation email for all three: created, confirmed and claimed
            send_mail(
                to_addr=user.username,
                mail=WELCOME_OSF4I,
                user=user,
                domain=DOMAIN,
                osf_support_email=OSF_SUPPORT_EMAIL,
                storage_flag_is_active=waffle.flag_is_active(
                    request, features.STORAGE_I18N),
            )

        # Affiliate the user to the primary institution if not previously affiliated
        if not user.is_affiliated_with_institution(institution):
            user.affiliated_institutions.add(institution)
            user.save()

        # Affiliate the user to the secondary institution if not previously affiliated
        if secondary_institution and not user.is_affiliated_with_institution(
                secondary_institution):
            user.affiliated_institutions.add(secondary_institution)
            user.save()

        return user, None
Example #6
0
    def authenticate(self, request):
        """
        Handle CAS institution authentication request.

        The JWT `data` payload is expected in the following structure:
        {
            "provider": {
                "idp":  "",
                "id":   "",
                "user": {
                    "username":     "",
                    "fullname":     "",
                    "familyName":   "",
                    "givenName":    "",
                    "middleNames":  "",
                    "suffix":       "",
                }
            }
        }

        :param request: the POST request
        :return: user, None if authentication succeed
        :raises: AuthenticationFailed if authentication fails
        """

        # Verify / decrypt / decode the payload
        try:
            payload = jwt.decode(
                jwe.decrypt(request.body, settings.JWE_SECRET),
                settings.JWT_SECRET,
                options={'verify_exp': False},
                algorithm='HS256',
            )
        except (jwt.InvalidTokenError, TypeError, jwe.exceptions.MalformedData):
            raise AuthenticationFailed

        # Load institution and user data
        data = json.loads(payload['data'])
        provider = data['provider']
        institution = Institution.load(provider['id'])
        if not institution:
            raise AuthenticationFailed('Invalid institution id: "{}"'.format(provider['id']))
        username = provider['user'].get('username')
        fullname = provider['user'].get('fullname')
        given_name = provider['user'].get('givenName')
        family_name = provider['user'].get('familyName')
        middle_names = provider['user'].get('middleNames')
        suffix = provider['user'].get('suffix')
        department = provider['user'].get('department')

        # Use given name and family name to build full name if it is not provided
        if given_name and family_name and not fullname:
            fullname = given_name + ' ' + family_name

        # Non-empty full name is required. Fail the auth and inform sentry if not provided.
        if not fullname:
            message = 'Institution login failed: fullname required for ' \
                      'user "{}" from institution "{}"'.format(username, provider['id'])
            sentry.log_message(message)
            raise AuthenticationFailed(message)

        # Get an existing user or create a new one. If a new user is created, the user object is
        # confirmed but not registered,which is temporarily of an inactive status. If an existing
        # user is found, it is also possible that the user is inactive (e.g. unclaimed, disabled,
        # unconfirmed, etc.).
        user, created = get_or_create_user(fullname, username, reset_password=False)

        # Existing but inactive users need to be either "activated" or failed the auth
        activation_required = False
        new_password_required = False
        if not created:
            try:
                drf.check_user(user)
                logger.info('Institution SSO: active user "{}"'.format(username))
            except exceptions.UnclaimedAccountError:
                # Unclaimed user (i.e. a user that has been added as an unregistered contributor)
                user.unclaimed_records = {}
                activation_required = True
                # Unclaimed users have an unusable password when being added as an unregistered
                # contributor. Thus a random usable password must be assigned during activation.
                new_password_required = True
                logger.info('Institution SSO: unclaimed contributor "{}"'.format(username))
            except exceptions.UnconfirmedAccountError:
                if user.has_usable_password():
                    # Unconfirmed user from default username / password signup
                    user.email_verifications = {}
                    activation_required = True
                    # Unconfirmed users already have a usable password set by the creator during
                    # sign-up. However, it must be overwritten by a new random one so the creator
                    # (if he is not the real person) can not access the account after activation.
                    new_password_required = True
                    logger.info('Institution SSO: unconfirmed user "{}"'.format(username))
                else:
                    # Login take-over has not been implemented for unconfirmed user created via
                    # external IdP login (ORCiD).
                    message = 'Institution SSO is not eligible for an unconfirmed account ' \
                              'created via external IdP login: username = "******"'.format(username)
                    sentry.log_message(message)
                    logger.error(message)
                    return None, None
            except exceptions.DeactivatedAccountError:
                # Deactivated user: login is not allowed for deactivated users
                message = 'Institution SSO is not eligible for a deactivated account: ' \
                          'username = "******"'.format(username)
                sentry.log_message(message)
                logger.error(message)
                return None, None
            except exceptions.MergedAccountError:
                # Merged user: this shouldn't happen since merged users do not have an email
                message = 'Institution SSO is not eligible for a merged account: ' \
                          'username = "******"'.format(username)
                sentry.log_message(message)
                logger.error(message)
                return None, None
            except exceptions.InvalidAccountError:
                # Other invalid status: this shouldn't happen unless the user happens to be in a
                # temporary state. Such state requires more updates before the user can be saved
                # to the database. (e.g. `get_or_create_user()` creates a temporary-state user.)
                message = 'Institution SSO is not eligible for an inactive account with ' \
                          'an unknown or invalid status: username = "******"'.format(username)
                sentry.log_message(message)
                logger.error(message)
                return None, None
        else:
            logger.info('Institution SSO: new user "{}"'.format(username))

        # The `department` field is updated each login when it was changed.
        if department and user.department != department:
            user.department = department
            user.save()

        # Both created and activated accounts need to be updated and registered
        if created or activation_required:

            if given_name:
                user.given_name = given_name
            if family_name:
                user.family_name = family_name
            if middle_names:
                user.middle_names = middle_names
            if suffix:
                user.suffix = suffix

            # Users claimed or confirmed via institution SSO should have their full name updated
            if activation_required:
                user.fullname = fullname

            user.update_date_last_login()

            # Relying on front-end validation until `accepted_tos` is added to the JWT payload
            user.accepted_terms_of_service = timezone.now()

            # Register and save user
            password = str(uuid.uuid4()) if new_password_required else None
            user.register(username, password=password)
            user.save()

            # Send confirmation email for all three: created, confirmed and claimed
            send_mail(
                to_addr=user.username,
                mail=WELCOME_OSF4I,
                mimetype='html',
                user=user,
                domain=DOMAIN,
                osf_support_email=OSF_SUPPORT_EMAIL,
                storage_flag_is_active=waffle.flag_is_active(request, features.STORAGE_I18N),
            )

        # Affiliate the user if not previously affiliated
        if not user.is_affiliated_with_institution(institution):
            user.affiliated_institutions.add(institution)
            user.save()

        return user, None