0001-custom_user-add-phone-and-phone-verification-fields-.patch
src/authentic2/admin.py | ||
---|---|---|
286 | 286 |
class AuthenticUserAdmin(UserAdmin): |
287 | 287 |
fieldsets = ( |
288 | 288 |
(None, {'fields': ('uuid', 'ou', 'password')}), |
289 |
(_('Personal info'), {'fields': ('username', 'first_name', 'last_name', 'email', 'email_verified')}), |
|
289 |
( |
|
290 |
_('Personal info'), |
|
291 |
{ |
|
292 |
'fields': ( |
|
293 |
'username', |
|
294 |
'first_name', |
|
295 |
'last_name', |
|
296 |
'email', |
|
297 |
'email_verified', |
|
298 |
'phone', |
|
299 |
'phone_verified_on', |
|
300 |
) |
|
301 |
}, |
|
302 |
), |
|
290 | 303 |
(_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups')}), |
291 | 304 |
(_('Important dates'), {'fields': ('last_login', 'date_joined', 'deactivation')}), |
292 | 305 |
) |
... | ... | |
302 | 315 |
'last_name', |
303 | 316 |
'email', |
304 | 317 |
'email_verified', |
318 |
'phone', |
|
319 |
'phone_verified_on', |
|
305 | 320 |
'password1', |
306 | 321 |
'password2', |
307 | 322 |
), |
... | ... | |
310 | 325 |
) |
311 | 326 |
readonly_fields = ('uuid',) |
312 | 327 |
list_filter = UserAdmin.list_filter + (UserRealmListFilter, ExternalUserListFilter) |
313 |
list_display = ['__str__', 'ou', 'first_name', 'last_name', 'email'] |
|
328 |
list_display = ['__str__', 'ou', 'first_name', 'last_name', 'email', 'phone']
|
|
314 | 329 |
actions = UserAdmin.actions + ['mark_as_inactive'] |
315 | 330 | |
316 | 331 |
def get_fieldsets(self, request, obj=None): |
... | ... | |
351 | 366 |
qs = models.Attribute.objects.all() |
352 | 367 |
else: |
353 | 368 |
qs = models.Attribute.objects.filter(required=True) |
354 |
non_model_fields = {a.name for a in qs} - {'first_name', 'last_name'} |
|
369 |
non_model_fields = {a.name for a in qs} - {'first_name', 'last_name', 'phone'}
|
|
355 | 370 |
fields = list(set(fields) - set(non_model_fields)) |
356 | 371 |
kwargs['fields'] = fields |
357 | 372 |
return super().get_form(request, obj=obj, **kwargs) |
src/authentic2/app_settings.py | ||
---|---|---|
298 | 298 |
default='3/d', definition='Maximum rate of emails sent to the same email address.' |
299 | 299 |
), |
300 | 300 |
A2_USER_DELETED_KEEP_DATA=Setting( |
301 |
default=['email', 'uuid'], definition='User data to keep after deletion' |
|
301 |
default=['email', 'uuid', 'phone'], definition='User data to keep after deletion'
|
|
302 | 302 |
), |
303 | 303 |
A2_USER_DELETED_KEEP_DATA_DAYS=Setting( |
304 | 304 |
default=365, definition='Number of days to keep data on deleted users' |
src/authentic2/custom_user/migrations/0032_auto_20220919_1230.py | ||
---|---|---|
1 |
# Generated by Django 2.2.26 on 2022-09-19 10:30 |
|
2 | ||
3 |
from django.db import migrations, models |
|
4 | ||
5 |
import authentic2.validators |
|
6 | ||
7 | ||
8 |
class Migration(migrations.Migration): |
|
9 | ||
10 |
dependencies = [ |
|
11 |
('custom_user', '0031_profile_email'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='deleteduser', |
|
17 |
name='old_phone', |
|
18 |
field=models.CharField(blank=True, max_length=64, null=True, verbose_name='Old phone number'), |
|
19 |
), |
|
20 |
migrations.AddField( |
|
21 |
model_name='user', |
|
22 |
name='phone', |
|
23 |
field=models.CharField( |
|
24 |
blank=True, |
|
25 |
max_length=64, |
|
26 |
null=True, |
|
27 |
validators=[authentic2.validators.PhoneNumberValidator], |
|
28 |
verbose_name='phone number', |
|
29 |
), |
|
30 |
), |
|
31 |
migrations.AddField( |
|
32 |
model_name='user', |
|
33 |
name='phone_verified_on', |
|
34 |
field=models.DateTimeField( |
|
35 |
blank=True, default=None, null=True, verbose_name='phone verification date' |
|
36 |
), |
|
37 |
), |
|
38 |
migrations.AddConstraint( |
|
39 |
model_name='user', |
|
40 |
constraint=models.CheckConstraint( |
|
41 |
check=models.Q( |
|
42 |
('username__isnull', False), |
|
43 |
('email__isnull', False), |
|
44 |
('phone__isnull', False), |
|
45 |
_connector='OR', |
|
46 |
), |
|
47 |
name='constraint_at_least_one_identifier', |
|
48 |
), |
|
49 |
), |
|
50 |
] |
src/authentic2/custom_user/models.py | ||
---|---|---|
23 | 23 |
from django.core.exceptions import MultipleObjectsReturned, ValidationError |
24 | 24 |
from django.core.mail import send_mail |
25 | 25 |
from django.db import models, transaction |
26 |
from django.db.models import Q |
|
26 | 27 |
from django.urls import reverse |
27 | 28 |
from django.utils import timezone |
28 | 29 |
from django.utils.translation import gettext_lazy as _ |
... | ... | |
42 | 43 |
from authentic2.utils import misc as utils_misc |
43 | 44 |
from authentic2.utils.cache import RequestCache |
44 | 45 |
from authentic2.utils.models import generate_slug |
45 |
from authentic2.validators import email_validator |
|
46 |
from authentic2.validators import PhoneNumberValidator, email_validator
|
|
46 | 47 |
from django_rbac.models import PermissionMixin |
47 | 48 | |
48 | 49 |
from .managers import UserManager, UserQuerySet |
... | ... | |
156 | 157 |
email_verified_date = models.DateTimeField( |
157 | 158 |
default=None, blank=True, null=True, verbose_name=_('email verified date') |
158 | 159 |
) |
160 |
phone = models.CharField( |
|
161 |
_('phone number'), null=True, blank=True, max_length=64, validators=[PhoneNumberValidator] |
|
162 |
) |
|
163 |
phone_verified_on = models.DateTimeField( |
|
164 |
null=True, |
|
165 |
blank=True, |
|
166 |
default=None, |
|
167 |
verbose_name=_('phone verification date'), |
|
168 |
) |
|
159 | 169 |
is_staff = models.BooleanField( |
160 | 170 |
_('staff status'), |
161 | 171 |
default=False, |
... | ... | |
202 | 212 |
verbose_name = _('user') |
203 | 213 |
verbose_name_plural = _('users') |
204 | 214 |
ordering = ('last_name', 'first_name', 'email', 'username') |
215 |
constraints = [ |
|
216 |
models.CheckConstraint( |
|
217 |
check=Q(username__isnull=False) | Q(email__isnull=False) | Q(phone__isnull=False), |
|
218 |
name='constraint_at_least_one_identifier', |
|
219 |
) |
|
220 |
] |
|
205 | 221 | |
206 | 222 |
def get_full_name(self): |
207 | 223 |
""" |
... | ... | |
247 | 263 |
return '<User: %s (%s)>' % (human_name, short_id) |
248 | 264 | |
249 | 265 |
def clean(self): |
250 |
if not (self.username or self.email or (self.first_name and self.last_name)): |
|
266 |
if not (self.username or self.email or self.phone or (self.first_name and self.last_name)):
|
|
251 | 267 |
raise ValidationError( |
252 | 268 |
_( |
253 |
'An account needs at least one identifier: username, email or a full name (first and last'
|
|
254 |
' name).' |
|
269 |
'An account needs at least one identifier: username, email, phone numbor or a full name '
|
|
270 |
'(first and last name).'
|
|
255 | 271 |
) |
256 | 272 |
) |
257 | 273 | |
... | ... | |
406 | 422 |
deleted_user.old_email = self.email.rsplit('#', 1)[0] |
407 | 423 |
if 'uuid' in app_settings.A2_USER_DELETED_KEEP_DATA: |
408 | 424 |
deleted_user.old_uuid = self.uuid |
425 |
if 'phone' in app_settings.A2_USER_DELETED_KEEP_DATA: |
|
426 |
deleted_user.old_phone = self.phone |
|
409 | 427 | |
410 | 428 |
# save LDAP account references |
411 | 429 |
external_ids = self.userexternalid_set.order_by('id') |
... | ... | |
454 | 472 |
old_uuid = models.TextField(verbose_name=_('Old UUID'), null=True, blank=True) |
455 | 473 |
old_user_id = models.PositiveIntegerField(verbose_name=_('Old user id'), null=True, blank=True) |
456 | 474 |
old_email = models.EmailField(verbose_name=_('Old email adress'), null=True, blank=True) |
475 |
old_phone = models.CharField(verbose_name=_('Old phone number'), null=True, blank=True, max_length=64) |
|
457 | 476 |
old_data = JSONField(verbose_name=_('Old data'), null=True, blank=True) |
458 | 477 | |
459 | 478 |
@classmethod |
... | ... | |
464 | 483 |
cls.objects.filter(deleted__lt=threshold).delete() |
465 | 484 | |
466 | 485 |
def __repr__(self): |
467 |
return 'DeletedUser(old_id=%s, old_uuid=%s…, old_email=%s)' % ( |
|
486 |
return 'DeletedUser(old_id=%s, old_uuid=%s…, old_email=%s, old_phone=%s)' % (
|
|
468 | 487 |
self.old_user_id or '-', |
469 | 488 |
(self.old_uuid or '')[:6], |
470 | 489 |
self.old_email or '-', |
490 |
self.old_phone or '-', |
|
471 | 491 |
) |
472 | 492 | |
473 | 493 |
def __str__(self): |
474 | 494 |
data = ['#%d' % self.old_user_id] |
475 | 495 |
if self.old_email: |
476 | 496 |
data.append(self.old_email) |
497 |
if self.old_phone: |
|
498 |
data.append(self.old_phone) |
|
477 | 499 |
return _('deleted user (%s)') % ', '.join(data) |
478 | 500 | |
479 | 501 |
class Meta: |
src/authentic2/validators.py | ||
---|---|---|
99 | 99 |
email_validator = EmailValidator() |
100 | 100 | |
101 | 101 | |
102 |
class PhoneNumberValidator(RegexValidator): |
|
103 |
def __init__(self, *args, **kwargs): |
|
104 |
self.regex = r'^\+?\d{,20}$' |
|
105 |
super().__init__(*args, **kwargs) |
|
106 | ||
107 | ||
102 | 108 |
class UsernameValidator(RegexValidator): |
103 | 109 |
def __init__(self, *args, **kwargs): |
104 | 110 |
self.regex = app_settings.A2_REGISTRATION_FORM_USERNAME_REGEX |
tests/api/test_all.py | ||
---|---|---|
66 | 66 |
'modified', |
67 | 67 |
'email_verified', |
68 | 68 |
'email_verified_date', |
69 |
'phone', |
|
70 |
'phone_verified_on', |
|
69 | 71 |
'last_account_deletion_alert', |
70 | 72 |
'deactivation', |
71 | 73 |
'deactivation_reason', |
... | ... | |
2390 | 2392 | |
2391 | 2393 |
def test_phone_normalization_ok(settings, app, admin): |
2392 | 2394 |
headers = basic_authorization_header(admin) |
2393 |
Attribute.objects.create(name='phone', label='phone', kind='phone_number')
|
|
2395 |
Attribute.objects.create(name='extra_phone', label='extra phone', kind='phone_number')
|
|
2394 | 2396 |
payload = { |
2395 | 2397 |
'username': 'janedoe', |
2396 |
'phone': ' + 334-99 98.56/43', |
|
2398 |
'extra_phone': ' + 334-99 98.56/43',
|
|
2397 | 2399 |
'first_name': 'Jane', |
2398 | 2400 |
'last_name': 'Doe', |
2399 | 2401 |
} |
2400 | 2402 |
resp = app.post_json('/api/users/', headers=headers, params=payload, status=201) |
2401 |
assert resp.json['phone'] == '+33499985643' |
|
2402 |
assert User.objects.get(username='janedoe').attributes.phone == '+33499985643' |
|
2403 |
assert resp.json['extra_phone'] == '+33499985643'
|
|
2404 |
assert User.objects.get(username='janedoe').attributes.extra_phone == '+33499985643'
|
|
2403 | 2405 | |
2404 | 2406 | |
2405 | 2407 |
def test_phone_normalization_nok(settings, app, admin): |
2406 | 2408 |
headers = basic_authorization_header(admin) |
2407 |
Attribute.objects.create(name='phone', label='phone', kind='phone_number')
|
|
2409 |
Attribute.objects.create(name='extra_phone', label='extra phone', kind='phone_number')
|
|
2408 | 2410 |
payload = { |
2409 | 2411 |
'username': 'janedoe', |
2410 | 2412 |
'first_name': 'Jane', |
2411 | 2413 |
'last_name': 'Doe', |
2412 | 2414 |
} |
2413 |
payload['phone'] = (' + 334-99+98.56/43',) |
|
2415 |
payload['extra_phone'] = (' + 334-99+98.56/43',)
|
|
2414 | 2416 |
app.post_json('/api/users/', headers=headers, params=payload, status=400) |
2415 | 2417 | |
2416 |
payload['phone'] = '1#2' |
|
2418 |
payload['extra_phone'] = '1#2'
|
|
2417 | 2419 |
app.post_json('/api/users/', headers=headers, params=payload, status=400) |
2418 | 2420 | |
2419 | 2421 | |
2420 | 2422 |
def test_fr_phone_normalization_ok(settings, app, admin): |
2421 | 2423 |
headers = basic_authorization_header(admin) |
2422 |
Attribute.objects.create(name='phone', label='phone', kind='fr_phone_number')
|
|
2424 |
Attribute.objects.create(name='extra_phone', label='extra phone', kind='fr_phone_number')
|
|
2423 | 2425 |
payload = { |
2424 | 2426 |
'username': 'janedoe', |
2425 |
'phone': ' 04-99 98.56/43', |
|
2427 |
'extra_phone': ' 04-99 98.56/43',
|
|
2426 | 2428 |
'first_name': 'Jane', |
2427 | 2429 |
'last_name': 'Doe', |
2428 | 2430 |
} |
2429 | 2431 |
resp = app.post_json('/api/users/', headers=headers, params=payload, status=201) |
2430 |
assert resp.json['phone'] == '0499985643' |
|
2431 |
assert User.objects.get(username='janedoe').attributes.phone == '0499985643' |
|
2432 |
assert resp.json['extra_phone'] == '0499985643'
|
|
2433 |
assert User.objects.get(username='janedoe').attributes.extra_phone == '0499985643'
|
|
2432 | 2434 | |
2433 | 2435 | |
2434 | 2436 |
def test_fr_phone_normalization_nok(settings, app, admin): |
2435 | 2437 |
headers = basic_authorization_header(admin) |
2436 |
Attribute.objects.create(name='phone', label='phone', kind='fr_phone_number')
|
|
2438 |
Attribute.objects.create(name='extra_phone', label='extra phone', kind='fr_phone_number')
|
|
2437 | 2439 |
payload = { |
2438 | 2440 |
'username': 'janedoe', |
2439 |
'phone': '+33499985643', |
|
2441 |
'extra_phone': '+33499985643',
|
|
2440 | 2442 |
'first_name': 'Jane', |
2441 | 2443 |
'last_name': 'Doe', |
2442 | 2444 |
} |
2443 | 2445 |
app.post_json('/api/users/', headers=headers, params=payload, status=400) |
2444 | 2446 | |
2445 |
payload['phone'] = '1#2' |
|
2447 |
payload['extra_phone'] = '1#2'
|
|
2446 | 2448 |
app.post_json('/api/users/', headers=headers, params=payload, status=400) |
2447 | 2449 | |
2448 | 2450 |
tests/idp_oidc/test_misc.py | ||
---|---|---|
1167 | 1167 |
assert claims['given_name'] == simple_user.first_name |
1168 | 1168 |
assert claims['family_name'] == simple_user.last_name |
1169 | 1169 |
assert claims['email'] == simple_user.email |
1170 |
assert claims['phone'] is None
|
|
1170 |
assert claims['phone'] == simple_user.phone
|
|
1171 | 1171 |
assert claims['email_verified'] is False |
1172 | 1172 | |
1173 | 1173 |
assert user_info['sub'] == make_sub(oidc_client, simple_user) |
... | ... | |
1175 | 1175 |
assert user_info['given_name'] == simple_user.first_name |
1176 | 1176 |
assert user_info['family_name'] == simple_user.last_name |
1177 | 1177 |
assert user_info['email'] == simple_user.email |
1178 |
assert user_info['phone'] is None
|
|
1178 |
assert user_info['phone'] == simple_user.phone
|
|
1179 | 1179 |
assert user_info['email_verified'] is False |
1180 | 1180 | |
1181 | 1181 |
params['scope'] = 'openid email' |
tests/test_all.py | ||
---|---|---|
73 | 73 |
'email_verified_date': None, |
74 | 74 |
'username': 'john.doe', |
75 | 75 |
'email': '', |
76 |
'phone': None, |
|
77 |
'phone_verified_on': None, |
|
76 | 78 |
'first_name': '', |
77 | 79 |
'last_name': '', |
78 | 80 |
'is_active': True, |
79 |
- |