def external_first_login_authenticate(user, response): """ Create a special unauthenticated session for user login through external identity provider for the first time. :param user: the user with external credential :param response: the response to return :return: the response """ data = session.data if session._get_current_object() else {} data.update({ 'auth_user_external_id_provider': user['external_id_provider'], 'auth_user_external_id': user['external_id'], 'auth_user_fullname': user['fullname'], 'auth_user_access_token': user['access_token'], 'auth_user_external_first_login': True, 'service_url': user['service_url'], }) user_identity = '{}#{}'.format(user['external_id_provider'], user['external_id']) print_cas_log( f'Finalizing first-time login from external IdP - data updated: user=[{user_identity}]', LogLevel.INFO, ) response = create_session(response, data=data) print_cas_log( f'Finalizing first-time login from external IdP - anonymous session created: user=[{user_identity}]', LogLevel.INFO, ) return response
def _parse_service_validation(self, xml): resp = CasResponse() doc = etree.fromstring(xml) auth_doc = doc.xpath('/cas:serviceResponse/*[1]', namespaces=doc.nsmap)[0] resp.status = str(auth_doc.xpath('local-name()')) if (resp.status == 'authenticationSuccess'): print_cas_log( f'Service validation succeeded with status: ["{resp.status}"]', LogLevel.INFO) resp.authenticated = True resp.user = str( auth_doc.xpath('string(./cas:user)', namespaces=doc.nsmap)) attributes = auth_doc.xpath('./cas:attributes/*', namespaces=doc.nsmap) for attribute in attributes: resp.attributes[str(attribute.xpath('local-name()'))] = str( attribute.text) scopes = resp.attributes.get('accessTokenScope') resp.attributes['accessTokenScope'] = set( scopes.split(' ') if scopes else []) else: print_cas_log( f'Service validation failed with status: ["{resp.status}"]', LogLevel.ERROR) resp.authenticated = False resp_attributes = [ f'({key}: {val})' for key, val in resp.attributes.items() ] print_cas_log(f'Parsed CAS response: attributes=[{resp_attributes}]', LogLevel.INFO) return resp
def service_validate(self, ticket, service_url): """ Send request to CAS to validate ticket. :param str ticket: CAS service ticket :param str service_url: Service URL from which the authentication request originates :rtype: CasResponse :raises: CasError if an unexpected response is returned """ url = furl.furl(self.BASE_URL) url.path.segments.extend(( 'p3', 'serviceValidate', )) url.args['ticket'] = ticket url.args['service'] = service_url print_cas_log(f'Validating service ticket ["{ticket}"]', LogLevel.INFO) resp = requests.get(url.url) if resp.status_code == 200: print_cas_log( f'Service ticket validation response: ticket=[{ticket}], status=[{resp.status_code}]', LogLevel.INFO, ) return self._parse_service_validation(resp.content) else: print_cas_log( f'Service ticket validation failed: ticket=[{ticket}], status=[{resp.status_code}]', LogLevel.ERROR, ) self._handle_error(resp)
def get_user_from_cas_resp(cas_resp): """ Given a CAS service validation response, attempt to retrieve user information and next action. The `user` in `cas_resp` is the unique GUID of the user. Please do not use the primary key `id` or the email `username`. This holds except for the first step of ORCiD login. :param cas_resp: the cas service validation response :return: the user, the external_credential, and the next action """ from osf.models import OSFUser if cas_resp.user: user = OSFUser.load(cas_resp.user) # cas returns a valid OSF user id if user: return user, None, 'authenticate' # cas does not return a valid OSF user id else: external_credential = validate_external_credential(cas_resp.user) # invalid cas response if not external_credential: print_cas_log( 'CAS response error - missing user or external identity', LogLevel.ERROR) return None, None, None # cas returns a valid external credential user = get_user( external_id_provider=external_credential['provider'], external_id=external_credential['id']) # existing user found if user: return user, external_credential, 'authenticate' # user first time login through external identity provider else: return None, external_credential, 'external_first_login' print_cas_log('CAS response error - `cas_resp.user` is empty', LogLevel.ERROR) return None, None, None
def authenticate(user, access_token, response, user_updates=None): data = session.data if session._get_current_object() else {} data.update({ 'auth_user_username': user.username, 'auth_user_id': user._primary_key, 'auth_user_fullname': user.fullname, 'auth_user_access_token': access_token, }) print_cas_log( f'Finalizing authentication - data updated: user=[{user._id}]', LogLevel.INFO) enqueue_task( update_user_from_activity.s(user._id, timezone.now().timestamp(), cas_login=True, updates=user_updates)) print_cas_log( f'Finalizing authentication - user update queued: user=[{user._id}]', LogLevel.INFO) response = create_session(response, data=data) print_cas_log( f'Finalizing authentication - session created: user=[{user._id}]', LogLevel.INFO) return response
def make_response_from_ticket(ticket, service_url): """ Given a CAS ticket and service URL, attempt to validate the user and return a proper redirect response. :param str ticket: CAS service ticket :param str service_url: Service URL from which the authentication request originates :return: redirect response """ service_furl = furl.furl(service_url) # `service_url` is guaranteed to be removed of `ticket` parameter, which has been pulled off in # `framework.sessions.before_request()`. if 'ticket' in service_furl.args: service_furl.args.pop('ticket') client = get_client() cas_resp = client.service_validate(ticket, service_furl.url) if cas_resp.authenticated: user, external_credential, action = get_user_from_cas_resp(cas_resp) user_updates = {} # serialize updates to user to be applied async # user found and authenticated if user and action == 'authenticate': print_cas_log( f'CAS response - authenticating user: user=[{user._id}], ' f'external=[{external_credential}], action=[{action}]', LogLevel.INFO, ) # If users check the TOS consent checkbox via CAS, CAS sets the attribute `termsOfServiceChecked` to `true` # and then release it to OSF among other authentication attributes. When OSF receives it, it trusts CAS and # updates the user object if this is THE FINAL STEP of the login flow. DON'T update TOS consent status when # `external_credential == true` (i.e. w/ `action == 'authenticate'` or `action == 'external_first_login'`) # since neither is the final step of a login flow. tos_checked_via_cas = cas_resp.attributes.get( 'termsOfServiceChecked', 'false') == 'true' if tos_checked_via_cas: user_updates['accepted_terms_of_service'] = timezone.now() print_cas_log( f'CAS TOS consent checked: {user.guids.first()._id}, {user.username}', LogLevel.INFO) # if we successfully authenticate and a verification key is present, invalidate it if user.verification_key: user_updates['verification_key'] = None # if user is authenticated by external IDP, ask CAS to authenticate user for a second time # this extra step will guarantee that 2FA are enforced # current CAS session created by external login must be cleared first before authentication if external_credential: user.verification_key = generate_verification_key() user.save() print_cas_log( f'CAS response - redirect existing external IdP login to verification key login: user=[{user._id}]', LogLevel.INFO) return redirect( get_logout_url( get_login_url(service_url, username=user.username, verification_key=user.verification_key))) # if user is authenticated by CAS # TODO [CAS-27]: Remove Access Token From Service Validation print_cas_log( f'CAS response - finalizing authentication: user=[{user._id}]', LogLevel.INFO) return authenticate(user, cas_resp.attributes.get('accessToken', ''), redirect(service_furl.url), user_updates) # first time login from external identity provider if not user and external_credential and action == 'external_first_login': print_cas_log( f'CAS response - first login from external IdP: ' f'external=[{external_credential}], action=[{action}]', LogLevel.INFO, ) from website.util import web_url_for # orcid attributes can be marked private and not shared, default to orcid otherwise fullname = u'{} {}'.format( cas_resp.attributes.get('given-names', ''), cas_resp.attributes.get('family-name', '')).strip() # TODO [CAS-27]: Remove Access Token From Service Validation user = { 'external_id_provider': external_credential['provider'], 'external_id': external_credential['id'], 'fullname': fullname, 'access_token': cas_resp.attributes.get('accessToken', ''), 'service_url': service_furl.url, } print_cas_log( f'CAS response - creating anonymous session: external=[{external_credential}]', LogLevel.INFO) return external_first_login_authenticate( user, redirect(web_url_for('external_login_email_get'))) # Unauthorized: ticket could not be validated, or user does not exist. print_cas_log( 'Ticket validation failed or user does not exist. Redirect back to service URL (logged out).', LogLevel.ERROR) return redirect(service_furl.url)