From 9d103aa50f9cbf35ae159c358fecff7a977cb915 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 8 May 2019 10:56:49 +0200 Subject: [PATCH] reorganize views and forms (#32934) --- src/authentic2/admin.py | 2 +- src/authentic2/app_settings.py | 10 - src/authentic2/attribute_aggregator/models.py | 0 src/authentic2/auth2_auth/models.py | 0 src/authentic2/authenticators.py | 5 +- src/authentic2/forms/__init__.py | 273 ------- src/authentic2/forms/authentication.py | 119 +++ src/authentic2/forms/passwords.py | 128 ++++ src/authentic2/forms/profile.py | 168 +++++ .../forms.py => forms/registration.py} | 119 +-- src/authentic2/forms/utils.py | 33 + src/authentic2/idp/models.py | 0 src/authentic2/idp/utils.py | 0 src/authentic2/idp/views.py | 0 src/authentic2/manager/forms.py | 7 +- src/authentic2/manager/models.py | 0 src/authentic2/manager/views.py | 6 +- src/authentic2/middleware.py | 11 +- src/authentic2/passwords.py | 2 +- src/authentic2/profile_forms.py | 38 - src/authentic2/profile_urls.py | 97 --- src/authentic2/profile_views.py | 127 ---- .../registration_backend/__init__.py | 0 src/authentic2/registration_backend/urls.py | 23 - src/authentic2/registration_backend/views.py | 416 ----------- src/authentic2/urls.py | 121 ++- src/authentic2/views.py | 696 ++++++++++++++++-- 27 files changed, 1231 insertions(+), 1170 deletions(-) delete mode 100644 src/authentic2/attribute_aggregator/models.py delete mode 100644 src/authentic2/auth2_auth/models.py create mode 100644 src/authentic2/forms/authentication.py create mode 100644 src/authentic2/forms/passwords.py create mode 100644 src/authentic2/forms/profile.py rename src/authentic2/{registration_backend/forms.py => forms/registration.py} (53%) create mode 100644 src/authentic2/forms/utils.py delete mode 100644 src/authentic2/idp/models.py delete mode 100644 src/authentic2/idp/utils.py delete mode 100644 src/authentic2/idp/views.py delete mode 100644 src/authentic2/manager/models.py delete mode 100644 src/authentic2/profile_forms.py delete mode 100644 src/authentic2/profile_urls.py delete mode 100644 src/authentic2/profile_views.py delete mode 100644 src/authentic2/registration_backend/__init__.py delete mode 100644 src/authentic2/registration_backend/urls.py delete mode 100644 src/authentic2/registration_backend/views.py diff --git a/src/authentic2/admin.py b/src/authentic2/admin.py index 18e47dd6..14535a5b 100644 --- a/src/authentic2/admin.py +++ b/src/authentic2/admin.py @@ -18,7 +18,7 @@ from django.contrib.auth.forms import ReadOnlyPasswordHashField from .nonce.models import Nonce from . import (models, compat, app_settings, decorators, attribute_kinds, utils) -from .forms import modelform_factory, BaseUserForm +from .forms.profile import BaseUserForm, modelform_factory from .custom_user.models import User def cleanup_action(modeladmin, request, queryset): diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index d21323a0..7e28180a 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -88,16 +88,6 @@ default_settings = dict( CAFILE = Setting(names=('AUTHENTIC2_CAFILE', 'CAFILE'), default=None, definition='File containing certificate chains as PEM certificates'), - A2_REGISTRATION_URLCONF = Setting(default='authentic2.registration_backend.urls', - definition='Root urlconf for the /accounts endpoints'), - A2_REGISTRATION_FORM_CLASS = Setting(default='authentic2.registration_backend.forms.RegistrationForm', - definition='Default registration form'), - A2_REGISTRATION_COMPLETION_FORM_CLASS = Setting(default='authentic2.registration_backend.forms.RegistrationCompletionForm', - definition='Default registration completion form'), - A2_REGISTRATION_SET_PASSWORD_FORM_CLASS = Setting(default='authentic2.registration_backend.forms.SetPasswordForm', - definition='Default set password form'), - A2_REGISTRATION_CHANGE_PASSWORD_FORM_CLASS = Setting(default='authentic2.registration_backend.forms.PasswordChangeForm', - definition='Default change password form'), A2_REGISTRATION_CAN_DELETE_ACCOUNT = Setting(default=True, definition='Can user self delete their account and all their data'), A2_REGISTRATION_CAN_CHANGE_PASSWORD = Setting(default=True, definition='Allow user to change its own password'), diff --git a/src/authentic2/attribute_aggregator/models.py b/src/authentic2/attribute_aggregator/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/auth2_auth/models.py b/src/authentic2/auth2_auth/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py index 0944d7b6..f813c939 100644 --- a/src/authentic2/authenticators.py +++ b/src/authentic2/authenticators.py @@ -1,7 +1,8 @@ from django.shortcuts import render from django.utils.translation import ugettext as _, ugettext_lazy -from . import views, app_settings, utils, constants, forms +from . import views, app_settings, utils, constants +from .forms import authentication as authentication_forms class LoginPasswordAuthenticator(object): @@ -20,7 +21,7 @@ class LoginPasswordAuthenticator(object): context = kwargs.get('context', {}) is_post = request.method == 'POST' and self.submit_name in request.POST data = request.POST if is_post else None - form = forms.AuthenticationForm(request=request, data=data) + form = authentication_forms.AuthenticationForm(request=request, data=data) if app_settings.A2_ACCEPT_EMAIL_AUTHENTICATION: form.fields['username'].label = _('Username or email') if app_settings.A2_USERNAME_LABEL: diff --git a/src/authentic2/forms/__init__.py b/src/authentic2/forms/__init__.py index 8c316f39..e69de29b 100644 --- a/src/authentic2/forms/__init__.py +++ b/src/authentic2/forms/__init__.py @@ -1,273 +0,0 @@ -# -# Copyright (C) 2010-2019 Entr'ouvert -# -# This program is free software: you can redistribute it and/or modify it -# under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import math - -from django import forms -from django.forms.models import modelform_factory as django_modelform_factory -from django.utils.translation import ugettext_lazy as _ -from django.contrib.auth import REDIRECT_FIELD_NAME, forms as auth_forms -from django.utils import html - -from django.contrib.auth import authenticate - -from django_rbac.utils import get_ou_model - -from authentic2.utils import lazy_label -from authentic2.compat import get_user_model -from authentic2.forms.fields import PasswordField - -from .. import app_settings -from ..exponential_retry_timeout import ExponentialRetryTimeout - -OU = get_ou_model() - - -class EmailChangeFormNoPassword(forms.Form): - email = forms.EmailField(label=_('New email')) - - def __init__(self, user, *args, **kwargs): - self.user = user - super(EmailChangeFormNoPassword, self).__init__(*args, **kwargs) - - -class EmailChangeForm(EmailChangeFormNoPassword): - password = forms.CharField(label=_("Password"), - widget=forms.PasswordInput) - - def clean_email(self): - email = self.cleaned_data['email'] - if email == self.user.email: - raise forms.ValidationError(_('This is already your email address.')) - return email - - def clean_password(self): - password = self.cleaned_data["password"] - if not self.user.check_password(password): - raise forms.ValidationError( - _('Incorrect password.'), - code='password_incorrect', - ) - return password - - -class NextUrlFormMixin(forms.Form): - next_url = forms.CharField(widget=forms.HiddenInput(), required=False) - - def __init__(self, *args, **kwargs): - from authentic2.middleware import StoreRequestMiddleware - - next_url = kwargs.pop('next_url', None) - request = StoreRequestMiddleware.get_request() - if not next_url and request: - next_url = request.GET.get(REDIRECT_FIELD_NAME) - super(NextUrlFormMixin, self).__init__(*args, **kwargs) - if next_url: - self.fields['next_url'].initial = next_url - - -class BaseUserForm(forms.ModelForm): - error_messages = { - 'duplicate_username': _("A user with that username already exists."), - } - - def __init__(self, *args, **kwargs): - from authentic2 import models - - self.attributes = models.Attribute.objects.all() - initial = kwargs.setdefault('initial', {}) - if kwargs.get('instance'): - instance = kwargs['instance'] - for av in models.AttributeValue.objects.with_owner(instance): - if av.attribute.name in self.declared_fields: - if av.verified: - self.declared_fields[av.attribute.name].widget.attrs['readonly'] = 'readonly' - initial[av.attribute.name] = av.to_python() - super(BaseUserForm, self).__init__(*args, **kwargs) - - def clean(self): - from authentic2 import models - - # make sure verified fields are not modified - for av in models.AttributeValue.objects.with_owner( - self.instance).filter(verified=True): - self.cleaned_data[av.attribute.name] = av.to_python() - super(BaseUserForm, self).clean() - - def save_attributes(self): - # only save non verified attributes here - verified_attributes = set( - self.instance.attribute_values.filter(verified=True).values_list('attribute__name', flat=True) - ) - for attribute in self.attributes: - name = attribute.name - if name in self.fields and name not in verified_attributes: - value = self.cleaned_data[name] - setattr(self.instance.attributes, name, value) - - def save(self, commit=True): - result = super(BaseUserForm, self).save(commit=commit) - if commit: - self.save_attributes() - else: - old = self.save_m2m - - def save_m2m(*args, **kwargs): - old(*args, **kwargs) - self.save_attributes() - self.save_m2m = save_m2m - return result - - -class EditProfileForm(NextUrlFormMixin, BaseUserForm): - pass - - -def modelform_factory(model, **kwargs): - '''Build a modelform for the given model, - - For the user model also add attribute based fields. - ''' - from authentic2 import models - - form = kwargs.pop('form', None) - fields = kwargs.get('fields') or [] - required = list(kwargs.pop('required', []) or []) - d = {} - # KV attributes are only supported for the user model currently - modelform = None - if issubclass(model, get_user_model()): - if not form: - form = BaseUserForm - attributes = models.Attribute.objects.all() - for attribute in attributes: - if attribute.name not in fields: - continue - d[attribute.name] = attribute.get_form_field() - for field in app_settings.A2_REQUIRED_FIELDS: - if field not in required: - required.append(field) - if not form or not hasattr(form, 'Meta'): - meta_d = {'model': model, 'fields': '__all__'} - meta = type('Meta', (), meta_d) - d['Meta'] = meta - if not form: # fallback - form = forms.ModelForm - modelform = None - if required: - def __init__(self, *args, **kwargs): - super(modelform, self).__init__(*args, **kwargs) - for field in required: - if field in self.fields: - self.fields[field].required = True - d['__init__'] = __init__ - modelform = type(model.__name__ + 'ModelForm', (form,), d) - kwargs['form'] = modelform - modelform.required_css_class = 'form-field-required' - return django_modelform_factory(model, **kwargs) - - -class AuthenticationForm(auth_forms.AuthenticationForm): - password = PasswordField(label=_('Password')) - remember_me = forms.BooleanField( - initial=False, - required=False, - label=_('Remember me'), - help_text=_('Do not ask for authentication next time')) - ou = forms.ModelChoiceField( - label=lazy_label(_('Organizational unit'), lambda: app_settings.A2_LOGIN_FORM_OU_SELECTOR_LABEL), - required=True, - queryset=OU.objects.all()) - - def __init__(self, *args, **kwargs): - super(AuthenticationForm, self).__init__(*args, **kwargs) - self.exponential_backoff = ExponentialRetryTimeout( - key_prefix='login-exp-backoff-', - duration=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION, - factor=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR) - - if not app_settings.A2_USER_REMEMBER_ME: - del self.fields['remember_me'] - - if not app_settings.A2_LOGIN_FORM_OU_SELECTOR: - del self.fields['ou'] - - if self.request: - self.remote_addr = self.request.META['REMOTE_ADDR'] - else: - self.remote_addr = '0.0.0.0' - - def exp_backoff_keys(self): - return self.cleaned_data['username'], self.remote_addr - - def clean(self): - username = self.cleaned_data.get('username') - password = self.cleaned_data.get('password') - - keys = None - if username and password: - keys = self.exp_backoff_keys() - seconds_to_wait = self.exponential_backoff.seconds_to_wait(*keys) - if seconds_to_wait > app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION: - seconds_to_wait -= app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION - msg = _('You made too many login errors recently, you must ' - 'wait %s seconds ' - 'to try again.') - msg = msg % int(math.ceil(seconds_to_wait)) - msg = html.mark_safe(msg) - raise forms.ValidationError(msg) - - try: - self.clean_authenticate() - except Exception: - if keys: - self.exponential_backoff.failure(*keys) - raise - else: - if keys: - self.exponential_backoff.success(*keys) - return self.cleaned_data - - def clean_authenticate(self): - # copied from django.contrib.auth.forms.AuthenticationForm to add support for ou selector - username = self.cleaned_data.get('username') - password = self.cleaned_data.get('password') - ou = self.cleaned_data.get('ou') - - if username is not None and password: - self.user_cache = authenticate(username=username, password=password, ou=ou, request=self.request) - if self.user_cache is None: - raise forms.ValidationError( - self.error_messages['invalid_login'], - code='invalid_login', - params={'username': self.username_field.verbose_name}, - ) - else: - self.confirm_login_allowed(self.user_cache) - - return self.cleaned_data - - @property - def media(self): - media = super(AuthenticationForm, self).media - media.add_js(['authentic2/js/js_seconds_until.js']) - if app_settings.A2_LOGIN_FORM_OU_SELECTOR: - media.add_js(['authentic2/js/ou_selector.js']) - return media - - -class SiteImportForm(forms.Form): - site_json = forms.FileField(label=_('Site Export File')) diff --git a/src/authentic2/forms/authentication.py b/src/authentic2/forms/authentication.py new file mode 100644 index 00000000..2f9e0bc2 --- /dev/null +++ b/src/authentic2/forms/authentication.py @@ -0,0 +1,119 @@ +# +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import math + +from django import forms +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth import forms as auth_forms +from django.utils import html + +from django.contrib.auth import authenticate + +from authentic2.forms.fields import PasswordField + +from ..a2_rbac.models import OrganizationalUnit as OU +from .. import app_settings, utils +from ..exponential_retry_timeout import ExponentialRetryTimeout + + +class AuthenticationForm(auth_forms.AuthenticationForm): + password = PasswordField(label=_('Password')) + remember_me = forms.BooleanField( + initial=False, + required=False, + label=_('Remember me'), + help_text=_('Do not ask for authentication next time')) + ou = forms.ModelChoiceField( + label=utils.lazy_label(_('Organizational unit'), lambda: app_settings.A2_LOGIN_FORM_OU_SELECTOR_LABEL), + required=True, + queryset=OU.objects.all()) + + def __init__(self, *args, **kwargs): + super(AuthenticationForm, self).__init__(*args, **kwargs) + self.exponential_backoff = ExponentialRetryTimeout( + key_prefix='login-exp-backoff-', + duration=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION, + factor=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR) + + if not app_settings.A2_USER_REMEMBER_ME: + del self.fields['remember_me'] + + if not app_settings.A2_LOGIN_FORM_OU_SELECTOR: + del self.fields['ou'] + + if self.request: + self.remote_addr = self.request.META['REMOTE_ADDR'] + else: + self.remote_addr = '0.0.0.0' + + def exp_backoff_keys(self): + return self.cleaned_data['username'], self.remote_addr + + def clean(self): + username = self.cleaned_data.get('username') + password = self.cleaned_data.get('password') + + keys = None + if username and password: + keys = self.exp_backoff_keys() + seconds_to_wait = self.exponential_backoff.seconds_to_wait(*keys) + if seconds_to_wait > app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION: + seconds_to_wait -= app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION + msg = _('You made too many login errors recently, you must ' + 'wait %s seconds ' + 'to try again.') + msg = msg % int(math.ceil(seconds_to_wait)) + msg = html.mark_safe(msg) + raise forms.ValidationError(msg) + + try: + self.clean_authenticate() + except Exception: + if keys: + self.exponential_backoff.failure(*keys) + raise + else: + if keys: + self.exponential_backoff.success(*keys) + return self.cleaned_data + + def clean_authenticate(self): + # copied from django.contrib.auth.forms.AuthenticationForm to add support for ou selector + username = self.cleaned_data.get('username') + password = self.cleaned_data.get('password') + ou = self.cleaned_data.get('ou') + + if username is not None and password: + self.user_cache = authenticate(username=username, password=password, ou=ou, request=self.request) + if self.user_cache is None: + raise forms.ValidationError( + self.error_messages['invalid_login'], + code='invalid_login', + params={'username': self.username_field.verbose_name}, + ) + else: + self.confirm_login_allowed(self.user_cache) + + return self.cleaned_data + + @property + def media(self): + media = super(AuthenticationForm, self).media + media.add_js(['authentic2/js/js_seconds_until.js']) + if app_settings.A2_LOGIN_FORM_OU_SELECTOR: + media.add_js(['authentic2/js/ou_selector.js']) + return media diff --git a/src/authentic2/forms/passwords.py b/src/authentic2/forms/passwords.py new file mode 100644 index 00000000..12533b42 --- /dev/null +++ b/src/authentic2/forms/passwords.py @@ -0,0 +1,128 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import logging +from collections import OrderedDict + +from django.contrib.auth import forms as auth_forms +from django.core.exceptions import ValidationError +from django.forms import Form +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from .. import models, hooks, app_settings, utils +from ..backends import get_user_queryset +from .fields import PasswordField, NewPasswordField, CheckPasswordField +from .utils import NextUrlFormMixin + + +logger = logging.getLogger(__name__) + + +class PasswordResetForm(forms.Form): + next_url = forms.CharField(widget=forms.HiddenInput, required=False) + + email = forms.EmailField( + label=_("Email"), max_length=254) + + def save(self): + """ + Generates a one-use only link for resetting password and sends to the + user. + """ + email = self.cleaned_data["email"].strip() + users = get_user_queryset() + active_users = users.filter(email__iexact=email, is_active=True) + for user in active_users: + # we don't set the password to a random string, as some users should not have + # a password + set_random_password = (user.has_usable_password() + and app_settings.A2_SET_RANDOM_PASSWORD_ON_RESET) + utils.send_password_reset_mail( + user, + set_random_password=set_random_password, + next_url=self.cleaned_data.get('next_url')) + if not active_users: + logger.info(u'password reset requests for "%s", no user found') + hooks.call_hooks('event', name='password-reset', email=email, users=active_users) + + +class PasswordResetMixin(Form): + '''Remove all password reset object for the current user when password is + successfully changed.''' + + def save(self, commit=True): + ret = super(PasswordResetMixin, self).save(commit=commit) + if commit: + models.PasswordReset.objects.filter(user=self.user).delete() + else: + old_save = self.user.save + + def save(*args, **kwargs): + ret = old_save(*args, **kwargs) + models.PasswordReset.objects.filter(user=self.user).delete() + return ret + self.user.save = save + return ret + + +class NotifyOfPasswordChange(object): + def save(self, commit=True): + user = super(NotifyOfPasswordChange, self).save(commit=commit) + if user.email: + ctx = { + 'user': user, + 'password': self.cleaned_data['new_password1'], + } + utils.send_templated_mail(user, "authentic2/password_change", ctx) + return user + + +class SetPasswordForm(NotifyOfPasswordChange, PasswordResetMixin, auth_forms.SetPasswordForm): + new_password1 = NewPasswordField(label=_("New password")) + new_password2 = CheckPasswordField(label=_("New password confirmation")) + + def clean_new_password1(self): + new_password1 = self.cleaned_data.get('new_password1') + if new_password1 and self.user.check_password(new_password1): + raise ValidationError(_('New password must differ from old password')) + return new_password1 + + +class PasswordChangeForm(NotifyOfPasswordChange, NextUrlFormMixin, PasswordResetMixin, + auth_forms.PasswordChangeForm): + old_password = PasswordField(label=_('Old password')) + new_password1 = NewPasswordField(label=_('New password')) + new_password2 = CheckPasswordField(label=_("New password confirmation")) + + def clean_new_password1(self): + new_password1 = self.cleaned_data.get('new_password1') + old_password = self.cleaned_data.get('old_password') + if new_password1 and new_password1 == old_password: + raise ValidationError(_('New password must differ from old password')) + return new_password1 + +# make old_password the first field +new_base_fields = OrderedDict() + +for k in ['old_password', 'new_password1', 'new_password2']: + new_base_fields[k] = PasswordChangeForm.base_fields[k] + +for k in PasswordChangeForm.base_fields: + if k not in ['old_password', 'new_password1', 'new_password2']: + new_base_fields[k] = PasswordChangeForm.base_fields[k] + +PasswordChangeForm.base_fields = new_base_fields diff --git a/src/authentic2/forms/profile.py b/src/authentic2/forms/profile.py new file mode 100644 index 00000000..b5577f3e --- /dev/null +++ b/src/authentic2/forms/profile.py @@ -0,0 +1,168 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from django.forms.models import modelform_factory as dj_modelform_factory +from django import forms +from django.utils.translation import ugettext_lazy as _, ugettext + +from ..custom_user.models import User +from .. import app_settings, models +from .utils import NextUrlFormMixin + + +class DeleteAccountForm(forms.Form): + password = forms.CharField(widget=forms.PasswordInput, label=_("Password")) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user') + super(DeleteAccountForm, self).__init__(*args, **kwargs) + + def clean_password(self): + password = self.cleaned_data.get('password') + if password and not self.user.check_password(password): + raise forms.ValidationError(ugettext('Password is invalid')) + return password + + +class EmailChangeFormNoPassword(forms.Form): + email = forms.EmailField(label=_('New email')) + + def __init__(self, user, *args, **kwargs): + self.user = user + super(EmailChangeFormNoPassword, self).__init__(*args, **kwargs) + + +class EmailChangeForm(EmailChangeFormNoPassword): + password = forms.CharField(label=_("Password"), + widget=forms.PasswordInput) + + def clean_email(self): + email = self.cleaned_data['email'] + if email == self.user.email: + raise forms.ValidationError(_('This is already your email address.')) + return email + + def clean_password(self): + password = self.cleaned_data["password"] + if not self.user.check_password(password): + raise forms.ValidationError( + _('Incorrect password.'), + code='password_incorrect', + ) + return password + + +class BaseUserForm(forms.ModelForm): + error_messages = { + 'duplicate_username': _("A user with that username already exists."), + } + + def __init__(self, *args, **kwargs): + from authentic2 import models + + self.attributes = models.Attribute.objects.all() + initial = kwargs.setdefault('initial', {}) + if kwargs.get('instance'): + instance = kwargs['instance'] + for av in models.AttributeValue.objects.with_owner(instance): + if av.attribute.name in self.declared_fields: + if av.verified: + self.declared_fields[av.attribute.name].widget.attrs['readonly'] = 'readonly' + initial[av.attribute.name] = av.to_python() + super(BaseUserForm, self).__init__(*args, **kwargs) + + def clean(self): + from authentic2 import models + + # make sure verified fields are not modified + for av in models.AttributeValue.objects.with_owner( + self.instance).filter(verified=True): + self.cleaned_data[av.attribute.name] = av.to_python() + super(BaseUserForm, self).clean() + + def save_attributes(self): + # only save non verified attributes here + verified_attributes = set( + self.instance.attribute_values.filter(verified=True).values_list('attribute__name', flat=True) + ) + for attribute in self.attributes: + name = attribute.name + if name in self.fields and name not in verified_attributes: + value = self.cleaned_data[name] + setattr(self.instance.attributes, name, value) + + def save(self, commit=True): + result = super(BaseUserForm, self).save(commit=commit) + if commit: + self.save_attributes() + else: + old = self.save_m2m + + def save_m2m(*args, **kwargs): + old(*args, **kwargs) + self.save_attributes() + self.save_m2m = save_m2m + return result + + +class EditProfileForm(NextUrlFormMixin, BaseUserForm): + pass + + +def modelform_factory(model, **kwargs): + '''Build a modelform for the given model, + + For the user model also add attribute based fields. + ''' + + form = kwargs.pop('form', None) + fields = kwargs.get('fields') or [] + required = list(kwargs.pop('required', []) or []) + d = {} + # KV attributes are only supported for the user model currently + modelform = None + if issubclass(model, User): + if not form: + form = profile_forms.BaseUserForm + attributes = models.Attribute.objects.all() + for attribute in attributes: + if attribute.name not in fields: + continue + d[attribute.name] = attribute.get_form_field() + for field in app_settings.A2_REQUIRED_FIELDS: + if field not in required: + required.append(field) + if not form or not hasattr(form, 'Meta'): + meta_d = {'model': model, 'fields': '__all__'} + meta = type('Meta', (), meta_d) + d['Meta'] = meta + if not form: # fallback + form = forms.ModelForm + modelform = None + if required: + def __init__(self, *args, **kwargs): + super(modelform, self).__init__(*args, **kwargs) + for field in required: + if field in self.fields: + self.fields[field].required = True + d['__init__'] = __init__ + modelform = type(model.__name__ + 'ModelForm', (form,), d) + kwargs['form'] = modelform + modelform.required_css_class = 'form-field-required' + return dj_modelform_factory(model, **kwargs) + + diff --git a/src/authentic2/registration_backend/forms.py b/src/authentic2/forms/registration.py similarity index 53% rename from src/authentic2/registration_backend/forms.py rename to src/authentic2/forms/registration.py index 5b99a13c..ca2d5d4a 100644 --- a/src/authentic2/registration_backend/forms.py +++ b/src/authentic2/forms/registration.py @@ -1,27 +1,33 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + import re -import copy -from collections import OrderedDict -from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _, ugettext -from django.forms import ModelForm, Form, CharField, PasswordInput, EmailField -from django.db.models.fields import FieldDoesNotExist -from django.forms.utils import ErrorList +from django.forms import Form, EmailField from django.contrib.auth.models import BaseUserManager, Group -from django.contrib.auth import forms as auth_forms, get_user_model, REDIRECT_FIELD_NAME -from django.core.mail import send_mail -from django.core import signing -from django.template import RequestContext -from django.template.loader import render_to_string -from django.core.urlresolvers import reverse -from django.core.validators import RegexValidator - -from authentic2.forms.fields import PasswordField, NewPasswordField, CheckPasswordField -from .. import app_settings, compat, forms, utils, validators, models, middleware, hooks + +from authentic2.forms.fields import NewPasswordField, CheckPasswordField from authentic2.a2_rbac.models import OrganizationalUnit +from .. import app_settings, compat, forms, models +from . import profile as profile_forms + User = compat.get_user_model() @@ -53,7 +59,7 @@ class RegistrationForm(Form): return email -class RegistrationCompletionFormNoPassword(forms.BaseUserForm): +class RegistrationCompletionFormNoPassword(profile_forms.BaseUserForm): error_css_class = 'form-field-error' required_css_class = 'form-field-required' @@ -67,7 +73,6 @@ class RegistrationCompletionFormNoPassword(forms.BaseUserForm): ou = OrganizationalUnit.objects.get(pk=self.data['ou']) username_is_unique |= ou.username_is_unique if username_is_unique: - User = get_user_model() exist = False try: User.objects.get(username=username) @@ -86,7 +91,6 @@ class RegistrationCompletionFormNoPassword(forms.BaseUserForm): if self.cleaned_data.get('email'): email = self.cleaned_data['email'] if app_settings.A2_REGISTRATION_EMAIL_IS_UNIQUE: - User = get_user_model() exist = False try: User.objects.get(email__iexact=email) @@ -130,80 +134,3 @@ class RegistrationCompletionForm(RegistrationCompletionFormNoPassword): raise ValidationError(_("The two password fields didn't match.")) self.instance.set_password(self.cleaned_data['password1']) return self.cleaned_data - - -class PasswordResetMixin(Form): - '''Remove all password reset object for the current user when password is - successfully changed.''' - - def save(self, commit=True): - ret = super(PasswordResetMixin, self).save(commit=commit) - if commit: - models.PasswordReset.objects.filter(user=self.user).delete() - else: - old_save = self.user.save - def save(*args, **kwargs): - ret = old_save(*args, **kwargs) - models.PasswordReset.objects.filter(user=self.user).delete() - return ret - self.user.save = save - return ret - - -class NotifyOfPasswordChange(object): - def save(self, commit=True): - user = super(NotifyOfPasswordChange, self).save(commit=commit) - if user.email: - ctx = { - 'user': user, - 'password': self.cleaned_data['new_password1'], - } - utils.send_templated_mail(user, "authentic2/password_change", ctx) - return user - - -class SetPasswordForm(NotifyOfPasswordChange, PasswordResetMixin, auth_forms.SetPasswordForm): - new_password1 = NewPasswordField(label=_("New password")) - new_password2 = CheckPasswordField(label=_("New password confirmation")) - - def clean_new_password1(self): - new_password1 = self.cleaned_data.get('new_password1') - if new_password1 and self.user.check_password(new_password1): - raise ValidationError(_('New password must differ from old password')) - return new_password1 - - -class PasswordChangeForm(NotifyOfPasswordChange, forms.NextUrlFormMixin, PasswordResetMixin, - auth_forms.PasswordChangeForm): - old_password = PasswordField(label=_('Old password')) - new_password1 = NewPasswordField(label=_('New password')) - new_password2 = CheckPasswordField(label=_("New password confirmation")) - - def clean_new_password1(self): - new_password1 = self.cleaned_data.get('new_password1') - old_password = self.cleaned_data.get('old_password') - if new_password1 and new_password1 == old_password: - raise ValidationError(_('New password must differ from old password')) - return new_password1 - -# make old_password the first field -PasswordChangeForm.base_fields = OrderedDict( - [(k, PasswordChangeForm.base_fields[k]) - for k in ['old_password', 'new_password1', 'new_password2']] + - [(k, PasswordChangeForm.base_fields[k]) - for k in PasswordChangeForm.base_fields if k not in ['old_password', 'new_password1', - 'new_password2']] -) - -class DeleteAccountForm(Form): - password = CharField(widget=PasswordInput, label=_("Password")) - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop('user') - super(DeleteAccountForm, self).__init__(*args, **kwargs) - - def clean_password(self): - password = self.cleaned_data.get('password') - if password and not self.user.check_password(password): - raise ValidationError(ugettext('Password is invalid')) - return password diff --git a/src/authentic2/forms/utils.py b/src/authentic2/forms/utils.py new file mode 100644 index 00000000..80c91b9f --- /dev/null +++ b/src/authentic2/forms/utils.py @@ -0,0 +1,33 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django import forms +from django.contrib.auth import REDIRECT_FIELD_NAME + +from ..middleware import StoreRequestMiddleware + + +class NextUrlFormMixin(forms.Form): + next_url = forms.CharField(widget=forms.HiddenInput(), required=False) + + def __init__(self, *args, **kwargs): + next_url = kwargs.pop('next_url', None) + request = StoreRequestMiddleware.get_request() + if not next_url and request: + next_url = request.GET.get(REDIRECT_FIELD_NAME) + super(NextUrlFormMixin, self).__init__(*args, **kwargs) + if next_url: + self.fields['next_url'].initial = next_url diff --git a/src/authentic2/idp/models.py b/src/authentic2/idp/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/idp/utils.py b/src/authentic2/idp/utils.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/idp/views.py b/src/authentic2/idp/views.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/manager/forms.py b/src/authentic2/manager/forms.py index 1708290f..eaa4aeb9 100644 --- a/src/authentic2/manager/forms.py +++ b/src/authentic2/manager/forms.py @@ -19,7 +19,7 @@ from django_rbac.models import Operation from django_rbac.utils import get_ou_model, get_role_model, get_permission_model from django_rbac.backends import DjangoRBACBackend -from authentic2.forms import BaseUserForm +from authentic2.forms.profile import BaseUserForm from authentic2.models import PasswordReset from authentic2.utils import import_module_or_class from authentic2.a2_rbac.utils import get_default_ou @@ -694,3 +694,8 @@ class UserChangeEmailForm(CssClass, FormWithRequest, forms.ModelForm): class Meta: fields = () + + +class SiteImportForm(forms.Form): + site_json = forms.FileField( + label=_('Site Export File')) diff --git a/src/authentic2/manager/models.py b/src/authentic2/manager/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/manager/views.py b/src/authentic2/manager/views.py index c23d2bda..8f5bd93a 100644 --- a/src/authentic2/manager/views.py +++ b/src/authentic2/manager/views.py @@ -25,12 +25,12 @@ from gadjo.templatetags.gadjo import xstatic from django_rbac.utils import get_ou_model from authentic2.data_transfer import export_site, import_site, DataImportError, ImportContext -from authentic2.forms import modelform_factory, SiteImportForm +from authentic2.forms.profile import modelform_factory from authentic2.utils import redirect, batch_queryset from authentic2.decorators import json as json_view from authentic2 import hooks -from . import app_settings, utils +from . import app_settings, utils, forms # https://github.com/MongoEngine/django-mongoengine/blob/master/django_mongoengine/views/edit.py @@ -680,7 +680,7 @@ site_export = SiteExport.as_view() class SiteImportView(MediaMixin, FormView): - form_class = SiteImportForm + form_class = forms.SiteImportForm template_name = 'authentic2/manager/site_import.html' success_url = reverse_lazy('a2-manager-homepage') diff --git a/src/authentic2/middleware.py b/src/authentic2/middleware.py index ef526498..6730f9cf 100644 --- a/src/authentic2/middleware.py +++ b/src/authentic2/middleware.py @@ -18,6 +18,7 @@ from django.shortcuts import render from . import app_settings, utils, plugins + class ThreadCollector(object): def __init__(self): if threading is None: @@ -48,6 +49,7 @@ class ThreadCollector(object): MESSAGE_IF_STRING_REPRESENTATION_INVALID = '[Could not get log message]' + class ThreadTrackingHandler(logging.Handler): def __init__(self, collector): logging.Handler.__init__(self) @@ -77,6 +79,7 @@ collector = ThreadCollector() logging_handler = ThreadTrackingHandler(collector) logging.root.addHandler(logging_handler) + class LoggingCollectorMiddleware(object): def process_request(self, request): collector.clear_collection() @@ -90,6 +93,7 @@ class LoggingCollectorMiddleware(object): request.logs = collector.get_collection() request.exception = exception + class CollectIPMiddleware(object): def process_response(self, request, response): # only collect IP if session is used @@ -104,6 +108,7 @@ class CollectIPMiddleware(object): request.session.modified = True return response + class OpenedSessionCookieMiddleware(object): def process_response(self, request, response): # do not emit cookie for API requests @@ -122,6 +127,7 @@ class OpenedSessionCookieMiddleware(object): response.delete_cookie(name, domain=domain) return response + class RequestIdMiddleware(object): def process_request(self, request): if not hasattr(request, 'request_id'): @@ -136,6 +142,7 @@ class RequestIdMiddleware(object): hexlify(struct.pack('I', random_id)), encoding='ascii') + class StoreRequestMiddleware(object): collection = {} @@ -153,6 +160,7 @@ class StoreRequestMiddleware(object): def get_request(cls): return cls.collection.get(threading.currentThread()) + class ViewRestrictionMiddleware(object): RESTRICTION_SESSION_KEY = 'view-restriction' @@ -185,6 +193,7 @@ class ViewRestrictionMiddleware(object): messages.warning(request, _('You must change your password to continue')) return utils.redirect_and_come_back(request, view) + class XForwardedForMiddleware(object): '''Copy the first address from X-Forwarded-For header to the REMOTE_ADDR meta. @@ -195,6 +204,7 @@ class XForwardedForMiddleware(object): request.META['REMOTE_ADDR'] = request.META['HTTP_X_FORWARDED_FOR'].split(",")[0].strip() return None + class DisplayMessageBeforeRedirectMiddleware(object): '''Verify if messages are currently stored and if there is a redirection to another domain, in this case show an intermediate page. @@ -236,7 +246,6 @@ class DisplayMessageBeforeRedirectMiddleware(object): class ServiceAccessControlMiddleware(object): - def process_exception(self, request, exception): if not isinstance(exception, (utils.ServiceAccessDenied,)): return None diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py index 361a2b9d..4f19cec8 100644 --- a/src/authentic2/passwords.py +++ b/src/authentic2/passwords.py @@ -6,10 +6,10 @@ import abc from django.utils.translation import ugettext as _ from django.utils.module_loading import import_string from django.utils.functional import lazy -from django.utils.safestring import mark_safe from django.utils import six from django.core.exceptions import ValidationError + from . import app_settings diff --git a/src/authentic2/profile_forms.py b/src/authentic2/profile_forms.py deleted file mode 100644 index 540591d5..00000000 --- a/src/authentic2/profile_forms.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging - -from django import forms -from django.utils.translation import ugettext as _ -from django.contrib.auth import get_user_model - -from .backends import get_user_queryset -from .utils import send_password_reset_mail -from . import hooks, app_settings - - -logger = logging.getLogger(__name__) - - -class PasswordResetForm(forms.Form): - next_url = forms.CharField(widget=forms.HiddenInput, required=False) - - email = forms.EmailField( - label=_("Email"), max_length=254) - - def save(self): - """ - Generates a one-use only link for resetting password and sends to the - user. - """ - email = self.cleaned_data["email"].strip() - users = get_user_queryset() - active_users = users.filter(email__iexact=email, is_active=True) - for user in active_users: - # we don't set the password to a random string, as some users should not have - # a password - set_random_password = (user.has_usable_password() - and app_settings.A2_SET_RANDOM_PASSWORD_ON_RESET) - send_password_reset_mail(user, set_random_password=set_random_password, - next_url=self.cleaned_data.get('next_url')) - if not active_users: - logger.info(u'password reset requests for "%s", no user found') - hooks.call_hooks('event', name='password-reset', email=email, users=active_users) diff --git a/src/authentic2/profile_urls.py b/src/authentic2/profile_urls.py deleted file mode 100644 index f27ab78b..00000000 --- a/src/authentic2/profile_urls.py +++ /dev/null @@ -1,97 +0,0 @@ -from django.conf.urls import url -from django.contrib.auth import views as auth_views, REDIRECT_FIELD_NAME -from django.contrib.auth.decorators import login_required -from django.core.urlresolvers import reverse -from django.http import HttpResponseRedirect -from django.contrib import messages -from django.utils.translation import ugettext as _ -from django.views.decorators.debug import sensitive_post_parameters - -from authentic2.utils import import_module_or_class, redirect, user_can_change_password -from . import app_settings, decorators, profile_views, hooks -from .views import (logged_in, edit_profile, email_change, email_change_verify, profile) - -SET_PASSWORD_FORM_CLASS = import_module_or_class( - app_settings.A2_REGISTRATION_SET_PASSWORD_FORM_CLASS) -CHANGE_PASSWORD_FORM_CLASS = import_module_or_class( - app_settings.A2_REGISTRATION_CHANGE_PASSWORD_FORM_CLASS) - -@sensitive_post_parameters() -@login_required -@decorators.setting_enabled('A2_REGISTRATION_CAN_CHANGE_PASSWORD') -def password_change_view(request, *args, **kwargs): - post_change_redirect = kwargs.pop('post_change_redirect', None) - if 'next_url' in request.POST and request.POST['next_url']: - post_change_redirect = request.POST['next_url'] - elif REDIRECT_FIELD_NAME in request.GET: - post_change_redirect = request.GET[REDIRECT_FIELD_NAME] - elif post_change_redirect is None: - post_change_redirect = reverse('account_management') - if not user_can_change_password(request=request): - messages.warning(request, _('Password change is forbidden')) - return redirect(request, post_change_redirect) - if 'cancel' in request.POST: - return redirect(request, post_change_redirect) - kwargs['post_change_redirect'] = post_change_redirect - extra_context = kwargs.setdefault('extra_context', {}) - extra_context['view'] = password_change_view - extra_context[REDIRECT_FIELD_NAME] = post_change_redirect - if not request.user.has_usable_password(): - kwargs['password_change_form'] = SET_PASSWORD_FORM_CLASS - response = auth_views.password_change(request, *args, **kwargs) - if isinstance(response, HttpResponseRedirect): - hooks.call_hooks('event', name='change-password', user=request.user, request=request) - messages.info(request, _('Password changed')) - return response - -password_change_view.title = _('Password Change') -password_change_view.do_not_call_in_templates = True - - -urlpatterns = [ - url(r'^logged-in/$', logged_in, name='logged-in'), - url(r'^edit/$', edit_profile, name='profile_edit'), - url(r'^edit/(?P[-\w]+)/$', edit_profile, name='profile_edit_with_scope'), - url(r'^change-email/$', email_change, name='email-change'), - url(r'^change-email/verify/$', email_change_verify, - name='email-change-verify'), - url(r'^$', profile, name='account_management'), - url(r'^password/change/$', - password_change_view, - {'password_change_form': CHANGE_PASSWORD_FORM_CLASS}, - name='password_change'), - url(r'^password/change/done/$', - auth_views.password_change_done, - name='password_change_done'), - - # Password reset - url(r'^password/reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', - profile_views.password_reset_confirm, - name='password_reset_confirm'), - url(r'^password/reset/$', - profile_views.password_reset, - name='password_reset'), - - # Legacy - url(r'^password/change/$', - password_change_view, - {'password_change_form': CHANGE_PASSWORD_FORM_CLASS}, - name='auth_password_change'), - url(r'^password/change/done/$', - auth_views.password_change_done, - name='auth_password_change_done'), - url(r'^password/reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', - auth_views.password_reset_confirm, - {'set_password_form': SET_PASSWORD_FORM_CLASS}, - name='auth_password_reset_confirm'), - url(r'^password/reset/$', - auth_views.password_reset, - name='auth_password_reset'), - url(r'^password/reset/complete/$', - auth_views.password_reset_complete, - name='auth_password_reset_complete'), - url(r'^password/reset/done/$', - auth_views.password_reset_done, - name='auth_password_reset_done'), - url(r'^switch-back/$', profile_views.switch_back, name='a2-switch-back'), -] diff --git a/src/authentic2/profile_views.py b/src/authentic2/profile_views.py deleted file mode 100644 index 8739ced9..00000000 --- a/src/authentic2/profile_views.py +++ /dev/null @@ -1,127 +0,0 @@ -import logging - -from django.views.generic import FormView -from django.contrib import messages -from django.contrib.auth import get_user_model, REDIRECT_FIELD_NAME, authenticate -from django.http import Http404 -from django.utils.translation import ugettext as _ -from django.utils.http import urlsafe_base64_decode - -from .compat import default_token_generator -from .registration_backend.forms import SetPasswordForm -from . import app_settings, cbv, profile_forms, utils, hooks - - -class PasswordResetView(cbv.NextURLViewMixin, FormView): - '''Ask for an email and send a password reset link by mail''' - form_class = profile_forms.PasswordResetForm - title = _('Password Reset') - - def get_template_names(self): - return [ - 'authentic2/password_reset_form.html', - 'registration/password_reset_form.html', - ] - - def get_form_kwargs(self, **kwargs): - kwargs = super(PasswordResetView, self).get_form_kwargs(**kwargs) - initial = kwargs.setdefault('initial', {}) - initial['next_url'] = self.request.GET.get(REDIRECT_FIELD_NAME, '') - return kwargs - - def get_context_data(self, **kwargs): - ctx = super(PasswordResetView, self).get_context_data(**kwargs) - if app_settings.A2_USER_CAN_RESET_PASSWORD is False: - raise Http404('Password reset is not allowed.') - ctx['title'] = _('Password reset') - return ctx - - def form_valid(self, form): - form.save() - # return to next URL - messages.info(self.request, _('If your email address exists in our ' - 'database, you will receive an email ' - 'containing instructions to reset ' - 'your password')) - return super(PasswordResetView, self).form_valid(form) - -password_reset = PasswordResetView.as_view() - - -class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView): - '''Validate password reset link, show a set password form and login - the user. - ''' - form_class = SetPasswordForm - title = _('Password Reset') - - def get_template_names(self): - return [ - 'registration/password_reset_confirm.html', - 'authentic2/password_reset_confirm.html', - ] - - def dispatch(self, request, *args, **kwargs): - validlink = True - uidb64 = kwargs['uidb64'] - self.token = token = kwargs['token'] - - UserModel = get_user_model() - # checked by URLconf - assert uidb64 is not None and token is not None - try: - uid = urlsafe_base64_decode(uidb64) - # use authenticate to eventually get an LDAPUser - self.user = authenticate(user=UserModel._default_manager.get(pk=uid)) - except (TypeError, ValueError, OverflowError, - UserModel.DoesNotExist): - validlink = False - messages.warning(request, _('User not found')) - - if validlink and not default_token_generator.check_token(self.user, token): - validlink = False - messages.warning(request, _('You reset password link is invalid ' - 'or has expired')) - if not validlink: - return utils.redirect(request, self.get_success_url()) - can_reset_password = utils.get_user_flag(user=self.user, - name='can_reset_password', - default=self.user.has_usable_password()) - if not can_reset_password: - messages.warning(request, _('It\'s not possible to reset your password. Please ' - 'contact an administrator.')) - return utils.redirect(request, self.get_success_url()) - return super(PasswordResetConfirmView, self).dispatch(request, *args, - **kwargs) - - def get_context_data(self, **kwargs): - ctx = super(PasswordResetConfirmView, self).get_context_data(**kwargs) - # compatibility with existing templates ! - ctx['title'] = _('Enter new password') - ctx['validlink'] = True - return ctx - - def get_form_kwargs(self): - kwargs = super(PasswordResetConfirmView, self).get_form_kwargs() - kwargs['user'] = self.user - return kwargs - - def form_valid(self, form): - # Changing password by mail validate the email - form.user.email_verified = True - form.save() - hooks.call_hooks('event', name='password-reset-confirm', user=form.user, token=self.token, - form=form) - logging.getLogger(__name__).info(u'user %s resetted its password with ' - 'token %r...', self.user, - self.token[:9]) - return self.finish() - - def finish(self): - return utils.simulate_authentication(self.request, self.user, 'email') - -password_reset_confirm = PasswordResetConfirmView.as_view() - - -def switch_back(request): - return utils.switch_back(request) diff --git a/src/authentic2/registration_backend/__init__.py b/src/authentic2/registration_backend/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/registration_backend/urls.py b/src/authentic2/registration_backend/urls.py deleted file mode 100644 index 264883c7..00000000 --- a/src/authentic2/registration_backend/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.conf.urls import url - -from django.views.generic.base import TemplateView -from django.contrib.auth.decorators import login_required - -from .views import RegistrationView, registration_completion, DeleteView, registration_complete - -urlpatterns = [ - url(r'^activate/(?P[\w: -]+)/$', - registration_completion, name='registration_activate'), - url(r'^register/$', - RegistrationView.as_view(), - name='registration_register'), - url(r'^register/complete/$', - registration_complete, - name='registration_complete'), - url(r'^register/closed/$', - TemplateView.as_view(template_name='registration/registration_closed.html'), - name='registration_disallowed'), - url(r'^delete/$', - login_required(DeleteView.as_view()), - name='delete_account'), -] diff --git a/src/authentic2/registration_backend/views.py b/src/authentic2/registration_backend/views.py deleted file mode 100644 index 2d3e73a5..00000000 --- a/src/authentic2/registration_backend/views.py +++ /dev/null @@ -1,416 +0,0 @@ -import collections -import logging -import random - -from django.conf import settings -from django.shortcuts import get_object_or_404 -from django.utils.translation import ugettext as _ -from django.utils.http import urlquote -from django.contrib import messages -from django.contrib.auth import REDIRECT_FIELD_NAME -from django.core import signing -from django.views.generic.base import TemplateView -from django.views.generic.edit import FormView, CreateView -from django.contrib.auth import get_user_model -from django.forms import CharField, Form -from django.core.urlresolvers import reverse_lazy -from django.http import Http404, HttpResponseBadRequest - -from authentic2.utils import (import_module_or_class, redirect, make_url, get_fields_and_labels, - simulate_authentication) -from authentic2.a2_rbac.utils import get_default_ou -from authentic2 import hooks - -from django_rbac.utils import get_ou_model - -from .. import models, app_settings, compat, cbv, forms, validators, utils, constants -from .forms import RegistrationCompletionForm, DeleteAccountForm -from .forms import RegistrationCompletionFormNoPassword -from authentic2.a2_rbac.models import OrganizationalUnit - -logger = logging.getLogger(__name__) - -User = compat.get_user_model() - - -def valid_token(method): - def f(request, *args, **kwargs): - try: - request.token = signing.loads(kwargs['registration_token'].replace(' ', ''), - max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) - except signing.SignatureExpired: - messages.warning(request, _('Your activation key is expired')) - return redirect(request, 'registration_register') - except signing.BadSignature: - messages.warning(request, _('Activation failed')) - return redirect(request, 'registration_register') - return method(request, *args, **kwargs) - return f - - -class BaseRegistrationView(FormView): - form_class = import_module_or_class(app_settings.A2_REGISTRATION_FORM_CLASS) - template_name = 'registration/registration_form.html' - title = _('Registration') - - def dispatch(self, request, *args, **kwargs): - if not getattr(settings, 'REGISTRATION_OPEN', True): - raise Http404('Registration is not open.') - self.token = {} - self.ou = get_default_ou() - # load pre-filled values - if request.GET.get('token'): - try: - self.token = signing.loads( - request.GET.get('token'), - max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) - except (TypeError, ValueError, signing.BadSignature) as e: - logger.warning(u'registration_view: invalid token: %s', e) - return HttpResponseBadRequest('invalid token', content_type='text/plain') - if 'ou' in self.token: - self.ou = OrganizationalUnit.objects.get(pk=self.token['ou']) - self.next_url = self.token.pop(REDIRECT_FIELD_NAME, utils.select_next_url(request, None)) - return super(BaseRegistrationView, self).dispatch(request, *args, **kwargs) - - def form_valid(self, form): - email = form.cleaned_data.pop('email') - for field in form.cleaned_data: - self.token[field] = form.cleaned_data[field] - - # propagate service to the registration completion view - if constants.SERVICE_FIELD_NAME in self.request.GET: - self.token[constants.SERVICE_FIELD_NAME] = \ - self.request.GET[constants.SERVICE_FIELD_NAME] - - self.token.pop(REDIRECT_FIELD_NAME, None) - self.token.pop('email', None) - - utils.send_registration_mail(self.request, email, next_url=self.next_url, - ou=self.ou, **self.token) - self.request.session['registered_email'] = email - return redirect(self.request, 'registration_complete', params={REDIRECT_FIELD_NAME: self.next_url}) - - def get_context_data(self, **kwargs): - context = super(BaseRegistrationView, self).get_context_data(**kwargs) - parameters = {'request': self.request, - 'context': context} - blocks = [utils.get_authenticator_method(authenticator, 'registration', parameters) - for authenticator in utils.get_backends('AUTH_FRONTENDS')] - context['frontends'] = collections.OrderedDict((block['id'], block) - for block in blocks if block) - return context - - -class RegistrationView(cbv.ValidateCSRFMixin, BaseRegistrationView): - pass - - -class RegistrationCompletionView(CreateView): - model = get_user_model() - success_url = 'auth_homepage' - - def get_template_names(self): - if self.users and not 'create' in self.request.GET: - return ['registration/registration_completion_choose.html'] - else: - return ['registration/registration_completion_form.html'] - - def get_success_url(self): - try: - redirect_url, next_field = app_settings.A2_REGISTRATION_REDIRECT - except Exception: - redirect_url = app_settings.A2_REGISTRATION_REDIRECT - next_field = REDIRECT_FIELD_NAME - - if self.token and self.token.get(REDIRECT_FIELD_NAME): - url = self.token[REDIRECT_FIELD_NAME] - if redirect_url: - url = make_url(redirect_url, params={next_field: url}) - else: - if redirect_url: - url = redirect_url - else: - url = make_url(self.success_url) - return url - - def dispatch(self, request, *args, **kwargs): - self.token = request.token - self.authentication_method = self.token.get('authentication_method', 'email') - self.email = request.token['email'] - if 'ou' in self.token: - self.ou = OrganizationalUnit.objects.get(pk=self.token['ou']) - else: - self.ou = get_default_ou() - self.users = User.objects.filter(email__iexact=self.email) \ - .order_by('date_joined') - if self.ou: - self.users = self.users.filter(ou=self.ou) - self.email_is_unique = app_settings.A2_EMAIL_IS_UNIQUE \ - or app_settings.A2_REGISTRATION_EMAIL_IS_UNIQUE - if self.ou: - self.email_is_unique |= self.ou.email_is_unique - self.init_fields_labels_and_help_texts() - # if registration is done during an SSO add the service to the registration event - self.service = self.token.get(constants.SERVICE_FIELD_NAME) - return super(RegistrationCompletionView, self) \ - .dispatch(request, *args, **kwargs) - - def init_fields_labels_and_help_texts(self): - attributes = models.Attribute.objects.filter( - asked_on_registration=True) - default_fields = attributes.values_list('name', flat=True) - required_fields = models.Attribute.objects.filter(required=True) \ - .values_list('name', flat=True) - fields, labels = get_fields_and_labels( - app_settings.A2_REGISTRATION_FIELDS, - default_fields, - app_settings.A2_REGISTRATION_REQUIRED_FIELDS, - app_settings.A2_REQUIRED_FIELDS, - models.Attribute.objects.filter(required=True).values_list('name', flat=True)) - help_texts = {} - if app_settings.A2_REGISTRATION_FORM_USERNAME_LABEL: - labels['username'] = app_settings.A2_REGISTRATION_FORM_USERNAME_LABEL - if app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT: - help_texts['username'] = \ - app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT - required = list(app_settings.A2_REGISTRATION_REQUIRED_FIELDS) + \ - list(required_fields) - if 'email' in fields: - fields.remove('email') - for field in self.token.get('skip_fields') or []: - if field in fields: - fields.remove(field) - self.fields = fields - self.labels = labels - self.required = required - self.help_texts = help_texts - - def get_form_class(self): - if not self.token.get('valid_email', True): - self.fields.append('email') - self.required.append('email') - form_class = RegistrationCompletionForm - if self.token.get('no_password', False): - form_class = RegistrationCompletionFormNoPassword - form_class = forms.modelform_factory(self.model, - form=form_class, - fields=self.fields, - labels=self.labels, - required=self.required, - help_texts=self.help_texts) - if 'username' in self.fields and app_settings.A2_REGISTRATION_FORM_USERNAME_REGEX: - # Keep existing field label and help_text - old_field = form_class.base_fields['username'] - field = CharField( - max_length=256, - label=old_field.label, - help_text=old_field.help_text, - validators=[validators.UsernameValidator()]) - form_class = type('RegistrationForm', (form_class,), {'username': field}) - return form_class - - def get_form_kwargs(self, **kwargs): - '''Initialize mail from token''' - kwargs = super(RegistrationCompletionView, self).get_form_kwargs(**kwargs) - if 'ou' in self.token: - OU = get_ou_model() - ou = get_object_or_404(OU, id=self.token['ou']) - else: - ou = get_default_ou() - - attributes = {'email': self.email, 'ou': ou} - for key in self.token: - if key in app_settings.A2_PRE_REGISTRATION_FIELDS: - attributes[key] = self.token[key] - logger.debug(u'attributes %s', attributes) - - prefilling_list = utils.accumulate_from_backends(self.request, 'registration_form_prefill') - logger.debug(u'prefilling_list %s', prefilling_list) - # Build a single meaningful prefilling with sets of values - prefilling = {} - for p in prefilling_list: - for name, values in p.items(): - if name in self.fields: - prefilling.setdefault(name, set()).update(values) - logger.debug(u'prefilling %s', prefilling) - - for name, values in prefilling.items(): - attributes[name] = ' '.join(values) - logger.debug(u'attributes with prefilling %s', attributes) - - if self.token.get('user_id'): - kwargs['instance'] = User.objects.get(id=self.token.get('user_id')) - else: - init_kwargs = {} - for key in ('email', 'first_name', 'last_name', 'ou'): - if key in attributes: - init_kwargs[key] = attributes[key] - kwargs['instance'] = get_user_model()(**init_kwargs) - - return kwargs - - def get_form(self, form_class=None): - form = super(RegistrationCompletionView, self).get_form(form_class=form_class) - hooks.call_hooks('front_modify_form', self, form) - return form - - def get_context_data(self, **kwargs): - ctx = super(RegistrationCompletionView, self).get_context_data(**kwargs) - ctx['token'] = self.token - ctx['users'] = self.users - ctx['email'] = self.email - ctx['email_is_unique'] = self.email_is_unique - ctx['create'] = 'create' in self.request.GET - return ctx - - def get(self, request, *args, **kwargs): - if len(self.users) == 1 and self.email_is_unique: - # Found one user, EMAIL is unique, log her in - simulate_authentication(request, self.users[0], - method=self.authentication_method, - service_slug=self.service) - return redirect(request, self.get_success_url()) - confirm_data = self.token.get('confirm_data', False) - - if confirm_data == 'required': - fields_to_confirm = self.required - else: - fields_to_confirm = self.fields - if (all(field in self.token for field in fields_to_confirm) - and (not confirm_data or confirm_data == 'required')): - # We already have every fields - form_kwargs = self.get_form_kwargs() - form_class = self.get_form_class() - data = self.token - if 'password' in data: - data['password1'] = data['password'] - data['password2'] = data['password'] - del data['password'] - form_kwargs['data'] = data - form = form_class(**form_kwargs) - if form.is_valid(): - user = form.save() - return self.registration_success(request, user, form) - self.get_form = lambda *args, **kwargs: form - return super(RegistrationCompletionView, self).get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - if self.users and self.email_is_unique: - # email is unique, users already exist, creating a new one is forbidden ! - return redirect(request, request.resolver_match.view_name, args=self.args, - kwargs=self.kwargs) - if 'uid' in request.POST: - uid = request.POST['uid'] - for user in self.users: - if str(user.id) == uid: - simulate_authentication(request, user, - method=self.authentication_method, - service_slug=self.service) - return redirect(request, self.get_success_url()) - return super(RegistrationCompletionView, self).post(request, *args, **kwargs) - - def form_valid(self, form): - - # remove verified fields from form, this allows an authentication - # method to provide verified data fields and to present it to the user, - # while preventing the user to modify them. - for av in models.AttributeValue.objects.with_owner(form.instance): - if av.verified and av.attribute.name in form.fields: - del form.fields[av.attribute.name] - - if ('email' in self.request.POST - and (not 'email' in self.token or self.request.POST['email'] != self.token['email']) - and not self.token.get('skip_email_check')): - # If an email is submitted it must be validated or be the same as in the token - data = form.cleaned_data - data['no_password'] = self.token.get('no_password', False) - utils.send_registration_mail( - self.request, - ou=self.ou, - next_url=self.get_success_url(), - **data) - self.request.session['registered_email'] = form.cleaned_data['email'] - return redirect(self.request, 'registration_complete') - super(RegistrationCompletionView, self).form_valid(form) - return self.registration_success(self.request, form.instance, form) - - def registration_success(self, request, user, form): - hooks.call_hooks('event', name='registration', user=user, form=form, view=self, - authentication_method=self.authentication_method, - token=request.token, service=self.service) - simulate_authentication(request, user, method=self.authentication_method, - service_slug=self.service) - messages.info(self.request, _('You have just created an account.')) - self.send_registration_success_email(user) - return redirect(request, self.get_success_url()) - - def send_registration_success_email(self, user): - if not user.email: - return - - template_names = [ - 'authentic2/registration_success' - ] - login_url = self.request.build_absolute_uri(settings.LOGIN_URL) - utils.send_templated_mail(user, template_names=template_names, - context={ - 'user': user, - 'email': user.email, - 'site': self.request.get_host(), - 'login_url': login_url, - }, - request=self.request) - - -class DeleteView(FormView): - template_name = 'authentic2/accounts_delete.html' - success_url = reverse_lazy('auth_logout') - title = _('Delete account') - - def dispatch(self, request, *args, **kwargs): - if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT: - return redirect(request, '..') - return super(DeleteView, self).dispatch(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - if 'cancel' in request.POST: - return redirect(request, 'account_management') - return super(DeleteView, self).post(request, *args, **kwargs) - - def get_form_class(self): - if self.request.user.has_usable_password(): - return DeleteAccountForm - return Form - - def get_form_kwargs(self, **kwargs): - kwargs = super(DeleteView, self).get_form_kwargs(**kwargs) - if self.request.user.has_usable_password(): - kwargs['user'] = self.request.user - return kwargs - - def form_valid(self, form): - utils.send_account_deletion_mail(self.request, self.request.user) - models.DeletedUser.objects.delete_user(self.request.user) - self.request.user.email += '#%d' % random.randint(1, 10000000) - self.request.user.email_verified = False - self.request.user.save(update_fields=['email', 'email_verified']) - logger.info(u'deletion of account %s requested', self.request.user) - hooks.call_hooks('event', name='delete-account', user=self.request.user) - messages.info(self.request, - _('Your account has been scheduled for deletion. You cannot use it anymore.')) - return super(DeleteView, self).form_valid(form) - -registration_completion = valid_token(RegistrationCompletionView.as_view()) - - -class RegistrationCompleteView(TemplateView): - template_name = 'registration/registration_complete.html' - - def get_context_data(self, **kwargs): - kwargs['next_url'] = utils.select_next_url(self.request, settings.LOGIN_REDIRECT_URL) - return super(RegistrationCompleteView, self).get_context_data( - account_activation_days=settings.ACCOUNT_ACTIVATION_DAYS, - **kwargs) - - -registration_complete = RegistrationCompleteView.as_view() diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py index b35b1559..f3363bc5 100644 --- a/src/authentic2/urls.py +++ b/src/authentic2/urls.py @@ -1,44 +1,129 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + from django.conf.urls import url, include from django.conf import settings from django.contrib import admin +from django.contrib.auth.decorators import login_required +from django.contrib.auth import views as dj_auth_views from django.contrib.staticfiles.views import serve +from django.views.generic.base import TemplateView from django.views.static import serve as media_serve -from . import app_settings, plugins, views +from . import plugins, views admin.autodiscover() handler500 = 'authentic2.views.server_error' -urlpatterns = [ - url(r'^$', views.homepage, name='auth_homepage'), - url(r'test_redirect/$', views.test_redirect) +accounts_urlpatterns = [ + url(r'^activate/(?P[\w: -]+)/$', + views.registration_completion, name='registration_activate'), + url(r'^register/$', + views.RegistrationView.as_view(), + name='registration_register'), + url(r'^register/complete/$', + views.registration_complete, + name='registration_complete'), + url(r'^register/closed/$', + TemplateView.as_view(template_name='registration/registration_closed.html'), + name='registration_disallowed'), + url(r'^delete/$', + login_required(views.DeleteView.as_view()), + name='delete_account'), + url(r'^logged-in/$', + views.logged_in, + name='logged-in'), + url(r'^edit/$', + views.edit_profile, + name='profile_edit'), + url(r'^edit/(?P[-\w]+)/$', + views.edit_profile, + name='profile_edit_with_scope'), + url(r'^change-email/$', + views.email_change, + name='email-change'), + url(r'^change-email/verify/$', + views.email_change_verify, + name='email-change-verify'), + url(r'^$', + views.profile, + name='account_management'), + + # Password change + url(r'^password/change/$', + views.password_change, + name='password_change'), + url(r'^password/change/done/$', + dj_auth_views.password_change_done, + name='password_change_done'), + + # Password reset + url(r'^password/reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + views.password_reset_confirm, + name='password_reset_confirm'), + url(r'^password/reset/$', + views.password_reset, + name='password_reset'), + + url(r'^switch-back/$', + views.switch_back, + name='a2-switch-back'), + + # Legacy, only there to provide old view names to resolver + url(r'^password/change/$', + views.notimplemented_view, + name='auth_password_change'), + url(r'^password/change/done/$', + views.notimplemented_view, + name='auth_password_change_done'), + + url(r'^password/reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + views.notimplemented_view, + name='auth_password_reset_confirm'), + url(r'^password/reset/$', + views.notimplemented_view, + name='auth_password_reset'), + url(r'^password/reset/complete/$', + views.notimplemented_view, + name='auth_password_reset_complete'), + url(r'^password/reset/done/$', + views.notimplemented_view, + name='auth_password_reset_done'), ] -not_homepage_patterns = [ +urlpatterns = [ + url(r'^$', views.homepage, name='auth_homepage'), url(r'^login/$', views.login, name='auth_login'), url(r'^logout/$', views.logout, name='auth_logout'), url(r'^redirect/(.*)', views.redirect, name='auth_redirect'), - url(r'^accounts/', include('authentic2.profile_urls')) -] - -not_homepage_patterns += [ - url(r'^accounts/', include(app_settings.A2_REGISTRATION_URLCONF)), + url(r'^accounts/', include(accounts_urlpatterns)), url(r'^admin/', include(admin.site.urls)), url(r'^idp/', include('authentic2.idp.urls')), url(r'^manage/', include('authentic2.manager.urls')), - url(r'^api/', include('authentic2.api_urls')) + url(r'^api/', include('authentic2.api_urls')), + url(r'^test_redirect/$', views.test_redirect), ] - -urlpatterns += not_homepage_patterns - try: if getattr(settings, 'DISCO_SERVICE', False): urlpatterns += [ (r'^disco_service/', include('disco_service.disco_responder')), ] -except: +except Exception: pass if settings.DEBUG: @@ -46,8 +131,10 @@ if settings.DEBUG: url(r'^static/(?P.*)$', serve) ] urlpatterns += [ - url(r'^media/(?P.*)$', media_serve, { - 'document_root': settings.MEDIA_ROOT}) + url(r'^media/(?P.*)$', media_serve, + { + 'document_root': settings.MEDIA_ROOT + }) ] if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS: diff --git a/src/authentic2/views.py b/src/authentic2/views.py index fdcc6022..0f6c9b44 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -1,19 +1,33 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import collections import logging -from authentic2.compat_lasso import lasso -import requests +import random import re -import collections - from django.conf import settings -from django.shortcuts import render_to_response, render -from django.template.loader import render_to_string, select_template +from django.shortcuts import render_to_response, render, get_object_or_404 +from django.template.loader import render_to_string from django.views.generic.edit import UpdateView, FormView -from django.views.generic import RedirectView, TemplateView +from django.views.generic import TemplateView from django.views.generic.base import View from django.contrib.auth import SESSION_KEY from django import http, shortcuts -from django.core import mail, signing +from django.core import signing from django.core.urlresolvers import reverse from django.core.exceptions import ValidationError from django.contrib import messages @@ -21,21 +35,30 @@ from django.utils import six from django.utils.translation import ugettext as _ from django.contrib.auth import logout as auth_logout from django.contrib.auth import REDIRECT_FIELD_NAME -from django.http import (HttpResponseRedirect, HttpResponseForbidden, - HttpResponse) -from django.core.exceptions import PermissionDenied +from django.contrib.auth.views import password_change as dj_password_change +from django.http import (HttpResponseRedirect, HttpResponseForbidden, HttpResponse) from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from django.views.decorators.cache import never_cache +from django.views.decorators.debug import sensitive_post_parameters from django.contrib.auth.decorators import login_required from django.db.models.fields import FieldDoesNotExist from django.db.models.query import Q - -# FIXME: this decorator has nothing to do with an idp, should be moved in the -# a2 package -# FIXME: this constant should be moved in the a2 package - - -from . import (utils, app_settings, forms, compat, decorators, constants, models, cbv, hooks) +from django.contrib.auth import get_user_model, authenticate +from django.http import Http404 +from django.utils.http import urlsafe_base64_decode +from django.views.generic.edit import CreateView +from django.forms import CharField, Form +from django.core.urlresolvers import reverse_lazy +from django.http import HttpResponseBadRequest + +from . import (utils, app_settings, forms, compat, decorators, constants, + models, cbv, hooks, validators) +from .a2_rbac.utils import get_default_ou +from .a2_rbac.models import OrganizationalUnit as OU +from .forms import ( + passwords as passwords_forms, + registration as registration_forms, + profile as profile_forms) logger = logging.getLogger(__name__) @@ -47,7 +70,7 @@ def redirect(request, next, template_name='redirect.html'): if not next.startswith('http'): next = '/%s%s' % (request.get_host(), next) logging.info('Redirect to %r' % next) - return render_to_response(template_name, { 'next': next }) + return render_to_response(template_name, {'next': next}) def server_error(request, template_name='500.html'): @@ -100,8 +123,7 @@ class EditProfile(cbv.HookMixin, cbv.TemplateNamesMixin, UpdateView): else: default_fields = list(attributes.values_list('name', flat=True)) fields, labels = utils.get_fields_and_labels( - editable_profile_fields, - default_fields) + editable_profile_fields, default_fields) if scopes: # restrict fields to those in the scopes fields = [field for field in fields if field in default_fields] @@ -115,9 +137,10 @@ class EditProfile(cbv.HookMixin, cbv.TemplateNamesMixin, UpdateView): fields, labels = self.get_fields(scopes=scopes) # Email must be edited through the change email view, as it needs validation fields = [field for field in fields if field != 'email'] - return forms.modelform_factory(compat.get_user_model(), fields=fields, - labels=labels, - form=forms.EditProfileForm) + return profile_forms.modelform_factory( + compat.get_user_model(), fields=fields, + labels=labels, + form=profile_forms.EditProfileForm) def get_object(self): return self.request.user @@ -173,8 +196,8 @@ class EmailChangeView(cbv.TemplateNamesMixin, FormView): def get_form_class(self): if self.request.user.has_usable_password(): - return forms.EmailChangeForm - return forms.EmailChangeFormNoPassword + return profile_forms.EmailChangeForm + return profile_forms.EmailChangeFormNoPassword def get_form_kwargs(self): kwargs = super(EmailChangeView, self).get_form_kwargs() @@ -225,24 +248,23 @@ class EmailChangeVerifyView(TemplateView): user.email = email user.email_verified = True user.save() - messages.info(request, _('your request for changing your email for {0} ' - 'is successful').format(email)) - logging.getLogger(__name__).info('user %s changed its email ' - 'from %s to %s', user, - old_email, email) + messages.info(request, + _('your request for changing your email for {0} is successful').format(email)) + logging.getLogger(__name__).info( + 'user %s changed its email from %s to %s', user, old_email, email) hooks.call_hooks('event', name='change-email-confirm', user=user, email=email) except signing.SignatureExpired: - messages.error(request, _('your request for changing your email is too ' - 'old, try again')) + messages.error(request, + _('your request for changing your email is too old, try again')) except signing.BadSignature: - messages.error(request, _('your request for changing your email is ' - 'invalid, try again')) + messages.error(request, + _('your request for changing your email is invalid, try again')) except ValueError: - messages.error(request, _('your request for changing your email was not ' - 'on this site, try again')) + messages.error(request, + _('your request for changing your email was not on this site, try again')) except User.DoesNotExist: - messages.error(request, _('your request for changing your email is for ' - 'an unknown user, try again')) + messages.error(request, + _('your request for changing your email is for an unknown user, try again')) except ValidationError as e: messages.error(request, e.message) else: @@ -264,8 +286,8 @@ def login(request, template_name='authentic2/login.html', # redirect user to homepage if already connected, if setting # A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE is True - if (request.user.is_authenticated() and - app_settings.A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE): + if (request.user.is_authenticated() + and app_settings.A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE): return utils.redirect(request, 'auth_homepage') redirect_to = request.GET.get(redirect_field_name) @@ -308,9 +330,9 @@ def login(request, template_name='authentic2/login.html', form_class = authenticator.form() submit_name = 'submit-%s' % fid block = { - 'id': fid, - 'name': name, - 'authenticator': authenticator + 'id': fid, + 'name': name, + 'authenticator': authenticator } if request.method == 'POST' and submit_name in request.POST: form = form_class(data=request.POST) @@ -322,7 +344,7 @@ def login(request, template_name='authentic2/login.html', else: block['form'] = form_class() blocks.append(block) - else: # New frontends API + else: # New frontends API parameters = {'request': request, 'context': context} block = utils.get_authenticator_method(authenticator, 'login', parameters) @@ -337,24 +359,21 @@ def login(request, template_name='authentic2/login.html', else: blocks[-1]['is_hidden'] = False - # Old frontends API for block in blocks: fid = block['id'] - if not 'form' in block: + if 'form' not in block: continue authenticator = block['authenticator'] context.update({ - 'submit_name': 'submit-%s' % fid, - redirect_field_name: redirect_to, - 'form': block['form'] + 'submit_name': 'submit-%s' % fid, + redirect_field_name: redirect_to, + 'form': block['form'] }) if hasattr(authenticator, 'get_context'): context.update(authenticator.get_context()) sub_template_name = authenticator.template() - block['content'] = render_to_string( - sub_template_name, context, - request=request) + block['content'] = render_to_string(sub_template_name, context, request=request) request.session.set_test_cookie() @@ -423,7 +442,7 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView): for field_name in getattr(request.user, 'USER_PROFILE', []): if field_name not in field_names: field_names.append(field_name) - qs = models.Attribute.objects.filter(Q(user_editable=True)|Q(user_visible=True)) + qs = models.Attribute.objects.filter(Q(user_editable=True) | Q(user_visible=True)) qs = qs.values_list('name', flat=True) for field_name in qs: if field_name not in field_names: @@ -479,8 +498,7 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView): # Credentials management parameters = {'request': request, 'context': context} - profiles = [utils.get_authenticator_method(frontend, 'profile', parameters) - for frontend in frontends] + profiles = [utils.get_authenticator_method(frontend, 'profile', parameters) for frontend in frontends] # Old frontends data structure for templates blocks = [block['content'] for block in profiles if block] # New frontends data structure for templates @@ -510,17 +528,24 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView): profile = login_required(ProfileView.as_view()) + def logout_list(request): '''Return logout links from idp backends''' return utils.accumulate_from_backends(request, 'logout_list') + def redirect_logout_list(request): '''Return redirect logout links from idp backends''' return utils.accumulate_from_backends(request, 'redirect_logout_list') -def logout(request, next_url=None, default_next_url='auth_homepage', - redirect_field_name=REDIRECT_FIELD_NAME, - template='authentic2/logout.html', do_local=True, check_referer=True): + +def logout(request, + next_url=None, + default_next_url='auth_homepage', + redirect_field_name=REDIRECT_FIELD_NAME, + template='authentic2/logout.html', + do_local=True, + check_referer=True): '''Logout first check if a logout request is authorized, i.e. that logout was done using a POST with CSRF token or with a GET from the same site. @@ -530,8 +555,7 @@ def logout(request, next_url=None, default_next_url='auth_homepage', ''' logger = logging.getLogger(__name__) default_next_url = utils.make_url(default_next_url) - next_url = next_url or request.GET.get(redirect_field_name, - default_next_url) + next_url = next_url or request.GET.get(redirect_field_name, default_next_url) ctx = {} ctx['next_url'] = next_url ctx['redir_timeout'] = 60 @@ -541,14 +565,14 @@ def logout(request, next_url=None, default_next_url='auth_homepage', return render(request, 'authentic2/logout_confirm.html', ctx) do_local = do_local and 'local' in request.GET if not do_local: - l = logout_list(request) - if l: + fragments = logout_list(request) + if fragments: # Full logout with iframes next_url = utils.make_url('auth_logout', params={ 'local': 'ok', REDIRECT_FIELD_NAME: next_url}) ctx['next_url'] = next_url - ctx['logout_list'] = l + ctx['logout_list'] = fragments ctx['message'] = _('Logging out from all your services') return render(request, template, ctx) # Get redirection targets for full logout with redirections @@ -613,13 +637,557 @@ class LoggedInView(View): logged_in = never_cache(LoggedInView.as_view()) + def csrf_failure_view(request, reason=""): messages.warning(request, _('The page is out of date, it was reloaded for you')) return HttpResponseRedirect(request.get_full_path()) + def test_redirect(request): next_url = request.GET.get(REDIRECT_FIELD_NAME, settings.LOGIN_REDIRECT_URL) messages.info(request, 'Une info') messages.warning(request, 'Un warning') messages.error(request, 'Une erreur') return HttpResponseRedirect(next_url) + + +class PasswordResetView(cbv.NextURLViewMixin, FormView): + '''Ask for an email and send a password reset link by mail''' + form_class = passwords_forms.PasswordResetForm + title = _('Password Reset') + + def get_template_names(self): + return [ + 'authentic2/password_reset_form.html', + 'registration/password_reset_form.html', + ] + + def get_form_kwargs(self, **kwargs): + kwargs = super(PasswordResetView, self).get_form_kwargs(**kwargs) + initial = kwargs.setdefault('initial', {}) + initial['next_url'] = self.request.GET.get(REDIRECT_FIELD_NAME, '') + return kwargs + + def get_context_data(self, **kwargs): + ctx = super(PasswordResetView, self).get_context_data(**kwargs) + if app_settings.A2_USER_CAN_RESET_PASSWORD is False: + raise Http404('Password reset is not allowed.') + ctx['title'] = _('Password reset') + return ctx + + def form_valid(self, form): + form.save() + # return to next URL + messages.info(self.request, _('If your email address exists in our ' + 'database, you will receive an email ' + 'containing instructions to reset ' + 'your password')) + return super(PasswordResetView, self).form_valid(form) + +password_reset = PasswordResetView.as_view() + + +class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView): + '''Validate password reset link, show a set password form and login + the user. + ''' + form_class = passwords_forms.SetPasswordForm + title = _('Password Reset') + + def get_template_names(self): + return [ + 'registration/password_reset_confirm.html', + 'authentic2/password_reset_confirm.html', + ] + + def dispatch(self, request, *args, **kwargs): + validlink = True + uidb64 = kwargs['uidb64'] + self.token = token = kwargs['token'] + + UserModel = get_user_model() + # checked by URLconf + assert uidb64 is not None and token is not None + try: + uid = urlsafe_base64_decode(uidb64) + # use authenticate to eventually get an LDAPUser + self.user = authenticate(user=UserModel._default_manager.get(pk=uid)) + except (TypeError, ValueError, OverflowError, + UserModel.DoesNotExist): + validlink = False + messages.warning(request, _('User not found')) + + if validlink and not compat.default_token_generator.check_token(self.user, token): + validlink = False + messages.warning(request, _('You reset password link is invalid or has expired')) + if not validlink: + return utils.redirect(request, self.get_success_url()) + can_reset_password = utils.get_user_flag(user=self.user, + name='can_reset_password', + default=self.user.has_usable_password()) + if not can_reset_password: + messages.warning( + request, + _('It\'s not possible to reset your password. Please contact an administrator.')) + return utils.redirect(request, self.get_success_url()) + return super(PasswordResetConfirmView, self).dispatch(request, *args, + **kwargs) + + def get_context_data(self, **kwargs): + ctx = super(PasswordResetConfirmView, self).get_context_data(**kwargs) + # compatibility with existing templates ! + ctx['title'] = _('Enter new password') + ctx['validlink'] = True + return ctx + + def get_form_kwargs(self): + kwargs = super(PasswordResetConfirmView, self).get_form_kwargs() + kwargs['user'] = self.user + return kwargs + + def form_valid(self, form): + # Changing password by mail validate the email + form.user.email_verified = True + form.save() + hooks.call_hooks('event', name='password-reset-confirm', user=form.user, token=self.token, + form=form) + logger.info(u'user %s resetted its password with token %r...', + self.user, self.token[:9]) + return self.finish() + + def finish(self): + return utils.simulate_authentication(self.request, self.user, 'email') + +password_reset_confirm = PasswordResetConfirmView.as_view() + + +def switch_back(request): + return utils.switch_back(request) + +logger = logging.getLogger(__name__) + +User = compat.get_user_model() + + +def valid_token(method): + def f(request, *args, **kwargs): + try: + request.token = signing.loads(kwargs['registration_token'].replace(' ', ''), + max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) + except signing.SignatureExpired: + messages.warning(request, _('Your activation key is expired')) + return utils.redirect(request, 'registration_register') + except signing.BadSignature: + messages.warning(request, _('Activation failed')) + return utils.redirect(request, 'registration_register') + return method(request, *args, **kwargs) + return f + + +class BaseRegistrationView(FormView): + form_class = registration_forms.RegistrationForm + template_name = 'registration/registration_form.html' + title = _('Registration') + + def dispatch(self, request, *args, **kwargs): + if not getattr(settings, 'REGISTRATION_OPEN', True): + raise Http404('Registration is not open.') + self.token = {} + self.ou = get_default_ou() + # load pre-filled values + if request.GET.get('token'): + try: + self.token = signing.loads( + request.GET.get('token'), + max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) + except (TypeError, ValueError, signing.BadSignature) as e: + logger.warning(u'registration_view: invalid token: %s', e) + return HttpResponseBadRequest('invalid token', content_type='text/plain') + if 'ou' in self.token: + self.ou = OU.objects.get(pk=self.token['ou']) + self.next_url = self.token.pop(REDIRECT_FIELD_NAME, utils.select_next_url(request, None)) + return super(BaseRegistrationView, self).dispatch(request, *args, **kwargs) + + def form_valid(self, form): + email = form.cleaned_data.pop('email') + for field in form.cleaned_data: + self.token[field] = form.cleaned_data[field] + + # propagate service to the registration completion view + if constants.SERVICE_FIELD_NAME in self.request.GET: + self.token[constants.SERVICE_FIELD_NAME] = \ + self.request.GET[constants.SERVICE_FIELD_NAME] + + self.token.pop(REDIRECT_FIELD_NAME, None) + self.token.pop('email', None) + + utils.send_registration_mail(self.request, email, next_url=self.next_url, + ou=self.ou, **self.token) + self.request.session['registered_email'] = email + return utils.redirect(self.request, 'registration_complete', params={REDIRECT_FIELD_NAME: self.next_url}) + + def get_context_data(self, **kwargs): + context = super(BaseRegistrationView, self).get_context_data(**kwargs) + parameters = {'request': self.request, + 'context': context} + blocks = [utils.get_authenticator_method(authenticator, 'registration', parameters) + for authenticator in utils.get_backends('AUTH_FRONTENDS')] + context['frontends'] = collections.OrderedDict((block['id'], block) + for block in blocks if block) + return context + + +class RegistrationView(cbv.ValidateCSRFMixin, BaseRegistrationView): + pass + + +class RegistrationCompletionView(CreateView): + model = get_user_model() + success_url = 'auth_homepage' + + def get_template_names(self): + if self.users and 'create' not in self.request.GET: + return ['registration/registration_completion_choose.html'] + else: + return ['registration/registration_completion_form.html'] + + def get_success_url(self): + try: + redirect_url, next_field = app_settings.A2_REGISTRATION_REDIRECT + except Exception: + redirect_url = app_settings.A2_REGISTRATION_REDIRECT + next_field = REDIRECT_FIELD_NAME + + if self.token and self.token.get(REDIRECT_FIELD_NAME): + url = self.token[REDIRECT_FIELD_NAME] + if redirect_url: + url = utils.make_url(redirect_url, params={next_field: url}) + else: + if redirect_url: + url = redirect_url + else: + url = utils.make_url(self.success_url) + return url + + def dispatch(self, request, *args, **kwargs): + self.token = request.token + self.authentication_method = self.token.get('authentication_method', 'email') + self.email = request.token['email'] + if 'ou' in self.token: + self.ou = OU.objects.get(pk=self.token['ou']) + else: + self.ou = get_default_ou() + self.users = User.objects.filter(email__iexact=self.email) \ + .order_by('date_joined') + if self.ou: + self.users = self.users.filter(ou=self.ou) + self.email_is_unique = app_settings.A2_EMAIL_IS_UNIQUE \ + or app_settings.A2_REGISTRATION_EMAIL_IS_UNIQUE + if self.ou: + self.email_is_unique |= self.ou.email_is_unique + self.init_fields_labels_and_help_texts() + # if registration is done during an SSO add the service to the registration event + self.service = self.token.get(constants.SERVICE_FIELD_NAME) + return super(RegistrationCompletionView, self) \ + .dispatch(request, *args, **kwargs) + + def init_fields_labels_and_help_texts(self): + attributes = models.Attribute.objects.filter( + asked_on_registration=True) + default_fields = attributes.values_list('name', flat=True) + required_fields = models.Attribute.objects.filter(required=True) \ + .values_list('name', flat=True) + fields, labels = utils.get_fields_and_labels( + app_settings.A2_REGISTRATION_FIELDS, + default_fields, + app_settings.A2_REGISTRATION_REQUIRED_FIELDS, + app_settings.A2_REQUIRED_FIELDS, + models.Attribute.objects.filter(required=True).values_list('name', flat=True)) + help_texts = {} + if app_settings.A2_REGISTRATION_FORM_USERNAME_LABEL: + labels['username'] = app_settings.A2_REGISTRATION_FORM_USERNAME_LABEL + if app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT: + help_texts['username'] = \ + app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT + required = list(app_settings.A2_REGISTRATION_REQUIRED_FIELDS) + \ + list(required_fields) + if 'email' in fields: + fields.remove('email') + for field in self.token.get('skip_fields') or []: + if field in fields: + fields.remove(field) + self.fields = fields + self.labels = labels + self.required = required + self.help_texts = help_texts + + def get_form_class(self): + if not self.token.get('valid_email', True): + self.fields.append('email') + self.required.append('email') + form_class = registration_forms.RegistrationCompletionForm + if self.token.get('no_password', False): + form_class = registration_forms.RegistrationCompletionFormNoPassword + form_class = profile_forms.modelform_factory( + self.model, + form=form_class, + fields=self.fields, + labels=self.labels, + required=self.required, + help_texts=self.help_texts) + if 'username' in self.fields and app_settings.A2_REGISTRATION_FORM_USERNAME_REGEX: + # Keep existing field label and help_text + old_field = form_class.base_fields['username'] + field = CharField( + max_length=256, + label=old_field.label, + help_text=old_field.help_text, + validators=[validators.UsernameValidator()]) + form_class = type('RegistrationForm', (form_class,), {'username': field}) + return form_class + + def get_form_kwargs(self, **kwargs): + '''Initialize mail from token''' + kwargs = super(RegistrationCompletionView, self).get_form_kwargs(**kwargs) + if 'ou' in self.token: + ou = get_object_or_404(OU, id=self.token['ou']) + else: + ou = get_default_ou() + + attributes = {'email': self.email, 'ou': ou} + for key in self.token: + if key in app_settings.A2_PRE_REGISTRATION_FIELDS: + attributes[key] = self.token[key] + logger.debug(u'attributes %s', attributes) + + prefilling_list = utils.accumulate_from_backends(self.request, 'registration_form_prefill') + logger.debug(u'prefilling_list %s', prefilling_list) + # Build a single meaningful prefilling with sets of values + prefilling = {} + for p in prefilling_list: + for name, values in p.items(): + if name in self.fields: + prefilling.setdefault(name, set()).update(values) + logger.debug(u'prefilling %s', prefilling) + + for name, values in prefilling.items(): + attributes[name] = ' '.join(values) + logger.debug(u'attributes with prefilling %s', attributes) + + if self.token.get('user_id'): + kwargs['instance'] = User.objects.get(id=self.token.get('user_id')) + else: + init_kwargs = {} + for key in ('email', 'first_name', 'last_name', 'ou'): + if key in attributes: + init_kwargs[key] = attributes[key] + kwargs['instance'] = get_user_model()(**init_kwargs) + + return kwargs + + def get_form(self, form_class=None): + form = super(RegistrationCompletionView, self).get_form(form_class=form_class) + hooks.call_hooks('front_modify_form', self, form) + return form + + def get_context_data(self, **kwargs): + ctx = super(RegistrationCompletionView, self).get_context_data(**kwargs) + ctx['token'] = self.token + ctx['users'] = self.users + ctx['email'] = self.email + ctx['email_is_unique'] = self.email_is_unique + ctx['create'] = 'create' in self.request.GET + return ctx + + def get(self, request, *args, **kwargs): + if len(self.users) == 1 and self.email_is_unique: + # Found one user, EMAIL is unique, log her in + utils.simulate_authentication( + request, self.users[0], + method=self.authentication_method, + service_slug=self.service) + return utils.redirect(request, self.get_success_url()) + confirm_data = self.token.get('confirm_data', False) + + if confirm_data == 'required': + fields_to_confirm = self.required + else: + fields_to_confirm = self.fields + if (all(field in self.token for field in fields_to_confirm) + and (not confirm_data or confirm_data == 'required')): + # We already have every fields + form_kwargs = self.get_form_kwargs() + form_class = self.get_form_class() + data = self.token + if 'password' in data: + data['password1'] = data['password'] + data['password2'] = data['password'] + del data['password'] + form_kwargs['data'] = data + form = form_class(**form_kwargs) + if form.is_valid(): + user = form.save() + return self.registration_success(request, user, form) + self.get_form = lambda *args, **kwargs: form + return super(RegistrationCompletionView, self).get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + if self.users and self.email_is_unique: + # email is unique, users already exist, creating a new one is forbidden ! + return utils.redirect( + request, request.resolver_match.view_name, args=self.args, + kwargs=self.kwargs) + if 'uid' in request.POST: + uid = request.POST['uid'] + for user in self.users: + if str(user.id) == uid: + utils.simulate_authentication( + request, user, + method=self.authentication_method, + service_slug=self.service) + return utils.redirect(request, self.get_success_url()) + return super(RegistrationCompletionView, self).post(request, *args, **kwargs) + + def form_valid(self, form): + + # remove verified fields from form, this allows an authentication + # method to provide verified data fields and to present it to the user, + # while preventing the user to modify them. + for av in models.AttributeValue.objects.with_owner(form.instance): + if av.verified and av.attribute.name in form.fields: + del form.fields[av.attribute.name] + + if ('email' in self.request.POST + and ('email' not in self.token or self.request.POST['email'] != self.token['email']) + and not self.token.get('skip_email_check')): + # If an email is submitted it must be validated or be the same as in the token + data = form.cleaned_data + data['no_password'] = self.token.get('no_password', False) + utils.send_registration_mail( + self.request, + ou=self.ou, + next_url=self.get_success_url(), + **data) + self.request.session['registered_email'] = form.cleaned_data['email'] + return utils.redirect(self.request, 'registration_complete') + super(RegistrationCompletionView, self).form_valid(form) + return self.registration_success(self.request, form.instance, form) + + def registration_success(self, request, user, form): + hooks.call_hooks('event', name='registration', user=user, form=form, view=self, + authentication_method=self.authentication_method, + token=request.token, service=self.service) + utils.simulate_authentication( + request, user, + method=self.authentication_method, + service_slug=self.service) + messages.info(self.request, _('You have just created an account.')) + self.send_registration_success_email(user) + return utils.redirect(request, self.get_success_url()) + + def send_registration_success_email(self, user): + if not user.email: + return + + template_names = [ + 'authentic2/registration_success' + ] + login_url = self.request.build_absolute_uri(settings.LOGIN_URL) + utils.send_templated_mail(user, template_names=template_names, + context={ + 'user': user, + 'email': user.email, + 'site': self.request.get_host(), + 'login_url': login_url, + }, + request=self.request) + + +class DeleteView(FormView): + template_name = 'authentic2/accounts_delete.html' + success_url = reverse_lazy('auth_logout') + title = _('Delete account') + + def dispatch(self, request, *args, **kwargs): + if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT: + return utils.redirect(request, '..') + return super(DeleteView, self).dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + if 'cancel' in request.POST: + return utils.redirect(request, 'account_management') + return super(DeleteView, self).post(request, *args, **kwargs) + + def get_form_class(self): + if self.request.user.has_usable_password(): + return profile_forms.DeleteAccountForm + return Form + + def get_form_kwargs(self, **kwargs): + kwargs = super(DeleteView, self).get_form_kwargs(**kwargs) + if self.request.user.has_usable_password(): + kwargs['user'] = self.request.user + return kwargs + + def form_valid(self, form): + utils.send_account_deletion_mail(self.request, self.request.user) + models.DeletedUser.objects.delete_user(self.request.user) + self.request.user.email += '#%d' % random.randint(1, 10000000) + self.request.user.email_verified = False + self.request.user.save(update_fields=['email', 'email_verified']) + logger.info(u'deletion of account %s requested', self.request.user) + hooks.call_hooks('event', name='delete-account', user=self.request.user) + messages.info(self.request, + _('Your account has been scheduled for deletion. You cannot use it anymore.')) + return super(DeleteView, self).form_valid(form) + +registration_completion = valid_token(RegistrationCompletionView.as_view()) + + +class RegistrationCompleteView(TemplateView): + template_name = 'registration/registration_complete.html' + + def get_context_data(self, **kwargs): + kwargs['next_url'] = utils.select_next_url(self.request, settings.LOGIN_REDIRECT_URL) + return super(RegistrationCompleteView, self).get_context_data( + account_activation_days=settings.ACCOUNT_ACTIVATION_DAYS, + **kwargs) + + +registration_complete = RegistrationCompleteView.as_view() + + +@sensitive_post_parameters() +@login_required +@decorators.setting_enabled('A2_REGISTRATION_CAN_CHANGE_PASSWORD') +def password_change(request, *args, **kwargs): + kwargs['password_change_form'] = passwords_forms.PasswordChangeForm + post_change_redirect = kwargs.pop('post_change_redirect', None) + if 'next_url' in request.POST and request.POST['next_url']: + post_change_redirect = request.POST['next_url'] + elif REDIRECT_FIELD_NAME in request.GET: + post_change_redirect = request.GET[REDIRECT_FIELD_NAME] + elif post_change_redirect is None: + post_change_redirect = reverse('account_management') + if not utils.user_can_change_password(request=request): + messages.warning(request, _('Password change is forbidden')) + return utils.redirect(request, post_change_redirect) + if 'cancel' in request.POST: + return utils.redirect(request, post_change_redirect) + kwargs['post_change_redirect'] = post_change_redirect + extra_context = kwargs.setdefault('extra_context', {}) + extra_context['view'] = password_change + extra_context[REDIRECT_FIELD_NAME] = post_change_redirect + if not request.user.has_usable_password(): + kwargs['password_change_form'] = passwords_forms.SetPasswordForm + response = dj_password_change(request, *args, **kwargs) + if isinstance(response, HttpResponseRedirect): + hooks.call_hooks('event', name='change-password', user=request.user, request=request) + messages.info(request, _('Password changed')) + return response +password_change.title = _('Password Change') +password_change.do_not_call_in_templates = True + + +def notimplemented_view(request): + raise NotImplementedError -- 2.20.1