Projet

Général

Profil

0003-views-require-authentication-for-deleting-account-wi.patch

Benjamin Dauvergne, 26 janvier 2022 16:01

Télécharger (24,7 ko)

Voir les différences:

Subject: [PATCH 3/3] views: require authentication for deleting account
 without a verified email (#28853)

 src/authentic2/authenticators.py              |   8 +
 src/authentic2/forms/profile.py               |  15 -
 .../authentic2/accounts_delete_request.html   |   4 +-
 src/authentic2/views.py                       |  80 +++++-
 src/authentic2_auth_fc/authenticators.py      |   1 +
 tests/conftest.py                             |   6 +-
 tests/test_views.py                           | 257 ++++++++++--------
 7 files changed, 231 insertions(+), 140 deletions(-)
src/authentic2/authenticators.py
36 36

  
37 37

  
38 38
class BaseAuthenticator:
39
    how = ()
40

  
39 41
    def __init__(self, show_condition=None, **kwargs):
40 42
        self.show_condition = show_condition
41 43

  
......
60 62

  
61 63
class LoginPasswordAuthenticator(BaseAuthenticator):
62 64
    id = 'password'
65
    how = ['password', 'password-on-https']
63 66
    submit_name = 'login-password-submit'
64 67
    priority = 0
65 68

  
......
119 122
        form = authentication_forms.AuthenticationForm(
120 123
            request=request, data=data, initial=initial, preferred_ous=preferred_ous
121 124
        )
125
        if request.user.is_authenticated and request.login_token.get('action'):
126
            form.initial['username'] = request.user.username or request.user.email
127
            form.fields['username'].widget.attrs.pop('autofocus', None)
128
            form.fields['username'].widget.attrs['readonly'] = 'true'
129
            form.fields['password'].widget.attrs['autofocus'] = 'autofocus'
122 130
        if app_settings.A2_ACCEPT_EMAIL_AUTHENTICATION:
123 131
            form.fields['username'].label = _('Username or email')
124 132
        if app_settings.A2_USERNAME_LABEL:
src/authentic2/forms/profile.py
17 17

  
18 18
from django import forms
19 19
from django.forms.models import modelform_factory as dj_modelform_factory
20
from django.utils.translation import ugettext
21 20
from django.utils.translation import ugettext_lazy as _
22 21

  
23 22
from authentic2 import app_settings, models
......
28 27
from .utils import NextUrlFormMixin
29 28

  
30 29

  
31
class DeleteAccountForm(forms.Form):
32
    password = forms.CharField(widget=forms.PasswordInput, label=_("Password"))
33

  
34
    def __init__(self, *args, **kwargs):
35
        self.user = kwargs.pop('user')
36
        super().__init__(*args, **kwargs)
37

  
38
    def clean_password(self):
39
        password = self.cleaned_data.get('password')
40
        if password and not self.user.check_password(password):
41
            raise forms.ValidationError(ugettext('Password is invalid'))
42
        return password
43

  
44

  
45 30
class EmailChangeFormNoPassword(forms.Form):
46 31
    email = ValidatedEmailField(label=_('New email'))
47 32

  
src/authentic2/templates/authentic2/accounts_delete_request.html
20 20
  Do you really want to delete your account?
21 21
  {% endblocktrans %}
22 22
  </p>
23
  {% if user.email_verified %}
23 24
  <p>
24 25
  {% blocktrans trimmed %}
25 26
  A validation message will be sent to {{ email }}. You will have to visit the
26 27
  link in this email in order to complete the deletion process.
27 28
  {% endblocktrans %}
28 29
  </p>
29
  <button class="submit-button" name="submit">{% trans "Send message" %}</button>
30
  {% endif %}
31
  <button class="submit-button" name="submit">{% if user.email_verified %}{% trans "Send message" %}{% else %}{% trans "Delete account" %}{% endif %}</button>
30 32
  <button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
31 33
</form>
32 34
{% endblock %}
src/authentic2/views.py
17 17
import collections
18 18
import logging
19 19
import re
20
import time
20 21
from email.utils import parseaddr
21 22

  
22 23
from django import shortcuts
......
288 289
def login(request, template_name='authentic2/login.html', redirect_field_name=REDIRECT_FIELD_NAME):
289 290
    """Displays the login form and handles the login action."""
290 291

  
292
    request.login_token = token = {}
293
    if 'token' in request.GET:
294
        try:
295
            token.update(signing.loads(request.GET['token']))
296
            logger.debug('login: got token %s', token)
297
        except (signing.SignatureExpired, signing.BadSignature, ValueError):
298
            logger.warning('login: bad token')
299
    methods = token.get('methods', [])
300

  
291 301
    # redirect user to homepage if already connected, if setting
292 302
    # A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE is True
293 303
    if request.user.is_authenticated and app_settings.A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE:
......
326 336

  
327 337
    # Create blocks
328 338
    for authenticator in authenticators:
339
        if methods and not (authenticator.id in methods or set(authenticator.how) & set(methods)):
340
            continue
329 341
        # Legacy API
330 342
        if not hasattr(authenticator, 'login'):
331 343
            fid = authenticator.id
......
383 395
        block = blocks[0]
384 396
        authenticator = block['authenticator']
385 397
        if hasattr(authenticator, 'autorun'):
398
            if 'message' in token:
399
                messages.info(request, token['message'])
386 400
            return authenticator.autorun(request, block['id'])
387 401

  
388 402
    # Old frontends API
......
409 423
            redirect_field_name: redirect_to,
410 424
        }
411 425
    )
426
    if 'message' in token:
427
        messages.info(request, token['message'])
412 428
    return render(request, template_name, context)
413 429

  
414 430

  
......
1322 1338
class AccountDeleteView(TemplateView):
1323 1339
    template_name = 'authentic2/accounts_delete_request.html'
1324 1340
    title = _('Request account deletion')
1341
    last_authentication_max_age = 600  # 10 minutes
1325 1342

  
1326 1343
    def dispatch(self, request, *args, **kwargs):
1327 1344
        if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT:
1328 1345
            return utils_misc.redirect(request, '..')
1346
        if not self.request.user.email_verified and not self.has_recent_authentication():
1347
            methods = [event['how'] for event in utils_misc.get_authentication_events(request)]
1348
            return utils_misc.login_require(
1349
                request,
1350
                token={
1351
                    'action': 'account-delete',
1352
                    'message': _('You must re-authenticate to delete your account.'),
1353
                    'methods': methods,
1354
                },
1355
            )
1329 1356
        return super().dispatch(request, *args, **kwargs)
1330 1357

  
1358
    def has_recent_authentication(self):
1359
        age = time.time() - utils_misc.last_authentication_event(request=self.request)['when']
1360
        return age < self.last_authentication_max_age
1361

  
1331 1362
    def post(self, request, *args, **kwargs):
1332 1363
        if 'cancel' in request.POST:
1333 1364
            return utils_misc.redirect(request, 'account_management')
1334
        utils_misc.send_account_deletion_code(self.request, self.request.user)
1335
        messages.info(request, _("An account deletion validation email has been sent to your email address."))
1365
        if self.request.user.email_verified:
1366
            utils_misc.send_account_deletion_code(self.request, self.request.user)
1367
            messages.info(
1368
                request, _("An account deletion validation email has been sent to your email address.")
1369
            )
1370
        else:
1371
            deletion_url = utils_misc.build_deletion_url(request, prompt=False)
1372
            return logout(
1373
                request,
1374
                next_url=deletion_url,
1375
                check_referer=False,
1376
            )
1336 1377
        return utils_misc.redirect(request, 'account_management')
1337 1378

  
1338 1379
    def get_context_data(self, **kwargs):
......
1345 1386
    template_name = 'authentic2/accounts_delete_validation.html'
1346 1387
    title = _('Confirm account deletion')
1347 1388
    user = None
1389
    prompt = True
1348 1390

  
1349 1391
    def dispatch(self, request, *args, **kwargs):
1350 1392
        try:
1351 1393
            deletion_token = signing.loads(
1352 1394
                kwargs['deletion_token'], max_age=app_settings.A2_DELETION_REQUEST_LIFETIME
1353 1395
            )
1396
            self.prompt = deletion_token.get('prompt', self.prompt)
1354 1397
            user_pk = deletion_token['user_pk']
1355 1398
            self.user = get_user_model().objects.get(pk=user_pk)
1356 1399
            # A user account wont be deactived twice
......
1372 1415
        messages.error(request, error)
1373 1416
        return utils_misc.redirect(request, 'auth_homepage')
1374 1417

  
1418
    def get(self, request, *args, **kwargs):
1419
        if not self.prompt:
1420
            return self.delete_account(request)
1421
        return super().get(request, *args, **kwargs)
1422

  
1375 1423
    def post(self, request, *args, **kwargs):
1376 1424
        if 'cancel' not in request.POST:
1377
            utils_misc.send_account_deletion_mail(self.request, self.user)
1378
            logger.info('deletion of account %s performed', self.user)
1379
            hooks.call_hooks('event', name='delete-account', user=self.user)
1380
            request.journal.record('user.deletion', user=self.user)
1381
            is_deleted_user_logged = self.user == request.user
1382
            self.user.delete()
1383
            messages.info(request, _('Deletion performed.'))
1384
            # No real use for cancel_url or next_url here, assuming the link
1385
            # has been received by email. We instead redirect the user to the
1386
            # homepage.
1387
            if is_deleted_user_logged:
1388
                return logout(request, check_referer=False)
1425
            return self.delete_account(request)
1426
        return utils_misc.redirect(request, 'auth_homepage')
1427

  
1428
    def delete_account(self, request):
1429
        utils_misc.send_account_deletion_mail(self.request, self.user)
1430
        logger.info('deletion of account %s performed', self.user)
1431
        hooks.call_hooks('event', name='delete-account', user=self.user)
1432
        request.journal.record('user.deletion', user=self.user)
1433
        is_deleted_user_logged = self.user == request.user
1434
        self.user.delete()
1435
        messages.info(request, _('Deletion performed.'))
1436
        # No real use for cancel_url or next_url here, assuming the link
1437
        # has been received by email. We instead redirect the user to the
1438
        # homepage.
1439
        if is_deleted_user_logged:
1440
            return logout(request, check_referer=False)
1389 1441
        return utils_misc.redirect(request, 'auth_homepage')
1390 1442

  
1391 1443
    def get_context_data(self, **kwargs):
src/authentic2_auth_fc/authenticators.py
28 28

  
29 29
class FcAuthenticator(BaseAuthenticator):
30 30
    id = 'fc'
31
    how = ['france-connect']
31 32
    priority = -1
32 33

  
33 34
    def enabled(self):
tests/conftest.py
126 126
@pytest.fixture
127 127
def simple_user(db, ou1):
128 128
    return create_user(
129
        username='user', first_name='Jôhn', last_name='Dôe', email='user@example.net', ou=get_default_ou()
129
        username='user',
130
        first_name='Jôhn',
131
        last_name='Dôe',
132
        email='user@example.net',
133
        ou=get_default_ou(),
130 134
    )
131 135

  
132 136

  
tests/test_views.py
62 62
    assert resp.location == '/accounts/password/change/'
63 63

  
64 64

  
65
def test_account_delete(app, simple_user, mailoutbox):
66
    assert simple_user.is_active
67
    assert len(mailoutbox) == 0
68
    page = login(app, simple_user, path=reverse('delete_account'))
69
    assert simple_user.email in page.text
70
    page.form.submit(name='submit').follow()
71
    assert len(mailoutbox) == 1
72
    link = get_link_from_mail(mailoutbox[0])
73
    assert mailoutbox[0].subject == 'Validate account deletion request on testserver'
74
    assert [simple_user.email] == mailoutbox[0].to
75
    page = app.get(link)
76
    # FIXME: webtest does not set the Referer header, so the logout page will always ask for
77
    # confirmation under tests
78
    response = page.form.submit(name='delete')
79
    assert '_auth_user_id' not in app.session
80
    assert User.objects.filter(id=simple_user.id).count() == 0
81
    assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
82
    assert len(mailoutbox) == 2
83
    assert mailoutbox[1].subject == 'Account deletion on testserver'
84
    assert mailoutbox[0].to == [simple_user.email]
85
    assert "Deletion performed" in str(response)  # Set-Cookie: messages..
86
    assert urlparse(response.location).path == '/'
87

  
88

  
89
def test_account_delete_when_logged_out(app, simple_user, mailoutbox):
90
    assert simple_user.is_active
91
    assert len(mailoutbox) == 0
92
    page = login(app, simple_user, path=reverse('delete_account'))
93
    page.form.submit(name='submit').follow()
94
    assert len(mailoutbox) == 1
95
    link = get_link_from_mail(mailoutbox[0])
96
    logout(app)
97
    page = app.get(link)
98
    assert (
99
        'You are about to delete the account of <strong>%s</strong>.' % escape(simple_user.get_full_name())
100
        in page.text
101
    )
102
    response = page.form.submit(name='delete')
103
    assert User.objects.filter(id=simple_user.id).count() == 0
104
    assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
105
    assert len(mailoutbox) == 2
106
    assert mailoutbox[1].subject == 'Account deletion on testserver'
107
    assert mailoutbox[0].to == [simple_user.email]
108
    assert "Deletion performed" in str(response)  # Set-Cookie: messages..
109
    assert urlparse(response.location).path == '/'
110

  
111

  
112
def test_account_delete_by_other_user(app, simple_user, user_ou1, mailoutbox):
113
    assert simple_user.is_active
114
    assert user_ou1.is_active
115
    assert len(mailoutbox) == 0
116
    page = login(app, simple_user, path=reverse('delete_account'))
117
    page.form.submit(name='submit').follow()
118
    assert len(mailoutbox) == 1
119
    link = get_link_from_mail(mailoutbox[0])
120
    logout(app)
121
    login(app, user_ou1, path=reverse('account_management'))
122
    page = app.get(link)
123
    assert (
124
        'You are about to delete the account of <strong>%s</strong>.' % escape(simple_user.get_full_name())
125
        in page.text
126
    )
127
    response = page.form.submit(name='delete')
128
    assert app.session['_auth_user_id'] == str(user_ou1.id)
129
    assert User.objects.filter(id=simple_user.id).count() == 0
130
    assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
131
    assert len(mailoutbox) == 2
132
    assert mailoutbox[1].subject == 'Account deletion on testserver'
133
    assert mailoutbox[0].to == [simple_user.email]
134
    assert "Deletion performed" in str(response)  # Set-Cookie: messages..
135
    assert urlparse(response.location).path == '/'
136

  
137

  
138
def test_account_delete_fake_token(app, simple_user, mailoutbox):
139
    response = (
140
        app.get(reverse('validate_deletion', kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'}))
141
        .follow()
142
        .follow()
143
    )
144
    assert "The account deletion request is invalid, try again" in response.text
145

  
146

  
147
def test_account_delete_expired_token(app, simple_user, mailoutbox, freezer):
148
    freezer.move_to('2019-08-01')
149
    page = login(app, simple_user, path=reverse('delete_account'))
150
    page.form.submit(name='submit').follow()
151
    freezer.move_to('2019-08-04')  # Too late...
152
    link = get_link_from_mail(mailoutbox[0])
153
    response = app.get(link).follow()
154
    assert "The account deletion request is too old, try again" in response.text
155

  
156

  
157
def test_account_delete_valid_token_unexistent_user(app, simple_user, mailoutbox):
158
    page = login(app, simple_user, path=reverse('delete_account'))
159
    page.form.submit(name='submit').follow()
160
    link = get_link_from_mail(mailoutbox[0])
161
    simple_user.delete()
162
    response = app.get(link).follow().follow()
163
    assert 'This account has previously been deleted.' in response.text
164

  
165

  
166
def test_account_delete_valid_token_inactive_user(app, simple_user, mailoutbox):
167
    page = login(app, simple_user, path=reverse('delete_account'))
168
    page.form.submit(name='submit').follow()
169
    link = get_link_from_mail(mailoutbox[0])
170
    simple_user.is_active = False
171
    simple_user.save()
172
    response = app.get(link).maybe_follow()
173
    assert 'This account is inactive, it cannot be deleted.' in response.text
65
class TestDeleteAccountEmailVerified:
66
    @pytest.fixture
67
    def simple_user(self, simple_user):
68
        simple_user.email_verified = True
69
        simple_user.save()
70
        return simple_user
71

  
72
    def test_account_delete(self, app, simple_user, mailoutbox):
73
        assert simple_user.is_active
74
        assert len(mailoutbox) == 0
75
        page = login(app, simple_user, path=reverse('delete_account'))
76
        assert simple_user.email in page.text
77
        page.form.submit(name='submit').follow()
78
        assert len(mailoutbox) == 1
79
        link = get_link_from_mail(mailoutbox[0])
80
        assert mailoutbox[0].subject == 'Validate account deletion request on testserver'
81
        assert [simple_user.email] == mailoutbox[0].to
82
        page = app.get(link)
83
        # FIXME: webtest does not set the Referer header, so the logout page will always ask for
84
        # confirmation under tests
85
        response = page.form.submit(name='delete')
86
        assert '_auth_user_id' not in app.session
87
        assert User.objects.filter(id=simple_user.id).count() == 0
88
        assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
89
        assert len(mailoutbox) == 2
90
        assert mailoutbox[1].subject == 'Account deletion on testserver'
91
        assert mailoutbox[0].to == [simple_user.email]
92
        assert "Deletion performed" in str(response)  # Set-Cookie: messages..
93
        assert urlparse(response.location).path == '/'
94

  
95
    def test_account_delete_when_logged_out(self, app, simple_user, mailoutbox):
96
        assert simple_user.is_active
97
        assert len(mailoutbox) == 0
98
        page = login(app, simple_user, path=reverse('delete_account'))
99
        page.form.submit(name='submit').follow()
100
        assert len(mailoutbox) == 1
101
        link = get_link_from_mail(mailoutbox[0])
102
        logout(app)
103
        page = app.get(link)
104
        assert (
105
            'You are about to delete the account of <strong>%s</strong>.'
106
            % escape(simple_user.get_full_name())
107
            in page.text
108
        )
109
        response = page.form.submit(name='delete')
110
        assert User.objects.filter(id=simple_user.id).count() == 0
111
        assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
112
        assert len(mailoutbox) == 2
113
        assert mailoutbox[1].subject == 'Account deletion on testserver'
114
        assert mailoutbox[0].to == [simple_user.email]
115
        assert "Deletion performed" in str(response)  # Set-Cookie: messages..
116
        assert urlparse(response.location).path == '/'
117

  
118
    def test_account_delete_by_other_user(self, app, simple_user, user_ou1, mailoutbox):
119
        assert simple_user.is_active
120
        assert user_ou1.is_active
121
        assert len(mailoutbox) == 0
122
        page = login(app, simple_user, path=reverse('delete_account'))
123
        page.form.submit(name='submit').follow()
124
        assert len(mailoutbox) == 1
125
        link = get_link_from_mail(mailoutbox[0])
126
        logout(app)
127
        login(app, user_ou1, path=reverse('account_management'))
128
        page = app.get(link)
129
        assert (
130
            'You are about to delete the account of <strong>%s</strong>.'
131
            % escape(simple_user.get_full_name())
132
            in page.text
133
        )
134
        response = page.form.submit(name='delete')
135
        assert app.session['_auth_user_id'] == str(user_ou1.id)
136
        assert User.objects.filter(id=simple_user.id).count() == 0
137
        assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
138
        assert len(mailoutbox) == 2
139
        assert mailoutbox[1].subject == 'Account deletion on testserver'
140
        assert mailoutbox[0].to == [simple_user.email]
141
        assert "Deletion performed" in str(response)  # Set-Cookie: messages..
142
        assert urlparse(response.location).path == '/'
143

  
144
    def test_account_delete_fake_token(self, app, simple_user, mailoutbox):
145
        response = (
146
            app.get(reverse('validate_deletion', kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'}))
147
            .follow()
148
            .follow()
149
        )
150
        assert "The account deletion request is invalid, try again" in response.text
151

  
152
    def test_account_delete_expired_token(self, app, simple_user, mailoutbox, freezer):
153
        freezer.move_to('2019-08-01')
154
        page = login(app, simple_user, path=reverse('delete_account'))
155
        page.form.submit(name='submit').follow()
156
        freezer.move_to('2019-08-04')  # Too late...
157
        link = get_link_from_mail(mailoutbox[0])
158
        response = app.get(link).follow()
159
        assert "The account deletion request is too old, try again" in response.text
160

  
161
    def test_account_delete_valid_token_unexistent_user(self, app, simple_user, mailoutbox):
162
        page = login(app, simple_user, path=reverse('delete_account'))
163
        page.form.submit(name='submit').follow()
164
        link = get_link_from_mail(mailoutbox[0])
165
        simple_user.delete()
166
        response = app.get(link).follow().follow()
167
        assert 'This account has previously been deleted.' in response.text
168

  
169
    def test_account_delete_valid_token_inactive_user(self, app, simple_user, mailoutbox):
170
        page = login(app, simple_user, path=reverse('delete_account'))
171
        page.form.submit(name='submit').follow()
172
        link = get_link_from_mail(mailoutbox[0])
173
        simple_user.is_active = False
174
        simple_user.save()
175
        response = app.get(link).maybe_follow()
176
        assert 'This account is inactive, it cannot be deleted.' in response.text
177

  
178

  
179
class TestDeleteAccountEmailNotVerified:
180
    def test_account_delete(self, app, simple_user, mailoutbox):
181
        assert simple_user.is_active
182
        assert len(mailoutbox) == 0
183
        page = login(app, simple_user, path=reverse('delete_account'))
184
        response = page.form.submit(name='submit').follow()
185
        assert '_auth_user_id' not in app.session
186
        assert User.objects.filter(id=simple_user.id).count() == 0
187
        assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
188
        assert len(mailoutbox) == 1
189
        assert mailoutbox[0].subject == 'Account deletion on testserver'
190
        assert mailoutbox[0].to == [simple_user.email]
191
        assert "Deletion performed" in str(response)  # Set-Cookie: messages..
192
        assert urlparse(response.location).path == '/'
193

  
194
    def test_account_delete_old_authentication(self, app, simple_user, mailoutbox, freezer):
195
        assert simple_user.is_active
196
        assert len(mailoutbox) == 0
197
        login(app, simple_user)
198
        freezer.move_to(datetime.timedelta(hours=1))
199
        redirect = app.get('/accounts/delete/')
200
        login_page = redirect.follow()
201
        assert 'You must re-authenticate' in login_page
202
        login_page.form.set('password', simple_user.username)
203
        page = login_page.form.submit(name='login-password-submit').follow()
204
        response = page.form.submit(name='submit').follow()
205
        assert '_auth_user_id' not in app.session
206
        assert User.objects.filter(id=simple_user.id).count() == 0
207
        assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
208
        assert len(mailoutbox) == 1
209
        assert mailoutbox[0].subject == 'Account deletion on testserver'
210
        assert mailoutbox[0].to == [simple_user.email]
211
        assert "Deletion performed" in str(response)  # Set-Cookie: messages..
212
        assert urlparse(response.location).path == '/'
174 213

  
175 214

  
176 215
def test_login_invalid_next(app):
177
-