Projet

Général

Profil

0001-accounts-send-validation-email-before-self-triggered.patch

Paul Marillonnet, 05 août 2019 16:25

Télécharger (19,3 ko)

Voir les différences:

Subject: [PATCH] accounts: send validation email before self-triggered account
 deletion (#27823)

 src/authentic2/app_settings.py                |  3 +
 .../account_delete_notification_subject.txt   |  2 +-
 .../account_deletion_code_body.html           | 13 +++
 .../authentic2/account_deletion_code_body.txt |  8 ++
 .../account_deletion_code_subject.txt         |  1 +
 ...e.html => accounts_delete_validation.html} |  8 +-
 .../authentic2/accounts_plain_message.html    | 10 ++
 src/authentic2/templatetags/__init__.py       |  0
 src/authentic2/templatetags/authentic2.py     |  7 ++
 src/authentic2/urls.py                        |  3 +
 src/authentic2/utils.py                       | 29 ++++++
 src/authentic2/views.py                       | 91 +++++++++++++------
 tests/test_views.py                           | 82 ++++++++++++++++-
 13 files changed, 222 insertions(+), 35 deletions(-)
 create mode 100644 src/authentic2/templates/authentic2/account_deletion_code_body.html
 create mode 100644 src/authentic2/templates/authentic2/account_deletion_code_body.txt
 create mode 100644 src/authentic2/templates/authentic2/account_deletion_code_subject.txt
 rename src/authentic2/templates/authentic2/{accounts_delete.html => accounts_delete_validation.html} (66%)
 create mode 100644 src/authentic2/templates/authentic2/accounts_plain_message.html
 create mode 100644 src/authentic2/templatetags/__init__.py
 create mode 100644 src/authentic2/templatetags/authentic2.py
src/authentic2/app_settings.py
278 278
    A2_EMAIL_CHANGE_TOKEN_LIFETIME=Setting(
279 279
        default=7200,
280 280
        definition='Lifetime in seconds of the token sent to verify email adresses'),
281
    A2_DELETION_REQUEST_LIFETIME=Setting(
282
        default=48*3600,
283
        definition='Lifetime in seconds of the user account deletion request'),
281 284
    A2_REDIRECT_WHITELIST=Setting(
282 285
        default=(),
283 286
        definition='List of origins which are authorized to ask for redirection.'),
src/authentic2/templates/authentic2/account_delete_notification_subject.txt
1
{% load i18n %}{% autoescape off %}{% blocktrans %}Account deletion request on {{ site }}{% endblocktrans %}{% endautoescape %}
1
{% load i18n %}{% autoescape off %}{% blocktrans %}Account deletion on {{ site }}{% endblocktrans %}{% endautoescape %}
src/authentic2/templates/authentic2/account_deletion_code_body.html
1
{% load i18n %}
2
<html>
3
  <body style="max-width: 90ex">
4
        <p>{% blocktrans %}{{ full_name }},{% endblocktrans %}</p>
5
        <p>
6
{% blocktrans %}
7
Please click on {{ deletion_url }} if you want to validate your account deletion request on {{ site }}.
8
If so, all related data will be deleted in the next few hours.
9
You won't be able to log in with this account anymore.
10
{% endblocktrans %}
11
        </p>
12
  </body>
13
</html>
src/authentic2/templates/authentic2/account_deletion_code_body.txt
1
{% load i18n %}{% autoescape off %}{% blocktrans %}{{ full_name }},{% endblocktrans %}
2

  
3
{% blocktrans %}
4
Please click on {{ deletion_url }} if you want to validate your account deletion request on {{ site }}.
5
If so, all related data will be deleted in the next few hours.
6
You won't be able to log in with this account anymore.
7
{% endblocktrans %}
8
{% endautoescape %}
src/authentic2/templates/authentic2/account_deletion_code_subject.txt
1
{% load i18n %}{% autoescape off %}{% blocktrans %}Validate account deletion request on {{ site }}{% endblocktrans %}{% endautoescape %}
src/authentic2/templates/authentic2/accounts_delete.html → src/authentic2/templates/authentic2/accounts_delete_validation.html
1 1
{% extends "authentic2/base-page.html" %}
2 2
{% load i18n %}
3
{% load authentic2 %}
3 4

  
4 5
{% block page-title %}
5 6
  {{ block.super }} - {{ view.title }}
......
15 16
{% block content %}
16 17
<form method="post">
17 18
  {% csrf_token %}
18
  <p>{% trans "Delete my account and all my personal datas ?" %}</p>
19
  {{ form.as_p }}
19
  <p>
20
  {% blocktrans with first_name=user.first_name last_name=user.last_name possessive=user.last_name|endswith:"s"|yesno:"','s" %}
21
  Delete {{ first_name }} {{ last_name }}{{ possessive }} account and all related personal data?
22
  {% endblocktrans %}
23
  </p>
20 24
  <button class="delete-button" name="submit">{% trans "Delete" %}</button>
21 25
  <button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
22 26
</form>
src/authentic2/templates/authentic2/accounts_plain_message.html
1
{% extends "authentic2/base-page.html" %}
2
{% load i18n %}
3

  
4
{% block page-title %}
5
  {{ block.super }} - {% trans "Account deletion" %}
6
{% endblock %}
7

  
8
{% block content %}
9
  <p>{{ message }}</p>
10
{% endblock %}
src/authentic2/templatetags/authentic2.py
1
from django import template
2

  
3
register = template.Library()
4

  
5
@register.filter
6
def endswith(value, suffix):
7
    return value.endswith(suffix)
src/authentic2/urls.py
44 44
    url(r'^delete/$',
45 45
        login_required(views.DeleteView.as_view()),
46 46
        name='delete_account'),
47
    url(r'validate-deletion/(?P<deletion_token>[\w: -]+)/$',
48
        views.ValidateDeletionView.as_view(),
49
        name='validate_deletion'),
47 50
    url(r'^logged-in/$',
48 51
        views.logged_in,
49 52
        name='logged-in'),
src/authentic2/utils.py
670 670
    return activate_url
671 671

  
672 672

  
673
def build_deletion_url(request, **kwargs):
674
    data = kwargs.copy()
675
    data['user_pk'] = request.user.pk
676
    deletion_token = signing.dumps(data)
677
    delete_url = request.build_absolute_uri(
678
            reverse('validate_deletion', kwargs={'deletion_token': deletion_token}))
679
    return delete_url
680

  
681

  
673 682
def send_registration_mail(request, email, ou, template_names=None, next_url=None, context=None,
674 683
                           **kwargs):
675 684
    '''Send a registration mail to an user. All given kwargs will be used
......
716 725
                registration_url)
717 726

  
718 727

  
728
def send_account_deletion_code(request, user):
729
    '''Send an account deletion notification code to a user.
730

  
731
       Can raise an smtplib.SMTPException
732
    '''
733
    logger = logging.getLogger(__name__)
734
    deletion_url = build_deletion_url(request)
735
    context = {
736
        'full_name': request.user.get_full_name(),
737
        'user': request.user,
738
        'site': request.get_host(),
739
        'deletion_url': deletion_url}
740
    template_names = [
741
        'authentic2/account_deletion_code']
742
    if user.ou:
743
        template_names.insert(0, 'authentic2/account_deletion_code_%s' % user.ou.slug)
744
    send_templated_mail(user.email, template_names, context, request=request)
745
    logger.info(u'account deletion code sent to %s', user.email)
746

  
747

  
719 748
def send_account_deletion_mail(request, user):
720 749
    '''Send an account deletion notification mail to a user.
721 750

  
src/authentic2/views.py
1060 1060
                                  request=self.request)
1061 1061

  
1062 1062

  
1063
class DeleteView(FormView):
1064
    template_name = 'authentic2/accounts_delete.html'
1065
    success_url = reverse_lazy('auth_logout')
1066
    title = _('Delete account')
1063
registration_completion = valid_token(RegistrationCompletionView.as_view())
1064

  
1067 1065

  
1066
class DeleteView(View):
1068 1067
    def dispatch(self, request, *args, **kwargs):
1069 1068
        if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT:
1070 1069
            return utils.redirect(request, '..')
1071 1070
        return super(DeleteView, self).dispatch(request, *args, **kwargs)
1072 1071

  
1073
    def post(self, request, *args, **kwargs):
1074
        if 'cancel' in request.POST:
1075
            return utils.redirect(request, 'account_management')
1076
        return super(DeleteView, self).post(request, *args, **kwargs)
1072
    def get(self, request, *args, **kwargs):
1073
        utils.send_account_deletion_code(self.request, self.request.user)
1074
        messages.info(request,
1075
                _("An account deletion validation email has been sent to your email address."))
1076
        return utils.redirect(request, 'account_management')
1077 1077

  
1078
    def get_form_class(self):
1079
        if self.request.user.has_usable_password():
1080
            return profile_forms.DeleteAccountForm
1081
        return Form
1082 1078

  
1083
    def get_form_kwargs(self, **kwargs):
1084
        kwargs = super(DeleteView, self).get_form_kwargs(**kwargs)
1085
        if self.request.user.has_usable_password():
1086
            kwargs['user'] = self.request.user
1087
        return kwargs
1079
class ValidateDeletionView(TemplateView):
1080
    template_name = 'authentic2/accounts_delete_validation.html'
1081
    title = _('Confirm account deletion')
1082
    user = None
1088 1083

  
1089
    def form_valid(self, form):
1090
        utils.send_account_deletion_mail(self.request, self.request.user)
1091
        models.DeletedUser.objects.delete_user(self.request.user)
1092
        self.request.user.email += '#%d' % random.randint(1, 10000000)
1093
        self.request.user.email_verified = False
1094
        self.request.user.save(update_fields=['email', 'email_verified'])
1095
        logger.info(u'deletion of account %s requested', self.request.user)
1096
        hooks.call_hooks('event', name='delete-account', user=self.request.user)
1097
        message_template = loader.get_template('authentic2/account_deletion_message.html')
1098
        messages.info(self.request, message_template.render(request=self.request))
1099
        return super(DeleteView, self).form_valid(form)
1084
    def dispatch(self, request, *args, **kwargs):
1085
        error = None
1086
        try:
1087
            deletion_token = signing.loads(kwargs['deletion_token'],
1088
                    max_age=app_settings.A2_DELETION_REQUEST_LIFETIME)
1089
            user_pk = deletion_token['user_pk']
1090
            self.user = get_user_model().objects.get(pk=user_pk)
1091
            # A user account wont be deactived twice
1092
            if not self.user.is_active:
1093
                raise ValidationError(
1094
                    _('This account had previously been deactivated and will be deleted soon.'))
1095
            logger.info('user %s confirmed the deletion of their own account', self.user)
1096
        except signing.SignatureExpired:
1097
            error = _('The account deletion request is too old, try again')
1098
        except signing.BadSignature:
1099
            error = _('The account deletion request is invalid, try again')
1100
        except ValueError:
1101
            error = _('The account deletion request was not on this site, try again')
1102
        except ValidationError as e:
1103
            error = e.message
1104
        except get_user_model().DoesNotExist:
1105
            error = _('This account has previously been deleted.')
1106

  
1107
        if error:
1108
            return render(request, 'authentic2/accounts_plain_message.html',
1109
                    context={'message': error})
1110
        return super(ValidateDeletionView, self).dispatch(request, *args, **kwargs)
1100 1111

  
1101
registration_completion = valid_token(RegistrationCompletionView.as_view())
1112
    def post(self, request, *args, **kwargs):
1113
        if 'cancel' not in request.POST:
1114
            utils.send_account_deletion_mail(self.request, self.user)
1115
            models.DeletedUser.objects.delete_user(self.user)
1116
            self.user.email += '#%d' % random.randint(1, 10000000)
1117
            self.user.email_verified = False
1118
            self.user.save(update_fields=['email', 'email_verified'])
1119
            logger.info(u'deletion of account %s performed', self.user)
1120
            hooks.call_hooks('event', name='delete-account', user=self.user)
1121
            if self.user == request.user:
1122
                # No validation message displayed, as the user will surely
1123
                # notice their own account deletion...
1124
                return utils.redirect(request, 'auth_logout')
1125
            # No real use for cancel_url or next_url here, assuming the link
1126
            # has been received by email.
1127
            return render(request, 'authentic2/accounts_plain_message.html',
1128
                    context={'message': 'Deletion performed.'})
1129
        return utils.redirect(request, '/')
1130

  
1131
    def get_context_data(self, **kwargs):
1132
        ctx = super(ValidateDeletionView, self).get_context_data(**kwargs)
1133
        ctx['user'] = self.user # Not necessarily the user in request
1134
        return ctx
1102 1135

  
1103 1136

  
1104 1137
class RegistrationCompleteView(TemplateView):
tests/test_views.py
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16
# authentic2
17 17

  
18
from utils import login
18
from utils import login, logout, get_link_from_mail
19 19
import pytest
20 20

  
21 21
from django.core.urlresolvers import reverse
......
41 41
    assert simple_user.is_active
42 42
    assert not len(mailoutbox)
43 43
    page = login(app, simple_user, path=reverse('delete_account'))
44
    page.form.set('password', simple_user.username)
44
    assert len(mailoutbox) == 1
45
    link = get_link_from_mail(mailoutbox[0])
46
    assert 'Validate account deletion request on testserver' == mailoutbox[0].subject
47
    assert [simple_user.email] == mailoutbox[0].to
48
    page = app.get(link)
45 49
    # FIXME: webtest does not set the Referer header, so the logout page will always ask for
46 50
    # confirmation under tests
47 51
    response = page.form.submit(name='submit').follow()
48 52
    response = response.form.submit()
49
    assert len(mailoutbox) == 1
50 53
    assert not User.objects.get(pk=simple_user.pk).is_active
54
    assert len(mailoutbox) == 2
55
    assert 'Account deletion on testserver' == mailoutbox[1].subject
56
    assert [simple_user.email] == mailoutbox[0].to
51 57
    assert urlparse(response.location).path == '/'
52 58
    response = response.follow().follow()
53 59
    assert response.request.url.endswith('/login/?next=/')
54 60

  
55 61

  
62
def test_account_delete_when_logged_out(app, simple_user, mailoutbox):
63
    assert simple_user.is_active
64
    assert not len(mailoutbox)
65
    page = login(app, simple_user, path=reverse('delete_account'))
66
    assert len(mailoutbox) == 1
67
    link = get_link_from_mail(mailoutbox[0])
68
    logout(app)
69
    page = app.get(link)
70
    assert 'Delete %s %s\'s account and all related personal data?' % (
71
            simple_user.first_name, simple_user.last_name) in page.text
72
    response = page.form.submit(name='submit')
73
    assert not User.objects.get(pk=simple_user.pk).is_active
74
    assert len(mailoutbox) == 2
75
    assert 'Account deletion on testserver' == mailoutbox[1].subject
76
    assert [simple_user.email] == mailoutbox[0].to
77
    assert "Deletion performed" in response.text
78

  
79

  
80
def test_account_delete_by_other_user(app, simple_user, user_ou1, mailoutbox):
81
    assert simple_user.is_active
82
    assert user_ou1.is_active
83
    assert not len(mailoutbox)
84
    page = login(app, simple_user, path=reverse('delete_account'))
85
    assert len(mailoutbox) == 1
86
    link = get_link_from_mail(mailoutbox[0])
87
    logout(app)
88
    login(app, user_ou1, path=reverse('account_management'))
89
    page = app.get(link)
90
    assert 'Delete %s %s\'s account and all related personal data?' % (
91
            simple_user.first_name, simple_user.last_name) in page.text
92
    response = page.form.submit(name='submit')
93
    assert not User.objects.get(pk=simple_user.pk).is_active
94
    assert User.objects.get(pk=user_ou1.pk).is_active
95
    assert "Deletion performed" in response.text
96
    assert len(mailoutbox) == 2
97
    assert 'Account deletion on testserver' == mailoutbox[1].subject
98
    assert [simple_user.email] == mailoutbox[0].to
99

  
100

  
101
def test_account_delete_fake_token(app, simple_user, mailoutbox):
102
    response = app.get(reverse('validate_deletion', kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'}))
103
    assert "The account deletion request is invalid, try again" in response.text
104

  
105

  
106
def test_account_delete_expired_token(app, simple_user, mailoutbox, freezer):
107
    freezer.move_to('2019-08-01')
108
    page = login(app, simple_user, path=reverse('delete_account'))
109
    freezer.move_to('2019-08-04') # Too late...
110
    link = get_link_from_mail(mailoutbox[0])
111
    response = app.get(link)
112
    assert "The account deletion request is too old, try again" in response.text
113

  
114

  
115
def test_account_delete_valid_token_unexistent_user(app, simple_user, mailoutbox):
116
    page = login(app, simple_user, path=reverse('delete_account'))
117
    link = get_link_from_mail(mailoutbox[0])
118
    simple_user.delete()
119
    response = app.get(link)
120
    assert 'This account has previously been deleted.' in response.text
121

  
122

  
123
def test_account_delete_valid_token_inactive_user(app, simple_user, mailoutbox):
124
    page = login(app, simple_user, path=reverse('delete_account'))
125
    link = get_link_from_mail(mailoutbox[0])
126
    simple_user.is_active = False
127
    simple_user.save()
128
    response = app.get(link)
129
    assert "This account had previously been deactivated" in response.text
130

  
131

  
56 132
def test_login_invalid_next(app):
57 133
    app.get(reverse('auth_login') + '?next=plop')
58 134

  
59
-