manager: prevent phone id duplicates at user edition time (#85276) #227

Closed
pmarillonnet wants to merge 4 commits from wip/85276-bo-phone-identifier-duplicate-prevention into wip/82737-authn-tel-post-registration-account-selection-buggy-form
10 changed files with 368 additions and 12 deletions

View File

@ -68,6 +68,7 @@ class OrganizationalUnitAdmin(admin.ModelAdmin):
'description',
'username_is_unique',
'email_is_unique',
'phone_is_unique',
'default',
'validate_emails',
'user_can_reset_password',

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.18 on 2023-11-06 15:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('a2_rbac', '0037_remove_organizationalunit_min_password_strength'),
]
operations = [
migrations.AddField(
model_name='organizationalunit',
name='phone_is_unique',
field=models.BooleanField(blank=True, default=False, verbose_name='Phone is unique'),
),
]

View File

@ -104,6 +104,7 @@ class OrganizationalUnit(AbstractBase):
username_is_unique = models.BooleanField(blank=True, default=False, verbose_name=_('Username is unique'))
email_is_unique = models.BooleanField(blank=True, default=False, verbose_name=_('Email is unique'))
phone_is_unique = models.BooleanField(blank=True, default=False, verbose_name=_('Phone is unique'))
default = fields.UniqueBooleanField(verbose_name=_('Default organizational unit'))
validate_emails = models.BooleanField(blank=True, default=False, verbose_name=_('Validate emails'))
@ -234,6 +235,7 @@ class OrganizationalUnit(AbstractBase):
'description': self.description,
'default': self.default,
'email_is_unique': self.email_is_unique,
'phone_is_unique': self.phone_is_unique,
'username_is_unique': self.username_is_unique,
'validate_emails': self.validate_emails,
}

View File

@ -138,9 +138,13 @@ default_settings = dict(
A2_USER_CAN_RESET_PASSWORD_BY_USERNAME=Setting(
default=False, definition='Allow password reset request by username'
),
A2_EMAIL_IS_UNIQUE=Setting(default=False, definition='Email of users must be unique'),
A2_EMAIL_IS_UNIQUE=Setting(default=False, definition="Users' email address must be unique"),
A2_REGISTRATION_EMAIL_IS_UNIQUE=Setting(
default=False, definition='Email of registered accounts must be unique'
default=False, definition='Email address declared at registration time must be unique'
),
A2_PHONE_IS_UNIQUE=Setting(default=False, definition="Users' phone number must be unique"),
A2_REGISTRATION_PHONE_IS_UNIQUE=Setting(
default=False, definition='Phone number declared at registration time must be unique'
),
A2_REGISTRATION_FORM_USERNAME_REGEX=Setting(
default=r'^[\w.@+-]+$', definition='Regex to validate usernames'

View File

@ -23,6 +23,7 @@ from io import StringIO
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import validate_slug
from django.urls import reverse
@ -43,7 +44,7 @@ from authentic2.forms.fields import (
)
from authentic2.forms.mixins import SlugMixin
from authentic2.forms.profile import BaseUserForm
from authentic2.models import APIClient, Attribute, PasswordReset, Service, Setting
from authentic2.models import APIClient, Attribute, AttributeValue, PasswordReset, Service, Setting
from authentic2.passwords import generate_password, validate_password
from authentic2.utils.misc import (
RUNTIME_SETTINGS,
@ -172,6 +173,43 @@ class UserEditForm(LimitQuerysetFormMixin, CssClass, BaseUserForm):
self.data[self.add_prefix('ou')] = qs[0].pk
self.data._mutable = False
def clean(self):
super().clean()
authn = get_password_authenticator()
if (
authn.is_phone_authn_active
and (name := authn.phone_identifier_field.name)
and self.cleaned_data.get(name, '')
):
if a2_app_settings.A2_PHONE_IS_UNIQUE:
if (
AttributeValue.objects.filter(
content_type=ContentType.objects.get_for_model(get_user_model()),
attribute=authn.phone_identifier_field,
)
.exclude(object_id=self.instance.id)
.exists()
):
raise ValidationError(_('This phone number identifier is already used.'))
elif getattr(self.instance.ou, 'phone_is_unique', False):
if (
other_owners := AttributeValue.objects.filter(
Review

T'abuses un peu de l'opérateur walrus là, en plus c'est moins lisible comme ça.

T'abuses un peu de l'opérateur walrus là, en plus c'est moins lisible comme ça.
Review

T'abuses un peu de l'opérateur walrus là, en plus c'est moins lisible comme ça (trop d'indentation).

> T'abuses un peu de l'opérateur walrus là, en plus c'est moins lisible comme ça (trop d'indentation).
Review

T'abuses un peu de l'opérateur walrus là, en plus c'est moins lisible comme ça.

Ok pas de souci, je vais revoir cette partie :)

> T'abuses un peu de l'opérateur walrus là, en plus c'est moins lisible comme ça. Ok pas de souci, je vais revoir cette partie :)
content_type=ContentType.objects.get_for_model(get_user_model()),
attribute=authn.phone_identifier_field,
)
.exclude(object_id=self.instance.id)
.values('object_id')
):
if User.objects.filter(
pk__in=other_owners,
ou=self.instance.ou,
):
raise ValidationError(
_(
'This phone number identifier is already used within organizational unit {ou}.'
).format(ou=self.instance.ou)
)
class Meta:
model = User
exclude = (

View File

@ -7,8 +7,17 @@
{% block content %}
<h2>{% trans "Login" %}</h2>
{% if email %}
<p>
{% blocktrans trimmed count accounts_number=accounts|length %}An account already exists for this email address.{% plural %}Existing accounts are associated with this email address.{% endblocktrans %}
</p>
{% elif phone %}
<p>
{% blocktrans trimmed count accounts_number=accounts|length %}An account already exists for this phone number.{% plural %}Existing accounts are associated with this phone number.{% endblocktrans %}
</p>
{% endif %}
<p>
{% blocktrans trimmed count accounts_number=accounts|length %}An account already exists for this email. Please click on the account name to log in with.{% plural %}More accounts are associated to this email. Please choose the account you want to log in with:{% endblocktrans %}
{% blocktrans trimmed count accounts_number=accounts|length %} Please click on the account name to log in with.{% plural %}Please choose the account you want to log in with.{% endblocktrans %}
</p>
<ul>

View File

@ -1796,9 +1796,19 @@ class RegistrationCompletionView(CreateView):
self.users = User.objects.filter(**qs_filter).order_by('date_joined')
if self.ou:
self.users = self.users.filter(ou=self.ou)
self.email_is_unique = app_settings.A2_EMAIL_IS_UNIQUE or app_settings.A2_REGISTRATION_EMAIL_IS_UNIQUE
if self.ou:
self.email_is_unique |= self.ou.email_is_unique
self.email_is_unique = self.phone_is_unique = False
if self.token.get('email', None):
self.email_is_unique = (
app_settings.A2_EMAIL_IS_UNIQUE or app_settings.A2_REGISTRATION_EMAIL_IS_UNIQUE
)
if self.ou:
self.email_is_unique |= self.ou.email_is_unique
elif self.token.get('phone', None) and self.authenticator.is_phone_authn_active:
self.phone_is_unique = (
app_settings.A2_PHONE_IS_UNIQUE or app_settings.A2_REGISTRATION_PHONE_IS_UNIQUE
)
if self.ou:
self.phone_is_unique |= self.ou.phone_is_unique
self.init_fields_labels_and_help_texts()
set_home_url(request, self.get_success_url())
return super().dispatch(request, *args, **kwargs)
@ -1921,12 +1931,18 @@ class RegistrationCompletionView(CreateView):
if hasattr(self, 'phone'):
ctx['phone'] = self.phone
ctx['email_is_unique'] = self.email_is_unique
ctx['phone_is_unique'] = self.phone_is_unique
ctx['create'] = 'create' in self.request.GET
return ctx
def get(self, request, *args, **kwargs):
if len(self.users) == 1 and self.email_is_unique:
# Found one user, EMAIL is unique, log her in
if len(self.users) == 1 and (
self.email_is_unique
and self.token.get('email', None)
or self.phone_is_unique
and self.token.get('phone', None)
):
# Found one user whose identifier is unique, log her in
utils_misc.simulate_authentication(request, self.users[0], method=self.authentication_method)
return utils_misc.redirect(request, self.get_success_url())
confirm_data = self.token.get('confirm_data', False)
@ -1956,8 +1972,13 @@ class RegistrationCompletionView(CreateView):
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
if self.users and self.email_is_unique:
# email is unique, users already exist, creating a new one is forbidden !
if self.users and (
self.email_is_unique
and self.token.get('email', None)
or self.phone_is_unique
and self.token.get('phone', None)
):
# identifier is unique, users already exist, creating a new one is forbidden !
return utils_misc.redirect(
request, request.resolver_match.view_name, args=self.args, kwargs=self.kwargs
)

View File

@ -277,6 +277,7 @@ def test_ou_export_json(db):
description='basic ou description',
username_is_unique=True,
email_is_unique=True,
phone_is_unique=True,
default=False,
validate_emails=True,
)
@ -287,6 +288,7 @@ def test_ou_export_json(db):
assert ou_dict['description'] == ou.description
assert ou_dict['username_is_unique'] == ou.username_is_unique
assert ou_dict['email_is_unique'] == ou.email_is_unique
assert ou_dict['phone_is_unique'] == ou.phone_is_unique
assert ou_dict['default'] == ou.default
assert ou_dict['validate_emails'] == ou.validate_emails

View File

@ -25,11 +25,12 @@ from django.urls import reverse
from httmock import HTTMock, remember_called, urlmatch
from authentic2 import models
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.apps.journal.models import Event
from authentic2.forms.profile import modelform_factory
from authentic2.forms.registration import RegistrationCompletionForm
from authentic2.models import Attribute, SMSCode, Token
from authentic2.models import Attribute, AttributeValue, SMSCode, Token
from authentic2.utils import misc as utils_misc
from authentic2.validators import EmailValidator
@ -1104,6 +1105,188 @@ def test_phone_registration_connection_error(app, db, settings, freezer, caplog,
)
def test_phone_registration_number_already_existing_create(app, db, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
# create duplicates
for i in range(3):
user = User.objects.create(
first_name='John',
last_name='Doe',
email=f'john-{i}@example.com',
username=f'john-{i}',
ou=get_default_ou(),
)
user.attributes.phone = '+33612345678'
user.save()
resp = app.get(reverse('registration_register'))
resp.form.set('phone_1', '612345678')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit()
location = resp.location.split('?')[0]
resp = resp.follow()
assert Token.objects.count() == 1
assert 'Existing accounts are associated with this phone number.' in resp.text
# three existing accounts
assert len(resp.pyquery('form')) == 3
# the possibility to create a new one
assert resp.pyquery('p a').text() == 'create a new account'
resp = app.get(f'{location}?create')
resp.form.set('password1', 'Password0')
resp.form.set('password2', 'Password0')
resp.form.set('first_name', 'John')
resp.form.set('last_name', 'Doe')
resp = resp.form.submit().follow()
assert 'You have just created an account' in resp.text
assert AttributeValue.objects.filter(content='+33612345678').count() == 4
assert User.objects.filter(first_name='John', last_name='Doe').count() == 4
def test_phone_registration_number_already_existing_select(app, db, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
user_ids = []
# create duplicates
for i in range(3):
user = User.objects.create(
first_name='John',
last_name='Doe',
email=f'john-{i}@example.com',
username=f'john-{i}',
ou=get_default_ou(),
)
user.attributes.phone = '+33612345678'
user.save()
user_ids.append(user.id)
resp = app.get(reverse('registration_register'))
resp.form.set('phone_1', '612345678')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
assert Token.objects.count() == 1
assert 'Existing accounts are associated with this phone number.' in resp.text
# three existing accounts
assert len(resp.pyquery('form')) == 3
# the possibility to create a new one
assert resp.pyquery('p a').text() == 'create a new account'
resp.forms[1].submit().follow()
assert app.session['_auth_user_id'] == str(user_ids[1])
def test_phone_registration_number_already_existing_phone_is_unique(app, db, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
settings.A2_PHONE_IS_UNIQUE = True
settings.A2_REGISTRATION_PHONE_IS_UNIQUE = True
# create duplicate
user = User.objects.create(
first_name='John',
last_name='Doe',
email='john@example.com',
username='john',
ou=get_default_ou(),
)
user.attributes.phone = '+33612345678'
user.save()
resp = app.get(reverse('registration_register'))
resp.form.set('phone_1', '612345678')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
assert app.session['_auth_user_id'] == str(user.id)
# logged in user is redirected to their homepage
assert resp.location == '/'
def test_phone_registration_number_already_existing_registration_phone_is_unique(
app, db, settings, phone_activated_authn
):
settings.SMS_URL = 'https://foo.whatever.none/'
settings.A2_PHONE_IS_UNIQUE = False
settings.A2_REGISTRATION_PHONE_IS_UNIQUE = True
user_ids = []
# create duplicate
user = User.objects.create(
first_name='John',
last_name='Doe',
email='john@example.com',
username='john',
ou=get_default_ou(),
)
user.attributes.phone = '+33612345678'
user.save()
user_ids.append(user.id)
resp = app.get(reverse('registration_register'))
resp.form.set('phone_1', '612345678')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
assert Token.objects.count() == 1
assert app.session['_auth_user_id'] == str(user.id)
# logged in user is redirected to their homepage
assert resp.location == '/'
def test_phone_registration_number_already_existing_ou_phone_is_unique(
app, db, settings, phone_activated_authn
):
settings.SMS_URL = 'https://foo.whatever.none/'
settings.A2_PHONE_IS_UNIQUE = False
settings.A2_REGISTRATION_PHONE_IS_UNIQUE = False
ou = get_default_ou()
ou.phone_is_unique = True
ou.save()
user_ids = []
# create duplicate
user = User.objects.create(
first_name='John',
last_name='Doe',
email='john@example.com',
username='john',
ou=ou,
)
user.attributes.phone = '+33612345678'
user.save()
user_ids.append(user.id)
resp = app.get(reverse('registration_register'))
resp.form.set('phone_1', '612345678')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
assert Token.objects.count() == 1
assert app.session['_auth_user_id'] == str(user.id)
# logged in user is redirected to their homepage
assert resp.location == '/'
def test_phone_registration(app, db, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
code_length = settings.SMS_CODE_LENGTH

View File

@ -180,6 +180,85 @@ def test_create_user_email_is_unique(app, superuser, settings):
assert 'This email address is already in use' in response
def test_create_user_phone_is_unique(app, superuser, settings, phone_activated_authn):
settings.A2_PHONE_IS_UNIQUE = True
Attribute.objects.update(required=False)
response = login(app, superuser, '/manage/users/')
response = response.click('Add user')
assert User.objects.count() == 1
response.form.set('username', 'john.doe')
response.form.set('phone_0', '33')
response.form.set('phone_1', '0622332233')
response.form.set('password1', '1234Password')
response.form.set('password2', '1234Password')
response.form.set('send_password_reset', False)
response = response.form.submit(status=302)
assert User.objects.count() == 2
# try again
response = app.get('/manage/users/')
response = response.click('Add user')
response.form.set('username', 'john.doe2')
response.form.set('phone_0', '33')
response.form.set('phone_1', '0622332233')
response.form.set('password1', '1234Password')
response.form.set('password2', '1234Password')
response.form.set('send_password_reset', False)
response = response.form.submit(status=200)
assert User.objects.count() == 2
assert 'This phone number identifier is already used.' in response
def test_create_user_ou_phone_is_unique(app, superuser, settings, phone_activated_authn, ou1, ou2):
settings.A2_PHONE_IS_UNIQUE = False
ou2.phone_is_unique = ou1.phone_is_unique = True
ou1.save()
ou2.save()
Attribute.objects.update(required=False)
response = login(app, superuser, '/manage/users/')
response = response.click('Add user')
assert User.objects.count() == 1
response.form.set('ou', ou1.id)
response = response.form.submit().follow()
response.form.set('username', 'john.doe')
response.form.set('phone_0', '33')
response.form.set('phone_1', '0622332233')
response.form.set('password1', '1234Password')
response.form.set('password2', '1234Password')
response.form.set('send_password_reset', False)
response = response.form.submit(status=302)
assert User.objects.count() == 2
# try again, same ou -> failure
response = app.get('/manage/users/')
response = response.click('Add user')
response.form.set('ou', ou1.id)
response = response.form.submit().follow()
response.form.set('username', 'john.doe2')
response.form.set('phone_0', '33')
response.form.set('phone_1', '0622332233')
response.form.set('password1', '1234Password')
response.form.set('password2', '1234Password')
response.form.set('send_password_reset', False)
response = response.form.submit(status=200)
assert User.objects.count() == 2
assert 'This phone number identifier is already used within organizational unit OU1.' in response
# try again, different ou -> ok
response = app.get('/manage/users/')
response = response.click('Add user')
response.form.set('ou', ou2.id)
response = response.form.submit().follow()
response.form.set('username', 'john.doe2')
response.form.set('phone_0', '33')
response.form.set('phone_1', '0622332233')
response.form.set('password1', '1234Password')
response.form.set('password2', '1234Password')
response.form.set('send_password_reset', False)
response = response.form.submit(status=302)
assert User.objects.count() == 3
def test_create_user_no_password(app, superuser):
response = login(app, superuser, '/manage/users/')
response = response.click('Add user')