Projet

Général

Profil

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

Benjamin Dauvergne, 17 février 2022 19:39

Télécharger (25,6 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 +
 src/authentic2_auth_oidc/authenticators.py    |   1 +
 src/authentic2_auth_saml/authenticators.py    |   1 +
 tests/conftest.py                             |   6 +-
 tests/test_views.py                           | 257 ++++++++++--------
 9 files changed, 233 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
......
294 295
def login(request, template_name='authentic2/login.html', redirect_field_name=REDIRECT_FIELD_NAME):
295 296
    """Displays the login form and handles the login action."""
296 297

  
298
    request.login_token = token = {}
299
    if 'token' in request.GET:
300
        try:
301
            token.update(crypto.loads(request.GET['token']))
302
            logger.debug('login: got token %s', token)
303
        except (crypto.SignatureExpired, crypto.BadSignature, ValueError):
304
            logger.warning('login: bad token')
305
    methods = token.get('methods', [])
306

  
297 307
    # redirect user to homepage if already connected, if setting
298 308
    # A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE is True
299 309
    if request.user.is_authenticated and app_settings.A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE:
......
330 340

  
331 341
    # Create blocks
332 342
    for authenticator in authenticators:
343
        if methods and not set(authenticator.how) & set(methods):
344
            continue
333 345
        # Legacy API
334 346
        if not hasattr(authenticator, 'login'):
335 347
            fid = authenticator.id
......
390 402
        block = blocks[0]
391 403
        authenticator = block['authenticator']
392 404
        if hasattr(authenticator, 'autorun'):
405
            if 'message' in token:
406
                messages.info(request, token['message'])
393 407
            return authenticator.autorun(request, block['id'])
394 408

  
395 409
    # Old frontends API
......
416 430
            redirect_field_name: redirect_to,
417 431
        }
418 432
    )
433
    if 'message' in token:
434
        messages.info(request, token['message'])
419 435
    return render(request, template_name, context)
420 436

  
421 437

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

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

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

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

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

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

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

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

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

  
1392 1444
    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):
src/authentic2_auth_oidc/authenticators.py
26 26

  
27 27
class OIDCAuthenticator(BaseAuthenticator):
28 28
    id = 'oidc'
29
    how = ['oidc']
29 30
    priority = 2
30 31

  
31 32
    def enabled(self):
src/authentic2_auth_saml/authenticators.py
28 28
class SAMLAuthenticator(BaseAuthenticator):
29 29
    id = 'saml'
30 30
    priority = 3
31
    how = ['saml']
31 32

  
32 33
    def enabled(self):
33 34
        return app_settings.enable and list(get_idps())
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
-