Projet

Général

Profil

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

Paul Marillonnet, 19 septembre 2019 17:14

Télécharger (19,5 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           | 15 +++
 .../authentic2/account_deletion_code_body.txt | 10 ++
 .../account_deletion_code_subject.txt         |  1 +
 ...lete.html => accounts_delete_request.html} |  7 +-
 .../accounts_delete_validation.html           | 27 ++++++
 src/authentic2/urls.py                        |  3 +
 src/authentic2/utils/__init__.py              | 29 ++++++
 src/authentic2/views.py                       | 90 ++++++++++++------
 tests/test_views.py                           | 91 ++++++++++++++++++-
 11 files changed, 242 insertions(+), 36 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_request.html} (74%)
 create mode 100644 src/authentic2/templates/authentic2/accounts_delete_validation.html
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 }}
8
if you want to validate your account deletion request on
9
{{ site }}.
10
If so, all related data will be deleted in the next few hours.
11
You won't be able to log in with this account anymore.
12
{% endblocktrans %}
13
        </p>
14
  </body>
15
</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 }}
5
if you want to validate your account deletion request on
6
{{ site }}.
7
If so, all related data will be deleted in the next few hours.
8
You won't be able to log in with this account anymore.
9
{% endblocktrans %}
10
{% 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_request.html
15 15
{% block content %}
16 16
<form method="post">
17 17
  {% csrf_token %}
18
  <p>{% trans "Delete my account and all my personal datas ?" %}</p>
19
  {{ form.as_p }}
20
  <button class="delete-button" name="submit">{% trans "Delete" %}</button>
18
  <p>
19
  {% trans "Send an account-deletion validation code?" %}
20
  </p>
21
  <button class="submit-button" name="submit">{% trans "Send the code" %}</button>
21 22
  <button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
22 23
</form>
23 24
{% endblock %}
src/authentic2/templates/authentic2/accounts_delete_validation.html
1
{% extends "authentic2/base-page.html" %}
2
{% load i18n %}
3

  
4
{% block page-title %}
5
  {{ block.super }} - {{ view.title }}
6
{% endblock %}
7

  
8
{% block breadcrumb %}
9
  {{ block.super }}
10
  <a href="{% url "account_management" %}">{% trans "Your account" %}</a>
11
  <a href="#">{{ view.title }}</a>
12
{% endblock %}
13

  
14

  
15
{% block content %}
16
<form method="post">
17
  {% csrf_token %}
18
  <p>
19
  {% blocktrans with full_name=user.get_full_name %}
20
  You are about to delete the account of <strong>{{ full_name }}</strong>.
21
  This will remove all related personal data and you won't be able to log in with this account anymore.
22
  {% endblocktrans %}
23
  </p>
24
  <button class="delete-button" name="delete">{% trans "Confirm deletion" %}</button>
25
  <button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
26
</form>
27
{% endblock %}
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/__init__.py
682 682
    return activate_url
683 683

  
684 684

  
685
def build_deletion_url(request, **kwargs):
686
    data = kwargs.copy()
687
    data['user_pk'] = request.user.pk
688
    deletion_token = signing.dumps(data)
689
    delete_url = request.build_absolute_uri(
690
            reverse('validate_deletion', kwargs={'deletion_token': deletion_token}))
691
    return delete_url
692

  
693

  
685 694
def send_registration_mail(request, email, ou, template_names=None, next_url=None, context=None,
686 695
                           **kwargs):
687 696
    '''Send a registration mail to an user. All given kwargs will be used
......
728 737
                registration_url)
729 738

  
730 739

  
740
def send_account_deletion_code(request, user):
741
    '''Send an account deletion notification code to a user.
742

  
743
       Can raise an smtplib.SMTPException
744
    '''
745
    logger = logging.getLogger(__name__)
746
    deletion_url = build_deletion_url(request)
747
    context = {
748
        'full_name': request.user.get_full_name(),
749
        'user': request.user,
750
        'site': request.get_host(),
751
        'deletion_url': deletion_url}
752
    template_names = [
753
        'authentic2/account_deletion_code']
754
    if user.ou:
755
        template_names.insert(0, 'authentic2/account_deletion_code_%s' % user.ou.slug)
756
    send_templated_mail(user.email, template_names, context, request=request)
757
    logger.info(u'account deletion code sent to %s', user.email)
758

  
759

  
731 760
def send_account_deletion_mail(request, user):
732 761
    '''Send an account deletion notification mail to a user.
733 762

  
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

  
1065

  
1066
class DeleteView(TemplateView):
1067
    template_name = 'authentic2/accounts_delete_request.html'
1068
    title = _('Request account deletion')
1067 1069

  
1068 1070
    def dispatch(self, request, *args, **kwargs):
1069 1071
        if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT:
......
1071 1073
        return super(DeleteView, self).dispatch(request, *args, **kwargs)
1072 1074

  
1073 1075
    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)
1076
        utils.send_account_deletion_code(self.request, self.request.user)
1077
        messages.info(request,
1078
                _("An account deletion validation email has been sent to your email address."))
1079
        return utils.redirect(request, 'account_management')
1077 1080

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

  
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
1082
class ValidateDeletionView(TemplateView):
1083
    template_name = 'authentic2/accounts_delete_validation.html'
1084
    title = _('Confirm account deletion')
1085
    user = None
1088 1086

  
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)
1087
    def dispatch(self, request, *args, **kwargs):
1088
        try:
1089
            deletion_token = signing.loads(kwargs['deletion_token'],
1090
                    max_age=app_settings.A2_DELETION_REQUEST_LIFETIME)
1091
            user_pk = deletion_token['user_pk']
1092
            self.user = get_user_model().objects.get(pk=user_pk)
1093
            # A user account wont be deactived twice
1094
            if not self.user.is_active:
1095
                raise ValidationError(
1096
                    _('This account had previously been deactivated and will be deleted soon.'))
1097
            logger.info('user %s confirmed the deletion of their own account', self.user)
1098
        except signing.SignatureExpired:
1099
            error = _('The account deletion request is too old, try again')
1100
        except signing.BadSignature:
1101
            error = _('The account deletion request is invalid, try again')
1102
        except ValueError:
1103
            error = _('The account deletion request was not on this site, try again')
1104
        except ValidationError as e:
1105
            error = e.message
1106
        except get_user_model().DoesNotExist:
1107
            error = _('This account has previously been deleted.')
1108
        else:
1109
            return super(ValidateDeletionView, self).dispatch(request, *args, **kwargs)
1110
        messages.error(request, error)
1111
        return utils.redirect(request, 'auth_homepage')
1100 1112

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

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

  
1103 1137

  
1104 1138
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
22
from django.utils.html import escape
22 23
from django.utils.six.moves.urllib.parse import urlparse
23 24

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

  
55 63

  
64
def test_account_delete_when_logged_out(app, simple_user, mailoutbox):
65
    assert simple_user.is_active
66
    assert not len(mailoutbox)
67
    page = login(app, simple_user, path=reverse('delete_account'))
68
    page.form.submit(name='submit').follow()
69
    assert len(mailoutbox) == 1
70
    link = get_link_from_mail(mailoutbox[0])
71
    logout(app)
72
    page = app.get(link)
73
    assert 'You are about to delete the account of <strong>%s</strong>.' % \
74
            escape(simple_user.get_full_name()) in page.text
75
    response = page.form.submit(name='delete').follow().follow()
76
    assert not User.objects.get(pk=simple_user.pk).is_active
77
    assert len(mailoutbox) == 2
78
    assert 'Account deletion on testserver' == mailoutbox[1].subject
79
    assert [simple_user.email] == mailoutbox[0].to
80
    assert "Deletion performed" in response.text
81

  
82

  
83
def test_account_delete_by_other_user(app, simple_user, user_ou1, mailoutbox):
84
    assert simple_user.is_active
85
    assert user_ou1.is_active
86
    assert not len(mailoutbox)
87
    page = login(app, simple_user, path=reverse('delete_account'))
88
    page.form.submit(name='submit').follow()
89
    assert len(mailoutbox) == 1
90
    link = get_link_from_mail(mailoutbox[0])
91
    logout(app)
92
    login(app, user_ou1, path=reverse('account_management'))
93
    page = app.get(link)
94
    assert 'You are about to delete the account of <strong>%s</strong>.' % \
95
            escape(simple_user.get_full_name()) in page.text
96
    response = page.form.submit(name='delete').follow()
97
    assert not User.objects.get(pk=simple_user.pk).is_active
98
    assert User.objects.get(pk=user_ou1.pk).is_active
99
    assert "Deletion performed" in response.text
100
    assert len(mailoutbox) == 2
101
    assert 'Account deletion on testserver' == mailoutbox[1].subject
102
    assert [simple_user.email] == mailoutbox[0].to
103

  
104

  
105
def test_account_delete_fake_token(app, simple_user, mailoutbox):
106
    response = app.get(reverse('validate_deletion', kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'})).follow().follow()
107
    assert "The account deletion request is invalid, try again" in response.text
108

  
109

  
110
def test_account_delete_expired_token(app, simple_user, mailoutbox, freezer):
111
    freezer.move_to('2019-08-01')
112
    page = login(app, simple_user, path=reverse('delete_account'))
113
    page.form.submit(name='submit').follow()
114
    freezer.move_to('2019-08-04') # Too late...
115
    link = get_link_from_mail(mailoutbox[0])
116
    response = app.get(link).follow()
117
    assert "The account deletion request is too old, try again" in response.text
118

  
119

  
120
def test_account_delete_valid_token_unexistent_user(app, simple_user, mailoutbox):
121
    page = login(app, simple_user, path=reverse('delete_account'))
122
    page.form.submit(name='submit').follow()
123
    link = get_link_from_mail(mailoutbox[0])
124
    simple_user.delete()
125
    response = app.get(link).follow().follow()
126
    assert 'This account has previously been deleted.' in response.text
127

  
128

  
129
def test_account_delete_valid_token_inactive_user(app, simple_user, mailoutbox):
130
    page = login(app, simple_user, path=reverse('delete_account'))
131
    page.form.submit(name='submit').follow()
132
    link = get_link_from_mail(mailoutbox[0])
133
    simple_user.is_active = False
134
    simple_user.save()
135
    response = app.get(link).follow()
136
    assert "This account had previously been deactivated" in response.text
137

  
138

  
56 139
def test_login_invalid_next(app):
57 140
    app.get(reverse('auth_login') + '?next=plop')
58 141

  
59
-