def test_existing_user_invitation_accepted(self): """ SsoBackend should create a new user if the username passed to does not exist and the email domain matches an AuthenticatedEmailDomain for the given IdentityProvider. It should also ensure that any user data from a registration form and/or the samlUserdata are all properly saved to the User model. """ admin_role = StaticRole.domain_admin(domain=self.domain.name) existing_user = WebUser.create(None, '*****@*****.**', 'testpwd', None, None) invitation = Invitation( domain=self.domain.name, email=existing_user.username, invited_by=self.user.couch_id, invited_on=datetime.datetime.utcnow(), role=admin_role.get_qualified_id(), ) invitation.save() AsyncSignupRequest.create_from_invitation(invitation) user = auth.authenticate( request=self.request, username=invitation.email, idp_slug=self.idp.slug, is_handshake_successful=True, ) self.assertIsNotNone(user) self.assertEqual(user.username, invitation.email) self.assertEqual(self.request.sso_new_user_messages['success'], [ f'You have been added to the "{invitation.domain}" project space.', ])
def test_new_user_created_and_invitation_accepted(self): """ When SsoBackend creates a new user and an invitation is present, that invitation should add the user to the invited project space and accept the invitation """ admin_role = StaticRole.domain_admin(self.domain.name) invitation = Invitation( domain=self.domain.name, email='*****@*****.**', invited_by=self.user.couch_id, invited_on=datetime.datetime.utcnow(), role=admin_role.get_qualified_id(), ) invitation.save() AsyncSignupRequest.create_from_invitation(invitation) generator.store_full_name_in_saml_user_data(self.request, 'Isa', 'Baas') user = auth.authenticate( request=self.request, username=invitation.email, idp_slug=self.idp.slug, is_handshake_successful=True, ) self.assertIsNotNone(user) self.assertEqual(user.username, invitation.email) self.assertEqual(user.first_name, 'Isa') self.assertEqual(user.last_name, 'Baas') self.assertEqual(self.request.sso_new_user_messages['success'], [ f'User account for {invitation.email} created.', f'You have been added to the "{invitation.domain}" project space.', ])
def test_new_user_created_and_expired_invitation_declined(self): """ When SsoBackend creates a new user and an EXPIRED invitation is present, a new user should still be created, but the invitation should be declined. """ invitation = Invitation( domain=self.domain.name, email='*****@*****.**', invited_by=self.user.couch_id, invited_on=datetime.datetime.utcnow() - relativedelta(months=2), ) invitation.save() AsyncSignupRequest.create_from_invitation(invitation) generator.store_full_name_in_saml_user_data(self.request, 'Zee', 'Bos') user = auth.authenticate( request=self.request, username=invitation.email, idp_slug=self.idp.slug, is_handshake_successful=True, ) self.assertIsNotNone(user) self.assertEqual(user.username, invitation.email) self.assertEqual(user.first_name, 'Zee') self.assertEqual(user.last_name, 'Bos') self.assertEqual(self.request.sso_new_user_messages['success'], [ f'User account for {invitation.email} created.', ]) self.assertEqual(self.request.sso_new_user_messages['error'], [ 'Could not accept invitation because it is expired.', ])
def test_new_user_displayname_is_used_if_first_and_last_are_missing(self): """ Azure AD does not mark the First and Last names as required, only the Display Name. If First and Last are missing, ensure that this information is then obtained from the Display Name """ username = '******' reg_form = RegisterWebUserForm() reg_form.cleaned_data = { 'email': username, 'phone_number': '+15555555555', 'project_name': 'test-vault', 'persona': 'Other', 'persona_other': "for tests", } generator.store_display_name_in_saml_user_data(self.request, 'Vanessa van Beek') AsyncSignupRequest.create_from_registration_form(reg_form) user = auth.authenticate( request=self.request, username=username, idp_slug=self.idp.slug, is_handshake_successful=True, ) self.assertIsNotNone(user) self.assertEqual(user.username, username) self.assertEqual(user.first_name, 'Vanessa') self.assertEqual(user.last_name, 'van Beek') self.assertEqual(self.request.sso_new_user_messages['success'], ["User account for [email protected] created."])
def test_new_user_displayname_with_one_name_is_used_as_first_name(self): """ Ensure that if the Azure AD "Display Name" has only one name/word in it that only the first name is populated. """ username = '******' reg_form = RegisterWebUserForm() reg_form.cleaned_data = { 'email': username, 'phone_number': '+15555555555', 'project_name': 'test-vault', 'persona': 'Other', 'persona_other': "for tests", } generator.store_display_name_in_saml_user_data(self.request, 'Test') AsyncSignupRequest.create_from_registration_form(reg_form) user = auth.authenticate( request=self.request, username=username, idp_slug=self.idp.slug, is_handshake_successful=True, ) self.assertIsNotNone(user) self.assertEqual(user.username, username) self.assertEqual(user.first_name, 'Test') self.assertEqual(user.last_name, '') self.assertEqual(self.request.sso_new_user_messages['success'], ["User account for [email protected] created."])
def test_new_user_created_and_data_is_saved(self): """ SsoBackend should create a new user if the username passed to does not exist and the email domain matches an AuthenticatedEmailDomain for the given IdentityProvider. It should also ensure that any user data from a registration form and/or the samlUserdata are all properly saved to the User model. """ username = '******' reg_form = RegisterWebUserForm() reg_form.cleaned_data = { 'email': username, 'phone_number': '+15555555555', 'project_name': 'test-vault', 'persona': 'Other', 'persona_other': "for tests", } generator.store_full_name_in_saml_user_data(self.request, 'Maarten', 'van der Berg') AsyncSignupRequest.create_from_registration_form(reg_form) user = auth.authenticate( request=self.request, username=username, idp_slug=self.idp.slug, is_handshake_successful=True, ) self.assertIsNotNone(user) self.assertEqual(user.username, username) self.assertEqual(user.first_name, 'Maarten') self.assertEqual(user.last_name, 'van der Berg') web_user = WebUser.get_by_username(user.username) self.assertEqual(web_user.phone_numbers[0], '+15555555555') self.assertEqual(self.request.sso_new_user_messages['success'], ["User account for [email protected] created."])
def register_new_user(self, data): idp = None if settings.ENFORCE_SSO_LOGIN: idp = IdentityProvider.get_required_identity_provider( data['data']['email']) reg_form = RegisterWebUserForm(data['data'], is_sso=idp is not None) if reg_form.is_valid(): ab_test = ab_tests.SessionAbTest(ab_tests.APPCUES_V3_APP, self.request) appcues_ab_test = ab_test.context['version'] if idp: signup_request = AsyncSignupRequest.create_from_registration_form( reg_form, additional_hubspot_data={ "appcues_test": appcues_ab_test, }) return { 'success': True, 'appcues_ab_test': appcues_ab_test, 'ssoLoginUrl': idp.get_login_url(signup_request.username), 'ssoIdpName': idp.name, } self._create_new_account(reg_form, additional_hubspot_data={ "appcues_test": appcues_ab_test, }) try: request_new_domain(self.request, reg_form.cleaned_data['project_name'], is_new_user=True) except NameUnavailableException: # technically, the form should never reach this as names are # auto-generated now. But, just in case... logging.error( "There as an issue generating a unique domain name " "for a user during new registration.") return { 'errors': { 'project name unavailable': [], } } return { 'success': True, 'appcues_ab_test': appcues_ab_test, } logging.error( "There was an error processing a new user registration form." "This shouldn't happen as validation should be top-notch " "client-side. Here is what the errors are: {}".format( reg_form.errors)) return { 'errors': reg_form.errors, }
def authenticate(self, request, username, idp_slug, is_handshake_successful): if not (request and username and idp_slug and is_handshake_successful): return None username = username.lower() try: identity_provider = IdentityProvider.objects.get(slug=idp_slug) except IdentityProvider.DoesNotExist: # not sure how we would even get here, but just in case request.sso_login_error = f"Identity Provider {idp_slug} does not exist." return None if not identity_provider.is_active: request.sso_login_error = f"This Identity Provider {idp_slug} is not active." return None email_domain = get_email_domain_from_username(username) if not email_domain: # not a valid username request.sso_login_error = f"Username {username} is not valid." return None if not AuthenticatedEmailDomain.objects.filter( email_domain=email_domain, identity_provider=identity_provider ).exists(): # if this user's email domain is not authorized by this identity # do not continue with authentication request.sso_login_error = ( f"The Email Domain {email_domain} is not allowed to " f"authenticate with this Identity Provider ({idp_slug})." ) return None async_signup = AsyncSignupRequest.get_by_username(username) # because the django messages middleware is not yet available... request.sso_new_user_messages = { 'success': [], 'error': [], } try: user = User.objects.get(username=username) is_new_user = False web_user = WebUser.get_by_username(username) except User.DoesNotExist: user, web_user = self._create_new_user(request, username, async_signup) is_new_user = True if async_signup and async_signup.invitation: self._process_invitation(request, async_signup.invitation, web_user, is_new_user) request.sso_login_error = None return user
def sso_test_create_user(request, idp_slug): """ A testing view exclusively for staging. This will be removed once the UIs are in place to sign up users or invite new users who must log in with SSO. """ if settings.SERVER_ENVIRONMENT not in ['staging']: raise Http404() username = request.GET.get('username') if username: prepare_session_with_sso_username(request, username) invitation_uuid = request.GET.get('invitation') invitation = Invitation.objects.get( uuid=invitation_uuid) if invitation_uuid else None if invitation: AsyncSignupRequest.create_from_invitation(invitation) return HttpResponseRedirect(reverse("sso_saml_login", args=(idp_slug, )))
def sso_saml_acs(request, idp_slug): """ ACS stands for "Assertion Consumer Service". The Identity Provider will send its response to this view after authenticating a user. This is often referred to as the "Entity ID" in the IdP's Service Provider configuration. In this view we verify the received SAML 2.0 response and then log in the user to CommCare HQ. """ request_id = request.session.get('AuthNRequestID') error_template = 'sso/acs_errors.html' try: request.saml2_auth.process_response(request_id=request_id) errors = request.saml2_auth.get_errors() except OneLogin_Saml2_Error as e: if e.code == OneLogin_Saml2_Error.SAML_RESPONSE_NOT_FOUND: return redirect("sso_saml_login", idp_slug=idp_slug) errors = [e] if errors: return render( request, error_template, { 'saml_error_reason': request.saml2_auth.get_last_error_reason() or errors[0], 'idp_type': "Azure AD", # we will update this later, 'docs_link': get_documentation_url(request.idp), }) if not request.saml2_auth.is_authenticated(): return render(request, 'sso/sso_request_denied.html', {}) if 'AuthNRequestID' in request.session: del request.session['AuthNRequestID'] store_saml_data_in_session(request) user = auth.authenticate( request=request, username=request.session['samlNameId'], idp_slug=idp_slug, is_handshake_successful=True, ) # we add the messages to the django messages framework here since # that middleware was not available for SsoBackend if hasattr(request, 'sso_new_user_messages'): for success_message in request.sso_new_user_messages['success']: messages.success(request, success_message) for error_message in request.sso_new_user_messages['error']: messages.error(request, error_message) if user: auth.login(request, user) # activate new project if needed async_signup = AsyncSignupRequest.get_by_username(user.username) if async_signup and async_signup.project_name: try: request_new_domain(request, async_signup.project_name, is_new_user=True, is_new_sso_user=True) except NameUnavailableException: # this should never happen, but in the off chance it does # we don't want to throw a 500 on this view messages.error( request, _("We were unable to create your requested project " "because the name was already taken." "Please contact support.")) AsyncSignupRequest.clear_data_for_username(user.username) relay_state = request.saml2_request_data['post_data'].get('RelayState') if relay_state not in [ OneLogin_Saml2_Utils.get_self_url(request.saml2_request_data), get_saml_login_url(request.idp), ]: # redirect to next=<relay_state> return HttpResponseRedirect( request.saml2_auth.redirect_to(relay_state)) return redirect("homepage") return render(request, error_template, { 'login_error': getattr(request, 'sso_login_error', None), })
def __call__(self, request, uuid, **kwargs): # add the correct parameters to this instance self.request = request if 'domain' in kwargs: self.domain = kwargs['domain'] if request.GET.get('switch') == 'true': logout(request) return redirect_to_login(request.path) if request.GET.get('create') == 'true': logout(request) return HttpResponseRedirect(request.path) try: invitation = Invitation.objects.get(uuid=uuid) except (Invitation.DoesNotExist, ValidationError): messages.error( request, _("Sorry, it looks like your invitation has expired. " "Please check the invitation link you received and try again, or " "request a project administrator to send you the invitation again." )) return HttpResponseRedirect(reverse("login")) if invitation.is_accepted: messages.error( request, _("Sorry, that invitation has already been used up. " "If you feel this is a mistake please ask the inviter for " "another invitation.")) return HttpResponseRedirect(reverse("login")) self.validate_invitation(invitation) if invitation.is_expired: return HttpResponseRedirect(reverse("no_permissions")) # Add zero-width space to username for better line breaking username = self.request.user.username.replace("@", "​@") context = { 'formatted_username': username, 'domain': self.domain, 'invite_to': self.domain, 'invite_type': _('Project'), 'hide_password_feedback': has_custom_clean_password(), } if request.user.is_authenticated: context['current_page'] = {'page_name': _('Project Invitation')} else: context['current_page'] = { 'page_name': _('Project Invitation, Account Required') } if request.user.is_authenticated: is_invited_user = request.couch_user.username.lower( ) == invitation.email.lower() if self.is_invited(invitation, request.couch_user ) and not request.couch_user.is_superuser: if is_invited_user: # if this invite was actually for this user, just mark it accepted messages.info( request, _("You are already a member of {entity}.").format( entity=self.inviting_entity)) invitation.is_accepted = True invitation.save() else: messages.error( request, _("It looks like you are trying to accept an invitation for " "{invited} but you are already a member of {entity} with the " "account {current}. Please sign out to accept this invitation " "as another user.").format( entity=self.inviting_entity, invited=invitation.email, current=request.couch_user.username)) return HttpResponseRedirect( self.redirect_to_on_success(invitation.email, self.domain)) if not is_invited_user: messages.error( request, _("The invited user {invited} and your user {current} " "do not match!").format( invited=invitation.email, current=request.couch_user.username)) if request.method == "POST": couch_user = CouchUser.from_django_user(request.user, strict=True) invitation.accept_invitation_and_join_domain(couch_user) log_user_change( by_domain=invitation.domain, for_domain=invitation.domain, couch_user=couch_user, changed_by_user=CouchUser.get_by_user_id( invitation.invited_by), changed_via=USER_CHANGE_VIA_INVITATION, change_messages=UserChangeMessage.domain_addition( invitation.domain)) track_workflow( request.couch_user.get_email(), "Current user accepted a project invitation", {"Current user accepted a project invitation": "yes"}) send_hubspot_form(HUBSPOT_EXISTING_USER_INVITE_FORM, request) return HttpResponseRedirect( self.redirect_to_on_success(invitation.email, self.domain)) else: mobile_user = CouchUser.from_django_user( request.user).is_commcare_user() context.update({ 'mobile_user': mobile_user, "invited_user": invitation.email if request.couch_user.username != invitation.email else "", }) return render(request, self.template, context) else: idp = None if settings.ENFORCE_SSO_LOGIN: idp = IdentityProvider.get_active_identity_provider_by_username( invitation.email) if request.method == "POST": form = WebUserInvitationForm(request.POST, is_sso=idp is not None) if form.is_valid(): # create the new user invited_by_user = CouchUser.get_by_user_id( invitation.invited_by) if idp: signup_request = AsyncSignupRequest.create_from_invitation( invitation) return HttpResponseRedirect( idp.get_login_url(signup_request.username)) user = activate_new_user_via_reg_form( form, created_by=invited_by_user, created_via=USER_CHANGE_VIA_INVITATION, domain=invitation.domain, is_domain_admin=False, ) user.save() messages.success( request, _("User account for %s created!") % form.cleaned_data["email"]) invitation.accept_invitation_and_join_domain(user) messages.success( self.request, _('You have been added to the "{}" project space.'). format(self.domain)) authenticated = authenticate( username=form.cleaned_data["email"], password=form.cleaned_data["password"], request=request) if authenticated is not None and authenticated.is_active: login(request, authenticated) track_workflow( request.POST['email'], "New User Accepted a project invitation", {"New User Accepted a project invitation": "yes"}) send_hubspot_form(HUBSPOT_NEW_USER_INVITE_FORM, request, user) return HttpResponseRedirect( self.redirect_to_on_success(invitation.email, invitation.domain)) else: if (CouchUser.get_by_username(invitation.email) or User.objects.filter( username__iexact=invitation.email).count() > 0): login_url = reverse("login") accept_invitation_url = reverse( 'domain_accept_invitation', args=[invitation.domain, invitation.uuid]) return HttpResponseRedirect( f"{login_url}" f"?next={accept_invitation_url}" f"&username={invitation.email}") form = WebUserInvitationForm( initial={ 'email': invitation.email, }, is_sso=idp is not None, ) context.update({ 'is_sso': idp is not None, 'idp_name': idp.name if idp else None, 'invited_user': invitation.email, }) context.update({"form": form}) return render(request, self.template, context)
def sso_saml_acs(request, idp_slug): """ ACS stands for "Assertion Consumer Service". The Identity Provider will send its response to this view after authenticating a user. This is often referred to as the "Entity ID" in the IdP's Service Provider configuration. In this view we verify the received SAML 2.0 response and then log in the user to CommCare HQ. """ # todo these are placeholders for the json dump below error_reason = None request_session_data = None saml_relay = None request_id = request.session.get('AuthNRequestID') processed_response = request.saml2_auth.process_response( request_id=request_id) errors = request.saml2_auth.get_errors() not_auth_warn = not request.saml2_auth.is_authenticated() if not errors: if 'AuthNRequestID' in request.session: del request.session['AuthNRequestID'] store_saml_data_in_session(request) user = auth.authenticate( request=request, username=request.session['samlNameId'], idp_slug=idp_slug, is_handshake_successful=True, ) # we add the messages to the django messages framework here since # that middleware was not available for SsoBackend if hasattr(request, 'sso_new_user_messages'): for success_message in request.sso_new_user_messages['success']: messages.success(request, success_message) for error_message in request.sso_new_user_messages['error']: messages.error(request, error_message) if user: auth.login(request, user) # activate new project if needed async_signup = AsyncSignupRequest.get_by_username(user.username) if async_signup and async_signup.project_name: try: request_new_domain(request, async_signup.project_name, is_new_user=True) except NameUnavailableException: # this should never happen, but in the off chance it does # we don't want to throw a 500 on this view messages.error( request, _("We were unable to create your requested project " "because the name was already taken." "Please contact support.")) AsyncSignupRequest.clear_data_for_username(user.username) return redirect("homepage") # todo for debugging purposes to dump into the response below request_session_data = { "samlUserdata": request.session['samlUserdata'], "samlNameId": request.session['samlNameId'], "samlNameIdFormat": request.session['samlNameIdFormat'], "samlNameIdNameQualifier": request.session['samlNameIdNameQualifier'], "samlNameIdSPNameQualifier": request.session['samlNameIdSPNameQualifier'], "samlSessionIndex": request.session['samlSessionIndex'], } else: error_reason = request.saml2_auth.get_last_error_reason() return HttpResponse( json.dumps({ "errors": errors, "error_reason": error_reason, "not_auth_warn": not_auth_warn, "request_id": request_id, "processed_response": processed_response, "saml_relay": saml_relay, "request_session_data": request_session_data, "login_error": getattr(request, 'sso_login_error', None), }), 'text/json')