Parcours de suppression de compte à l’aide du numéro de téléphone (#72615) #95

Merged
pmarillonnet merged 2 commits from wip/72615-phone-based-account-deletion into main 2023-08-03 12:18:03 +02:00
9 changed files with 275 additions and 38 deletions

View File

@ -52,6 +52,7 @@ from authentic2.decorators import errorcollector
from authentic2.models import Attribute, AttributeValue, Service
from authentic2.utils import misc as utils_misc
from authentic2.utils.cache import RequestCache
from authentic2.utils.misc import get_password_authenticator
from authentic2.utils.models import generate_slug
from authentic2.validators import PhoneNumberValidator, email_validator
@ -539,6 +540,21 @@ class User(AbstractBaseUser):
self.deactivation_reason = reason
self.save(update_fields=['is_active', 'deactivation', 'deactivation_reason'])
@property
def phone_identifier(self):
if field := get_password_authenticator().phone_identifier_field:
atvs = (
Review

Peut-être que ça marche de faire direct return AttributeValue.objects.with_owner(...).first(), dans ce cas la requête serait j'imagine un peu plus performante, mais c'est du détail

Peut-être que ça marche de faire direct `return AttributeValue.objects.with_owner(...).first()`, dans ce cas la requête serait j'imagine un peu plus performante, mais c'est du détail
Review

Ça me va de laisser comme cela, il n’est pas censé y avoir des tonnes de lignes renvoyées, si c’est le cas c’est qu’il y a un problème ailleurs.

Ça me va de laisser comme cela, il n’est pas censé y avoir des tonnes de lignes renvoyées, si c’est le cas c’est qu’il y a un problème ailleurs.
AttributeValue.objects.with_owner(self)
.filter(
attribute=field,
content__isnull=False,
)
.values_list('content', flat=True)
)
if atvs:
return atvs[0]
return None
@property
def verbose_deactivation_reason(self):
from authentic2.backends.ldap_backend import (

View File

@ -128,17 +128,8 @@ class PasswordResetForm(HoneypotForm):
sms_sent = False
phone_authn_active = self.authenticator.is_phone_authn_active
user_ct = ContentType.objects.get_for_model(get_user_model())
for user in active_users:
if not user.email and not (
phone_authn_active
and models.AttributeValue.objects.filter(
attribute=self.authenticator.phone_identifier_field,
object_id=user.id,
content_type=user_ct,
content__isnull=False,
)
):
if not user.email and not (phone_authn_active and user.phone_identifier):
logger.info(
'password reset failed for account "%r": account has no email nor mobile phone number',
user,

View File

@ -816,10 +816,12 @@ class SMSCode(models.Model):
KIND_REGISTRATION = 'registration'
KIND_PASSWORD_LOST = 'password-reset'
KIND_PHONE_CHANGE = 'phone-change'
KIND_ACCOUNT_DELETION = 'account-deletion'
CODE_TO_TOKEN_KINDS = {
KIND_REGISTRATION: 'registration',
KIND_PASSWORD_LOST: 'pw-reset',
KIND_PHONE_CHANGE: 'phone-change',
KIND_ACCOUNT_DELETION: 'account-deletion',
}
value = models.CharField(
verbose_name=_('Identifier'), default=create_sms_code, editable=False, max_length=32

View File

@ -27,6 +27,13 @@
link in this email in order to complete the deletion process.
{% endblocktrans %}
</p>
{% elif user.phone_verified_on %}
<p>
{% blocktrans trimmed %}
A validation code will be sent to {{ phone }}. You will have to type in the
code in the next page to complete the deletion process.
{% endblocktrans %}
</p>
{% endif %}
<div class="buttons">
<button class="submit-button" name="submit">{% if user.email_verified %}{% trans "Send message" %}{% else %}{% trans "Delete account" %}{% endif %}</button>

View File

@ -0,0 +1 @@
{% load i18n %}{% blocktrans trimmed with value=code.value %}Your code is {{ value }}{% endblocktrans %}

View File

@ -121,6 +121,20 @@ def send_registration_sms(phone_number, ou, template_names=None, context=None, *
)
def send_account_deletion_sms(phone_number, ou, user=None, template_names=None, context=None, **kwargs):
from authentic2.models import SMSCode
return send_sms(
phone_number,
ou,
user=user,
template_names=template_names or ['deletion/sms_code_account_deletion.txt'],
context=context,
kind=SMSCode.KIND_ACCOUNT_DELETION,
**kwargs,
)
def send_password_reset_sms(phone_number, ou, user=None, template_names=None, context=None, **kwargs):
from authentic2.models import SMSCode

View File

@ -310,14 +310,7 @@ class PhoneChangeView(HomeURLMixin, IdentifierChangeMixin, cbv.TemplateNamesMixi
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
user_ct = ContentType.objects.get_for_model(get_user_model())
if phones := models.AttributeValue.objects.filter(
attribute=self.authenticator.phone_identifier_field,
content_type=user_ct,
object_id=self.request.user.pk,
content__isnull=False,
).values_list('content', flat=True):
ctx['phone'] = phones[0]
ctx['phone'] = self.request.user.phone_identifier
return ctx
def get_success_url(self):
@ -381,12 +374,7 @@ class PhoneChangeView(HomeURLMixin, IdentifierChangeMixin, cbv.TemplateNamesMixi
)
return utils_misc.redirect(self.request, reverse('auth_homepage'))
old_atvs = (
models.AttributeValue.objects.with_owner(self.request.user)
.filter(attribute=self.authenticator.phone_identifier_field)
.values_list('content', flat=True)
)
old_phone = old_atvs[0] if old_atvs else ''
old_phone = self.request.user.phone_identifier
self.request.journal.record(
'user.phone.change.request',
@ -805,15 +793,7 @@ class ProfileView(HomeURLMixin, cbv.TemplateNamesMixin, TemplateView):
for idp_backend in idp_backends:
if hasattr(idp_backend, 'federation_management'):
federation_management.extend(idp_backend.federation_management(request))
user_ct = ContentType.objects.get_for_model(get_user_model())
atvs = models.AttributeValue.objects.filter(
attribute=authenticator.phone_identifier_field,
content_type=user_ct,
object_id=request.user.pk,
content__isnull=False,
).values_list('content', flat=True)
if atvs:
context.update({'phone': atvs[0]})
context.update({'phone': self.request.user.phone_identifier})
allow_phone_change = bool(
authenticator.phone_identifier_field
@ -1632,6 +1612,15 @@ class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
),
params=params,
)
elif self.code.kind == models.SMSCode.KIND_ACCOUNT_DELETION:
return utils_misc.redirect(
self.request,
reverse(
'validate_deletion',
kwargs={'deletion_token': token.uuid},
),
params=params,
)
input_sms_code = InputSMSCodeView.as_view()
@ -1994,9 +1983,15 @@ class AccountDeleteView(HomeURLMixin, RecentAuthenticationMixin, TemplateView):
title = _('Request account deletion')
def dispatch(self, request, *args, **kwargs):
self.ou = self.request.user.ou or get_default_ou()
self.next_url = utils_misc.select_next_url(request, None)
self.authenticator = utils_misc.get_password_authenticator()
if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT:
return utils_misc.redirect(request, '..')
if not self.request.user.email_verified and not self.has_recent_authentication():
if (
not (self.request.user.email_verified or self.request.user.phone_verified_on)
and not self.has_recent_authentication()
):
return self.reauthenticate(
action='account-delete', message=_('You must re-authenticate to delete your account.')
)
@ -2005,11 +2000,30 @@ class AccountDeleteView(HomeURLMixin, RecentAuthenticationMixin, TemplateView):
def post(self, request, *args, **kwargs):
if 'cancel' in request.POST:
return utils_misc.redirect(request, 'account_management')
phone = request.user.phone_identifier
if self.request.user.email_verified:
utils_misc.send_account_deletion_code(self.request, self.request.user)
messages.info(
request, _("An account deletion validation email has been sent to your email address.")
)
elif self.request.user.phone_verified_on and phone:
try:
code = utils_sms.send_account_deletion_sms(phone, ou=self.ou, user=self.request.user)
except SMSError:
messages.warning(
self.request,
_(
'Something went wrong while trying to send the SMS code to you.'
' Please contact your administrator and try again later.'
),
)
return utils_misc.redirect(self.request, reverse('auth_homepage'))
return utils_misc.redirect(
self.request,
reverse('input_sms_code', kwargs={'token': code.url_token}),
params={REDIRECT_FIELD_NAME: self.next_url, 'token': code.url_token},
)
else:
deletion_url = utils_misc.build_deletion_url(request, prompt=False)
return logout(
@ -2022,6 +2036,7 @@ class AccountDeleteView(HomeURLMixin, RecentAuthenticationMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['email'] = self.request.user.email
ctx['phone'] = self.request.user.phone_identifier
return ctx
@ -2033,6 +2048,7 @@ class ValidateDeletionView(TemplateView):
def dispatch(self, request, *args, **kwargs):
error = None
self.authenticator = utils_misc.get_password_authenticator()
try:
deletion_token = crypto.loads(
@ -2057,8 +2073,44 @@ class ValidateDeletionView(TemplateView):
error = _('This account has previously been deleted.')
if error:
messages.error(request, error)
return utils_misc.redirect(request, 'auth_homepage')
# second attempt, phone-based deletion, based on models.Token usage
token = kwargs['deletion_token'].replace(' ', '')
if token:
try:
token = models.Token.use('account-deletion', token, delete=False)
except (models.Token.DoesNotExist, ValidationError, ValueError):
messages.error(request, error)
return utils_misc.redirect(request, 'auth_homepage')
try:
self.token_phone = token.content['phone']
user_pk = token.content['user']
except KeyError:
messages.error(
request,
_('Something went wrong while trying to delete your account. Try again later.'),
)
return utils_misc.redirect(request, 'auth_homepage')
try:
self.user = User.objects.get(pk=user_pk)
except User.DoesNotExist:
messages.error(
request,
_('Something went wrong while trying to delete your account. Try again later.'),
)
return utils_misc.redirect(request, 'auth_homepage')
self.current_phone = self.user.phone_identifier
self.prompt = False
if not self.token_phone or self.current_phone != self.token_phone:
messages.error(
request,
_(
'Something went wrong while processing your account deletion request. Please try again.'
),
)
return utils_misc.redirect(request, 'auth_homepage')
return super().dispatch(request, *args, **kwargs)

Ici j'imagine que le comportement souhaité c'est 1/ utiliser le token 2/ si il y a une erreur pendant l'utilisation, échouer mais réactiver le token.

Je pense que c'est inutile de chercher à faire ça, si le token est mauvais autant l'invalider (c'est comme ça partout ailleurs dans le code).

Concrètement ça veut juste dire enlever le bloc atomique, et revenir à une gestion normale des exceptions.

Ici j'imagine que le comportement souhaité c'est 1/ utiliser le token 2/ si il y a une erreur pendant l'utilisation, échouer mais réactiver le token. Je pense que c'est inutile de chercher à faire ça, si le token est mauvais autant l'invalider (c'est comme ça partout ailleurs dans le code). Concrètement ça veut juste dire enlever le bloc atomique, et revenir à une gestion normale des exceptions.

Oui tu as raison, pas besoin d’atomicité ici en fait. Je vais faire revoir cela.

Oui tu as raison, pas besoin d’atomicité ici en fait. Je vais faire revoir cela.
def get(self, request, *args, **kwargs):

View File

@ -125,6 +125,8 @@ def nomail_user(db, ou1):
last_name='Dôe',
ou=get_default_ou(),
phone='+33123456789',
email='',
email_verified=False,
)

View File

@ -23,14 +23,15 @@ from urllib.parse import urlparse
import pytest
from django.urls import reverse
from django.utils.html import escape
from httmock import HTTMock
from django.utils.timezone import now
from httmock import HTTMock, remember_called
from httmock import response as httmock_response
from httmock import urlmatch
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.custom_user.models import DeletedUser, User
from authentic2.forms.passwords import PasswordChangeForm, SetPasswordForm
from authentic2.models import Attribute
from authentic2.models import Attribute, SMSCode, Token
from authentic2.views import passive_login
from .utils import assert_event, get_link_from_mail, login, logout
@ -38,6 +39,18 @@ from .utils import assert_event, get_link_from_mail, login, logout
pytestmark = pytest.mark.django_db
@urlmatch(netloc='foo.whatever.none')
@remember_called
def sms_service_mock(url, request):
return {
'content': {},
'headers': {
'content-type': 'application/json',
},
'status_code': 200,
}
def test_profile(app, simple_user):
page = login(app, simple_user, path=reverse('account_management'))
assert simple_user.first_name in page
@ -307,6 +320,145 @@ class TestDeleteAccountEmailNotVerified:
assert urlparse(response.location).path == '/'
def test_delete_account_phone_identifier(app, nomail_user, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
nomail_user.attributes.phone = '+33122446666'
nomail_user.phone_verified_on = now()
nomail_user.save()
nomail_user_id = nomail_user.id
login(
app, nomail_user, login=nomail_user.attributes.phone, path='/accounts/', password=nomail_user.username
)
resp = app.get('/accounts/delete/')
assert 'A validation code will be sent to +33122446666' in resp.text
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
assert not Token.objects.count()
resp.form.set('sms_code', code.value)
resp = resp.form.submit('').follow().maybe_follow()
# assert not Token.objects.count() # single use token?
with pytest.raises(User.DoesNotExist):
User.objects.get(id=nomail_user_id)
def test_delete_account_phone_identifier_deactivated_user(app, nomail_user, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
nomail_user.attributes.phone = '+33122446666'
nomail_user.phone_verified_on = now()
nomail_user.save()
nomail_user_id = nomail_user.id
login(
app, nomail_user, login=nomail_user.attributes.phone, path='/accounts/', password=nomail_user.username
)
resp = app.get('/accounts/delete/')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
nomail_user.is_active = False
nomail_user.save()
resp = resp.form.submit('').follow().maybe_follow()
# a user, submitting a deletion request, deactivated since then, should still
# be able to complete the deletion if the request has been submitted
with pytest.raises(User.DoesNotExist):
User.objects.get(id=nomail_user_id)
def test_delete_account_verified_email_precedence_over_verified_phone(
app, simple_user, settings, phone_activated_authn
):
settings.SMS_URL = 'https://foo.whatever.none/'
simple_user.attributes.phone = '+33122446666'
simple_user.email_verified = True
simple_user.phone_verified_on = now()
simple_user.save()
login(
app, simple_user, login=simple_user.attributes.phone, path='/accounts/', password=simple_user.username
)
resp = app.get('/accounts/delete/')
# email is verified and defaults as deletion code exchange means
assert 'A validation message will be sent to user@example.net.' in resp.text
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
assert not SMSCode.objects.count()
def test_delete_account_verified_phone_precedence_over_unverified_email(
app, simple_user, settings, phone_activated_authn
):
settings.SMS_URL = 'https://foo.whatever.none/'
simple_user.attributes.phone = '+33122446666'
simple_user.email_verified = False
simple_user.phone_verified_on = now()
simple_user.save()
login(
app, simple_user, login=simple_user.attributes.phone, path='/accounts/', password=simple_user.username
)
resp = app.get('/accounts/delete/')
# email is unverified and skipped as deletion code exchange means
# fallback on phone
assert 'A validation code will be sent to +33122446666' in resp.text
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
assert SMSCode.objects.get()
def test_delete_account_unverified_identifiers_direct_deletion(
app, simple_user, settings, phone_activated_authn
):
settings.SMS_URL = 'https://foo.whatever.none/'
simple_user.attributes.phone = '+33122446666'
simple_user.email_verified = False
simple_user.phone_verified_on = None
simple_user.save()
simple_user_id = simple_user.id
login(
app, simple_user, login=simple_user.attributes.phone, path='/accounts/', password=simple_user.username
)
resp = app.get('/accounts/delete/')
# email is unverified but so is the user's phone
# deletion process is direct
assert 'A validation message' not in resp.text
assert 'A validation code' not in resp.text
with HTTMock(sms_service_mock):
resp.form.submit().follow()
assert not SMSCode.objects.count()
assert not Token.objects.count()
with pytest.raises(User.DoesNotExist):
User.objects.get(id=simple_user_id)
def test_delete_account_phone_identifier_changed_in_between(
app, nomail_user, settings, phone_activated_authn
):
settings.SMS_URL = 'https://foo.whatever.none/'
nomail_user.attributes.phone = '+33122446666'
nomail_user.phone_verified_on = now()
nomail_user.save()
nomail_user_id = nomail_user.id
login(
app, nomail_user, login=nomail_user.attributes.phone, path='/accounts/', password=nomail_user.username
)
resp = app.get('/accounts/delete/')
assert 'A validation code will be sent to +33122446666' in resp.text
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
nomail_user.attributes.phone = '+33122446688'
nomail_user.save()
resp.form.set('sms_code', code.value)
resp = resp.form.submit('').follow().maybe_follow()
assert 'Something went wrong' in resp.text
assert User.objects.get(id=nomail_user_id)
def test_login_invalid_next(app):
app.get(reverse('auth_login') + '?next=plop')