Parcours de suppression de compte à l’aide du numéro de téléphone (#72615) #95
|
@ -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 = (
|
||||
|
||||
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 (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{% load i18n %}{% blocktrans trimmed with value=code.value %}Your code is {{ value }}{% endblocktrans %}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
vdeniaud
commented
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.
pmarillonnet
commented
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):
|
||||
|
|
|
@ -125,6 +125,8 @@ def nomail_user(db, ou1):
|
|||
last_name='Dôe',
|
||||
ou=get_default_ou(),
|
||||
phone='+33123456789',
|
||||
email='',
|
||||
email_verified=False,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
Loading…
Reference in New Issue
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Ç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.