def test_get_setting_provided_settings(self): """ Ensure the provided values for settings are used when present """ self.assertEqual(get_setting("LOGIN_URL"), "/new/login/") self.assertEqual(get_setting("UNIAUTH_LOGIN_DISPLAY_STANDARD"), False) self.assertEqual(get_setting("UNIAUTH_MAX_LINKED_EMAILS"), -11) # Should still be the default value, since it wasn't set self.assertEqual(get_setting("PASSWORD_RESET_TIMEOUT_DAYS"), 3)
def test_get_setting_default_settings(self): """ Ensure the default values for Uniauth's settings are returned when not provided in the settings file """ # The test settings file does not set any Uniauth # settings - so everything should be the default value self.assertEqual(get_setting("LOGIN_URL"), "/accounts/login/") self.assertEqual(get_setting("PASSWORD_RESET_TIMEOUT_DAYS"), 3) self.assertEqual(get_setting("UNIAUTH_LOGIN_DISPLAY_STANDARD"), True) for setting, default_value in DEFAULT_SETTING_VALUES.items(): self.assertEqual(get_setting(setting), default_value)
def logout(request): """ Logs the user out of their Uniauth account, and redirects to the next page, defaulting to the URL specified by the UNIAUTH_LOGOUT_REDIRECT_URL setting. If no redirect page is set (URL parameter not given and UNIAUTH_LOGOUT_REDIRECT_URL is None), renders the logout template. Also logs the user out of CAS if they logged in via CAS, and the UNIAUTH_LOGOUT_CAS_COMPLETELY setting is true. """ next_page = request.GET.get('next') auth_method = request.session.get('auth-method') if not next_page and get_setting('UNIAUTH_LOGOUT_REDIRECT_URL'): next_page = get_redirect_url( request, get_setting('UNIAUTH_LOGOUT_REDIRECT_URL')) # Formally log out user auth_logout(request) # Determine whether the user logged in through an institution's CAS institution = None if auth_method and auth_method.startswith("cas-"): try: institution = Institution.objects.get(slug=auth_method[4:]) except Institution.DoesNotExist: pass # If we need to logout an institution's CAS, # redirect to that CAS server's logout URL if institution and get_setting('UNIAUTH_LOGOUT_CAS_COMPLETELY'): redirect_url = urlunparse( (get_protocol(request), request.get_host(), next_page or reverse('uniauth:logout'), '', '', '')) client = CASClient(version=2, service_url=get_service_url(request), server_url=institution.cas_server_url) return HttpResponseRedirect(client.get_logout_url(redirect_url)) # If next page is set, proceed to it elif next_page: return HttpResponseRedirect(next_page) # Otherwise, render the logout view else: return render(request, 'uniauth/logout.html')
def _login_success(request, user, next_url, drop_params=[]): """ Determines where to go upon successful authentication: Redirects to link tmp account page if user is a temporary user, redirects to next_url otherwise. Any query parameters whose key exists in drop_params are not propogated to the destination URL """ query_params = request.GET.copy() jwt_auth = get_setting('UNIAUTH_USE_JWT_AUTH') # Drop all blacklisted query parameters for key in drop_params: if key in query_params: del query_params[key] # Temporary users should redirect to the link-to-profile page if is_tmp_user(user): query_params[REDIRECT_FIELD_NAME] = next_url return HttpResponseRedirect(reverse('uniauth:link-to-profile') + \ '?' + urlencode(query_params)) # All other users should redirect to next_url else: suffix = '' if REDIRECT_FIELD_NAME in query_params: del query_params[REDIRECT_FIELD_NAME] if len(query_params) > 0: suffix = '?' + urlencode(query_params) if jwt_auth: refresh, access = get_jwt_tokens_for_user(user) request.session['jwt-refresh'] = refresh request.session['jwt-access'] = access return HttpResponseRedirect(next_url + suffix)
def _run_test(setting): try: result = get_setting(setting) self.fail("get_setting did not raise an exception when" "asked for non-existent setting: '%s' returned '%s'" % (setting, result)) except KeyError: pass
def clean(self): """ Ensure the user does not link more than the maximum number of linked emails per user. """ cleaned_data = super(AddLinkedEmailForm, self).clean() max_linked_emails = get_setting('UNIAUTH_MAX_LINKED_EMAILS') if max_linked_emails > 0 and \ self.user.profile.linked_emails.count() >= max_linked_emails: err_msg = ( "You can not link more than %d emails to your account." % max_linked_emails) raise forms.ValidationError(err_msg, code="max_emails") return cleaned_data
def clean(self): """ Ensures an email can't be linked and verified for multiple accounts if UNIAUTH_ALLOW_SHARED_EMAILS is False. Also ensures a user does not link more than the maximum number of linked emails per user. """ from uniauth.utils import get_setting # Check for shared emails if necessary if not get_setting('UNIAUTH_ALLOW_SHARED_EMAILS') and \ LinkedEmail.objects.filter(address=self.address, is_verified=True) and self.is_verified: raise ValidationError("This email address has already been " + "linked to another account.") # Ensure a user doesn't link more than the maximum max_linked_emails = get_setting('UNIAUTH_MAX_LINKED_EMAILS') if max_linked_emails > 0 and \ self.profile.linked_emails.count() >= max_linked_emails: raise ValidationError(("You can not link more than %d emails " "to your account.") % max_linked_emails) super(LinkedEmail, self).clean()
class PasswordReset(PasswordResetView): """ Asks user for an email address to send the link to, then sends a password reset link to that address. If no user has that email address linked to their account, no email is sent, unbeknownst to the user. """ email_template_name = 'uniauth/password-reset-email.html' form_class = PasswordResetForm from_email = get_setting('UNIAUTH_FROM_EMAIL') success_url = reverse_lazy('uniauth:password-reset-done') template_name = 'uniauth/password-reset.html' def form_valid(self, form): """ Add global context data before composing reset email """ self.extra_email_context = _get_global_context(self.request) return super(PasswordReset, self).form_valid(form)
def clean_email(self): """ Ensures that you can't sign up with an email address another verified user already has their primary email. If UNIAUTH_ALLOW_SHARED_EMAILS is False, ensures the email hasn't been linked + verified by any profile. """ email = self.cleaned_data.get('email') if get_user_model().objects.filter(email=email).exists(): err_msg = ("A user with that primary email address already " "exists. Please choose another.") raise forms.ValidationError(err_msg, code="email_taken") if not get_setting('UNIAUTH_ALLOW_SHARED_EMAILS') and \ LinkedEmail.objects.filter(address=email, is_verified=True).exists(): err_msg = ("That email address has already been linked " "to another account.") raise forms.ValidationError(err_msg, code="already_linked") return email
def clean_email(self): """ Ensures you can't link an email that has already been linked to the current Uniauth profile. If UNIAUTH_ALLOW_SHARED_EMAILS is False, ensures the email hasn't been linked to any profile. """ email = self.cleaned_data.get('email') if LinkedEmail.objects.filter(profile=self.user.profile, address=email).exists(): err_msg = ("That email address has already been linked " "to this account.") raise forms.ValidationError(err_msg, code="already_linked") if not get_setting('UNIAUTH_ALLOW_SHARED_EMAILS') and \ LinkedEmail.objects.filter(address=email, is_verified=True).exists(): err_msg = ("That email address has already been linked " "to another account.") raise forms.ValidationError(err_msg, code="already_linked") return email
def _send_verification_email(request, to_email, verify_email): """ Sends an email (to to_email) containing a link to verify the target email (verify_email) as a linked email address for the user. Expects to_email as a string, and verify_email as a LinkedEmail model instance. """ subject = "Verify your email address." message = render_to_string( 'uniauth/verification-email.html', { 'protocol': get_protocol(request), 'domain': get_current_site(request), 'pk': encode_pk(verify_email.pk), 'token': token_generator.make_token(verify_email), 'query_params': _get_global_context(request)["query_params"], }) email = EmailMessage(subject, message, to=[to_email], from_email=get_setting('UNIAUTH_FROM_EMAIL')) email.send()
def merge_model_instances(primary_object, alias_objects, field_trace=[]): """ Merge several model instances into one, the `primary_object`. Use this function to merge model objects and migrate all of the related fields from the alias objects into the primary object. Performs recursive merging of related One-to-One fields. """ generic_fields = _get_generic_fields() # get related fields related_fields = list(filter( lambda x: x.is_relation is True, primary_object._meta.get_fields())) many_to_many_fields = list(filter( lambda x: x.many_to_many is True, related_fields)) related_fields = list(filter( lambda x: x.many_to_many is False, related_fields)) # Loop through all alias objects and migrate their references to the # primary object deleted_objects = [] deleted_objects_count = 0 for alias_object in alias_objects: # Migrate all foreign key references from alias object to primary # object. for many_to_many_field in many_to_many_fields: alias_varname = many_to_many_field.name related_objects = getattr(alias_object, alias_varname) for obj in related_objects.all(): try: # Handle regular M2M relationships. getattr(alias_object, alias_varname).remove(obj) getattr(primary_object, alias_varname).add(obj) except AttributeError: # Handle M2M relationships with a 'through' model. # This does not delete the 'through model. through_model = getattr(alias_object, alias_varname).through kwargs = { many_to_many_field.m2m_reverse_field_name(): obj, many_to_many_field.m2m_field_name(): alias_object, } through_model_instances = through_model.objects.filter(**kwargs) for instance in through_model_instances: # Re-attach the through model to the primary_object setattr( instance, many_to_many_field.m2m_field_name(), primary_object) instance.save() for related_field in related_fields: if related_field.one_to_many: alias_varname = related_field.get_accessor_name() related_objects = getattr(alias_object, alias_varname) for obj in related_objects.all(): field_name = related_field.field.name setattr(obj, field_name, primary_object) obj.save() elif related_field.one_to_one or related_field.many_to_one: alias_varname = related_field.name related_object = getattr(alias_object, alias_varname) primary_related_object = getattr(primary_object, alias_varname) if primary_related_object is None: setattr(primary_object, alias_varname, related_object) primary_object.save() elif related_field.one_to_one: # Perform recurisve merging for one-to-one fields if get_setting("UNIAUTH_PERFORM_RECURSIVE_MERGING"): if related_field in field_trace: continue updated_trace = field_trace + [related_field, related_field.remote_field] merge_model_instances(primary_related_object, [related_object], updated_trace) else: related_object.delete() for field in generic_fields: filter_kwargs = {} filter_kwargs[field.fk_field] = alias_object._get_pk_val() filter_kwargs[field.ct_field] = field.get_content_type(alias_object) related_objects = field.model.objects.filter(**filter_kwargs) for generic_related_object in related_objects: setattr(generic_related_object, field.name, primary_object) generic_related_object.save() if alias_object.id: deleted_objects += [alias_object] alias_object.delete() deleted_objects_count += 1 return primary_object, deleted_objects, deleted_objects_count
def verify_token(request, pk_base64, token): """ Verifies a token generated for validating an email address, and notifies the user whether verification was successful. """ next_url = request.GET.get('next') or request.GET.get(REDIRECT_FIELD_NAME) context = {'next_url': next_url, 'is_signup': False} user_model = get_user_model() # Attempt to get the linked email to verify try: email_pk = decode_pk(pk_base64) email = LinkedEmail.objects.get(pk=email_pk) except (TypeError, ValueError, OverflowError, user_model.DoesNotExist, LinkedEmail.DoesNotExist): email = None # In the unlikely scenario that a user is trying to sign up # with an email another verified user has as a primary email # address, reject verification immediately if email is not None and is_tmp_user(email.profile.user) and \ get_user_model().objects.filter(email=email.address).exists(): email = None # If the token successfully verifies, update the linked email if email is not None and token_generator.check_token(email, token): email.is_verified = True email.save() # If the user this email is linked to is a temporary # one, change it to a fully registered user user = email.profile.user if is_tmp_user(user) or is_unlinked_account(user): context['is_signup'] = True old_username = user.username # Change the email + username to the verified email user.email = email.address user.username = choose_username(user.email) user.save() # If the user was created via CAS, add the institution # account described by the temporary username if old_username.startswith("cas"): username_split = get_account_username_split(old_username) _add_institution_account(user.profile, username_split[1], username_split[2]) # If UNIAUTH_ALLOW_SHARED_EMAILS is False, and there were # pending LinkedEmails for this address on other accounts, # delete them if not get_setting('UNIAUTH_ALLOW_SHARED_EMAILS'): LinkedEmail.objects.filter(address=email.address, is_verified=False).delete() return render(request, 'uniauth/verification-success.html', context) # If anything went wrong, just render the failed verification template else: return render(request, 'uniauth/verification-failure.html', context)
def login(request): """ Authenticates the user, then redirects them to the next page, defaulting to the URL specified by the UNIAUTH_LOGIN_REDIRECT_URL setting. Offers users the choice between logging in with their Uniauth credentials, or through the CAS interface for any supported institution. """ next_url = request.GET.get('next') context = _get_global_context(request) if not next_url: next_url = get_redirect_url(request) # If the user is already authenticated, proceed to next page if request.user.is_authenticated: return _login_success(request, request.user, next_url) display_standard = get_setting('UNIAUTH_LOGIN_DISPLAY_STANDARD') display_cas = get_setting('UNIAUTH_LOGIN_DISPLAY_CAS') num_institutions = len(context['institutions']) # Ensure the login settings are configured correctly if not display_standard and not display_cas: err_msg = "At least one of '%s' and '%s' must be True." % \ ('UNIAUTH_LOGIN_DISPLAY_STANDARD', 'UNIAUTH_LOGIN_DISPLAY_CAS') raise ImproperlyConfigured(err_msg) if display_cas and num_institutions == 0: err_msg = ("'%s' is True, but there are no Institutions in the " "database to log into!") % 'UNIAUTH_LOGIN_DISPLAY_CAS' raise ImproperlyConfigured(err_msg) context['display_standard'] = display_standard context['display_cas'] = display_cas context['num_institutions'] = num_institutions # If we aren't displaying the standard login form, # we're just displaying the CAS login options if not display_standard: institutions = context['institutions'] query_params = context['query_params'] # If there's only one possible institution to log # into, redirect to its CAS login page immediately if num_institutions == 1: return HttpResponseRedirect(institutions[0][2] + query_params) # Otherwise, render the page (without the Login form) else: return render(request, 'uniauth/login.html', context) # If we are displaying the login form, and it's # a POST request, attempt to validate the form elif request.method == "POST": form = LoginForm(request, request.POST) # Authentication successful: setup session + proceed if form.is_valid(): user = form.get_user() auth_login(request, user) request.session['auth-method'] = "uniauth" return _login_success(request, user, next_url) # Authentication failed: render form errors else: context['form'] = form return render(request, 'uniauth/login.html', context) # Otherwise, render a blank Login form else: form = LoginForm(request) context['form'] = form return render(request, 'uniauth/login.html', context)
from django.conf.urls import url from django.core.exceptions import ImproperlyConfigured from uniauth import views from uniauth.utils import get_setting # The standard login form requires URLs that don't exist when # cas_only is used. Ensure that this setting is False. if get_setting('UNIAUTH_LOGIN_DISPLAY_STANDARD'): err_msg = ("'uniauth.urls.cas_only' can not be used when %s is True. " "Please include 'uniauth.urls' instead.") % \ 'UNIAUTH_LOGIN_DISPLAY_STANDARD' raise ImproperlyConfigured(err_msg) app_name = 'uniauth' urlpatterns = [ url(r'^login/$', views.login, name='login'), url(r'^cas-login/(?P<institution>[a-z0-9\-]+)/$', views.cas_login, name='cas-login'), url(r'^logout/$', views.logout, name='logout'), ]