Compare commits
19 Commits
c1fefb0341
...
ad67e67417
Author | SHA1 | Date |
---|---|---|
Paul Marillonnet | ad67e67417 | |
Benjamin Dauvergne | 2b3d04a6d1 | |
Paul Marillonnet | 1d966eab30 | |
Paul Marillonnet | f3e57d5089 | |
Paul Marillonnet | a6df1a2750 | |
Paul Marillonnet | fe998389ec | |
Paul Marillonnet | 6a7a4814a9 | |
Paul Marillonnet | 0abfbc9480 | |
Benjamin Dauvergne | d0420218bb | |
Thomas NOËL | 16b714c01f | |
Thomas NOËL | 67674f56f9 | |
Emmanuel Cazenave | 36bc55d3ce | |
Benjamin Dauvergne | 4dc8f6aab7 | |
Yann Weber | 95701c5e76 | |
Yann Weber | 0334e56117 | |
Yann Weber | 634211e1b7 | |
Yann Weber | cbbc6b78c4 | |
Yann Weber | 137a5898b4 | |
Yann Weber | 891dd6a1de |
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 3.2.23 on 2024-03-28 16:50
|
||||
|
||||
import django.contrib.postgres.indexes
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.text
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('a2_rbac', '0039_set_user_view_permissions_by_ou'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='role',
|
||||
index=django.contrib.postgres.indexes.GinIndex(
|
||||
django.contrib.postgres.indexes.OpClass(
|
||||
django.db.models.functions.text.Upper(
|
||||
django.db.models.expressions.Func(
|
||||
django.db.models.expressions.F('name'), function='public.immutable_unaccent'
|
||||
)
|
||||
),
|
||||
'public.gin_trgm_ops',
|
||||
),
|
||||
name='name_idx',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -23,10 +23,12 @@ from django.conf import settings
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres import indexes as postgresql_indexes
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models.functions import Upper
|
||||
from django.db.models.query import Prefetch, Q
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
|
@ -614,6 +616,15 @@ class Role(AbstractBase):
|
|||
condition=models.Q(service__isnull=True, ou__isnull=True, admin_scope_ct__isnull=True),
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
postgresql_indexes.GinIndex(
|
||||
postgresql_indexes.OpClass(
|
||||
Upper(models.Func(models.F('name'), function='public.immutable_unaccent')),
|
||||
'public.gin_trgm_ops',
|
||||
),
|
||||
name='name_idx',
|
||||
),
|
||||
]
|
||||
|
||||
def natural_key(self):
|
||||
return [
|
||||
|
|
|
@ -68,7 +68,7 @@ from .journal_event_types import (
|
|||
UserNotificationInactivity,
|
||||
UserRegistration,
|
||||
)
|
||||
from .models import APIClient, Attribute, PasswordReset, Service
|
||||
from .models import APIClient, Attribute, AttributeValue, PasswordReset, Service
|
||||
from .passwords import get_password_checker, get_password_strength
|
||||
from .utils import hooks
|
||||
from .utils import misc as utils_misc
|
||||
|
@ -434,6 +434,26 @@ class BaseUserSerializer(serializers.ModelSerializer):
|
|||
hasher.safe_summary(attrs.get('hashed_password'))
|
||||
except Exception:
|
||||
errors['hashed_password'] = 'hash format error'
|
||||
authenticator = utils_misc.get_password_authenticator()
|
||||
if authenticator.is_phone_authn_active and (
|
||||
value := attrs.get('attributes', {}).get(authenticator.phone_identifier_field.name, None)
|
||||
):
|
||||
qs = AttributeValue.objects.filter(
|
||||
attribute=authenticator.phone_identifier_field,
|
||||
content=value,
|
||||
)
|
||||
if self.instance:
|
||||
qs.exclude(object_id=self.instance.id)
|
||||
# manage ou- or global-uniqueness settings
|
||||
ou = attrs.get('ou', None) or get_default_ou()
|
||||
if not app_settings.A2_PHONE_IS_UNIQUE:
|
||||
if not ou.phone_is_unique:
|
||||
qs = qs.none()
|
||||
else:
|
||||
qs = qs.filter(object_id__in=User.objects.filter(ou=ou))
|
||||
if qs.exists():
|
||||
errors['attributes'] = _('This phone number identifier is already used.')
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
return attrs
|
||||
|
|
|
@ -177,7 +177,6 @@ default_settings = dict(
|
|||
default=True, definition='Check username uniqueness on registration'
|
||||
),
|
||||
IDP_BACKENDS=(),
|
||||
VALID_REFERERS=Setting(default=(), definition='List of prefix to match referers'),
|
||||
A2_OPENED_SESSION_COOKIE_NAME=Setting(default='A2_OPENED_SESSION', definition='Authentic session open'),
|
||||
A2_OPENED_SESSION_COOKIE_DOMAIN=Setting(default=None),
|
||||
A2_ATTRIBUTE_KINDS=Setting(default=(), definition='List of other attribute kinds'),
|
||||
|
@ -285,6 +284,9 @@ default_settings = dict(
|
|||
definition='Set a random password on request to reset the password from the front-office',
|
||||
),
|
||||
A2_ACCOUNTS_URL=Setting(default=None, definition='IdP has no account page, redirect to this one.'),
|
||||
A2_ACCOUNTS_DISPLAY_COMPLETION_RATIO=Setting(
|
||||
default=False, definition='Display user\'s profile completion ratio.'
|
||||
),
|
||||
A2_CACHE_ENABLED=Setting(default=True, definition='Disable all cache decorators for testing purpose.'),
|
||||
A2_ALLOW_PHONE_AUTHN_MANAGEMENT=Setting(
|
||||
default=False,
|
||||
|
|
|
@ -90,6 +90,7 @@ class LoginPasswordAuthenticatorAdvancedForm(forms.ModelForm):
|
|||
'accept_email_authentication',
|
||||
'accept_phone_authentication',
|
||||
'phone_identifier_field',
|
||||
'sms_code_duration',
|
||||
):
|
||||
del self.fields[field]
|
||||
|
||||
|
@ -111,6 +112,7 @@ class LoginPasswordAuthenticatorAdvancedForm(forms.ModelForm):
|
|||
'accept_email_authentication',
|
||||
'accept_phone_authentication',
|
||||
'phone_identifier_field',
|
||||
'sms_code_duration',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 3.2.18 on 2024-04-03 08:36
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
import authentic2.apps.authenticators.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('authenticators', '0019_fix_addroleaction_condition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='loginpasswordauthenticator',
|
||||
name='sms_code_duration',
|
||||
field=models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
help_text=authentic2.apps.authenticators.models.sms_code_duration_help_text,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(
|
||||
60, 'Ensure that this value is higher than 60, or leave blank for default value.'
|
||||
),
|
||||
django.core.validators.MaxValueValidator(
|
||||
3600, 'Ensure that this value is lower than 3600, or leave blank for default value.'
|
||||
),
|
||||
],
|
||||
verbose_name='SMS codes lifetime (in seconds)',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -19,7 +19,9 @@ import logging
|
|||
import uuid
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Max
|
||||
from django.shortcuts import render, reverse
|
||||
|
@ -135,10 +137,28 @@ class BaseAuthenticator(models.Model):
|
|||
value=value,
|
||||
)
|
||||
|
||||
def is_for_office(self, office_keyword, ctx):
|
||||
try:
|
||||
return evaluate_condition(
|
||||
settings.AUTHENTICATOR_SHOW_CONDITIONS[office_keyword], ctx, on_raise=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return False
|
||||
|
||||
def shown(self, ctx=()):
|
||||
if not self.show_condition:
|
||||
return True
|
||||
ctx = dict(ctx, id=self.slug)
|
||||
|
||||
def is_for_backoffice():
|
||||
return self.is_for_office('is_for_backoffice', ctx)
|
||||
|
||||
def is_for_frontoffice():
|
||||
return self.is_for_office('is_for_frontoffice', ctx)
|
||||
|
||||
ctx = dict(
|
||||
ctx, id=self.slug, is_for_backoffice=is_for_backoffice, is_for_frontoffice=is_for_frontoffice
|
||||
)
|
||||
try:
|
||||
return evaluate_condition(self.show_condition, ctx, on_raise=True)
|
||||
except Exception as e:
|
||||
|
@ -214,6 +234,12 @@ class BaseAuthenticator(models.Model):
|
|||
return authenticator, created
|
||||
|
||||
|
||||
def sms_code_duration_help_text():
|
||||
return _(
|
||||
f'Time (in seconds, between 60 and 3600) after which SMS codes expire. Default is {settings.SMS_CODE_DURATION}.'
|
||||
)
|
||||
|
||||
|
||||
class AuthenticatorRelatedObjectBase(models.Model):
|
||||
authenticator = models.ForeignKey(BaseAuthenticator, on_delete=models.CASCADE)
|
||||
|
||||
|
@ -407,6 +433,20 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
|
|||
max_length=32,
|
||||
help_text=_('Maximum rate of SMSs sent to the same phone number.'),
|
||||
)
|
||||
sms_code_duration = models.PositiveSmallIntegerField(
|
||||
_('SMS codes lifetime (in seconds)'),
|
||||
help_text=sms_code_duration_help_text,
|
||||
validators=[
|
||||
MinValueValidator(
|
||||
60, _('Ensure that this value is higher than 60, or leave blank for default value.')
|
||||
),
|
||||
MaxValueValidator(
|
||||
3600, _('Ensure that this value is lower than 3600, or leave blank for default value.')
|
||||
),
|
||||
],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
type = 'password'
|
||||
how = ['password', 'password-on-https']
|
||||
|
|
|
@ -148,22 +148,23 @@ def get_title_choices():
|
|||
|
||||
|
||||
def validate_phone_number(value):
|
||||
default_country = settings.PHONE_COUNTRY_CODES[settings.DEFAULT_COUNTRY_CODE]['region']
|
||||
conf = []
|
||||
for conf_key in (
|
||||
'region',
|
||||
'region_desc',
|
||||
'example_value',
|
||||
):
|
||||
conf.append(settings.PHONE_COUNTRY_CODES[settings.DEFAULT_COUNTRY_CODE].get(conf_key))
|
||||
try:
|
||||
phonenumbers.parse(value)
|
||||
except phonenumbers.NumberParseException:
|
||||
try:
|
||||
phonenumbers.parse(
|
||||
value,
|
||||
default_country,
|
||||
conf[0],
|
||||
)
|
||||
except phonenumbers.NumberParseException:
|
||||
raise ValidationError(
|
||||
_(
|
||||
'Phone number must be either in E.164 globally unique format or dialable from'
|
||||
' {code} country code ({country}).'
|
||||
).format(code=settings.DEFAULT_COUNTRY_CODE, country=default_country)
|
||||
)
|
||||
raise ValidationError(_(f'Phone number must be dialable from {conf[1]} (e.g. {conf[2]}).'))
|
||||
|
||||
|
||||
french_validate_phone_number = RegexValidator(
|
||||
|
|
|
@ -140,8 +140,12 @@ class ValidatedEmailField(EmailField):
|
|||
widget = EmailInput
|
||||
|
||||
def __init__(self, *args, max_length=254, **kwargs):
|
||||
# pylint: disable=useless-super-delegation
|
||||
super().__init__(*args, max_length=max_length, **kwargs)
|
||||
error_messages = kwargs.pop('error_messages', {})
|
||||
if 'invalid' not in error_messages:
|
||||
error_messages['invalid'] = _(
|
||||
'Please enter a valid email address (example: john.doe@entrouvert.com)'
|
||||
)
|
||||
super().__init__(*args, max_length=max_length, error_messages=error_messages, **kwargs)
|
||||
|
||||
|
||||
class RoleChoiceField(ModelChoiceField):
|
||||
|
@ -220,15 +224,29 @@ class PhoneField(MultiValueField):
|
|||
country_code = data_list[0]
|
||||
data_list[0] = '+%s' % data_list[0]
|
||||
data_list[1] = clean_number(data_list[1])
|
||||
dial = (
|
||||
settings.PHONE_COUNTRY_CODES.get(country_code, {}).get('region', None)
|
||||
or settings.PHONE_COUNTRY_CODES[settings.DEFAULT_COUNTRY_CODE]['region']
|
||||
)
|
||||
|
||||
conf = []
|
||||
for conf_key in (
|
||||
'region',
|
||||
'region_desc',
|
||||
'example_value',
|
||||
):
|
||||
conf.append(
|
||||
settings.PHONE_COUNTRY_CODES.get(country_code, {}).get(conf_key, None)
|
||||
or settings.PHONE_COUNTRY_CODES[settings.DEFAULT_COUNTRY_CODE][conf_key]
|
||||
)
|
||||
if all(conf[1:]):
|
||||
validation_error_message = _(
|
||||
f'Invalid phone number. Phone number from {conf[1]} must respect local format (e.g. {conf[2]}).'
|
||||
)
|
||||
else:
|
||||
# missing human-friendly config elements, can't provide a clearer validation error message:
|
||||
validation_error_message = _('Invalid phone number.')
|
||||
try:
|
||||
pn = phonenumbers.parse(''.join(data_list), dial)
|
||||
pn = phonenumbers.parse(''.join(data_list), conf[0])
|
||||
except phonenumbers.NumberParseException:
|
||||
raise ValidationError(_('Invalid phone number.'))
|
||||
raise ValidationError(validation_error_message)
|
||||
if not phonenumbers.is_valid_number(pn):
|
||||
raise ValidationError(_('Invalid phone number.'))
|
||||
raise ValidationError(validation_error_message)
|
||||
return phonenumbers.format_number(pn, phonenumbers.PhoneNumberFormat.E164)
|
||||
return ''
|
||||
|
|
|
@ -45,7 +45,7 @@ class RegistrationForm(HoneypotForm):
|
|||
|
||||
email = ValidatedEmailField(
|
||||
label=_('Email'),
|
||||
help_text=_('Your email address'),
|
||||
help_text=_('Your email address (example: name@example.com)'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: Authentic\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-02-15 17:50+0100\n"
|
||||
"POT-Creation-Date: 2024-04-04 10:57+0200\n"
|
||||
"PO-Revision-Date: 2024-02-15 17:50+0100\n"
|
||||
"Last-Translator: Mikaël Ates <mates@entrouvert.com>\n"
|
||||
"Language-Team: None\n"
|
||||
|
@ -479,6 +479,10 @@ msgstr "L’identifiant est obligatoire"
|
|||
msgid "Username is required in this ou"
|
||||
msgstr "L’identifiant est obligatoire dans cette collectivité."
|
||||
|
||||
#: src/authentic2/api_views.py src/authentic2/manager/forms.py
|
||||
msgid "This phone number identifier is already used."
|
||||
msgstr "Ce numéro de téléphone identifiant est déjà utilisé."
|
||||
|
||||
#: src/authentic2/api_views.py
|
||||
msgid "Multiple roles found"
|
||||
msgstr "Plusieurs rôles correspondent au slug"
|
||||
|
@ -745,8 +749,8 @@ msgstr "Modification d’un objet lié à un moyen d’authentification"
|
|||
#: src/authentic2/apps/authenticators/journal_event_types.py
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"edit of object \"{related_object}\" in authenticator "
|
||||
"\"{authenticator}\" ({change})"
|
||||
"edit of object \"{related_object}\" in authenticator \"{authenticator}\" "
|
||||
"({change})"
|
||||
msgstr ""
|
||||
"modification d’un objet « {related_object} » dans le moyen "
|
||||
"d’authentification « {authenticator} » ({change})"
|
||||
|
@ -865,6 +869,15 @@ msgstr "Collectivité introuvable : %s."
|
|||
msgid "Missing slug."
|
||||
msgstr "Slug absent."
|
||||
|
||||
#: src/authentic2/apps/authenticators/models.py
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Time (in seconds, between 60 and 3600) after which SMS codes expire. Default "
|
||||
"is {settings.SMS_CODE_DURATION}."
|
||||
msgstr ""
|
||||
"Laps de temps (en secondes, entre 60 et 3600) après lequel le code SMS n’est "
|
||||
"plus valide. La valeur par défaut est {settings.SMS_CODE_DURATION}."
|
||||
|
||||
#: src/authentic2/apps/authenticators/models.py
|
||||
msgid "Role"
|
||||
msgstr "Rôle"
|
||||
|
@ -1092,6 +1105,24 @@ msgstr "Rate limit des SMS envoyés, par numéro"
|
|||
msgid "Maximum rate of SMSs sent to the same phone number."
|
||||
msgstr "Nombre maximum d’envois vers un même numéro, en fonction du temps."
|
||||
|
||||
#: src/authentic2/apps/authenticators/models.py
|
||||
msgid "SMS codes lifetime (in seconds)"
|
||||
msgstr "Durée de vie des codes SMS (en secondes)"
|
||||
|
||||
#: src/authentic2/apps/authenticators/models.py
|
||||
msgid ""
|
||||
"Ensure that this value is higher than 60, or leave blank for default value."
|
||||
msgstr ""
|
||||
"Assurez-vous que cette valeur est supérieure 60, ou laissez-la vide pour "
|
||||
"recourir à la valeur par défaut."
|
||||
|
||||
#: src/authentic2/apps/authenticators/models.py
|
||||
msgid ""
|
||||
"Ensure that this value is lower than 3600, or leave blank for default value."
|
||||
msgstr ""
|
||||
"Assurez-vous que cette valeur est inférieure à 3600, ou laissez-la vide pour "
|
||||
"recourir à la valeur par défaut."
|
||||
|
||||
#: src/authentic2/apps/authenticators/models.py
|
||||
#: src/authentic2_auth_oidc/models.py src/authentic2_auth_saml/models.py
|
||||
msgid "Advanced"
|
||||
|
@ -1490,12 +1521,10 @@ msgstr ""
|
|||
|
||||
#: src/authentic2/attribute_kinds.py
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Phone number must be either in E.164 globally unique format or dialable from "
|
||||
"{code} country code ({country})."
|
||||
msgid "Phone number must be dialable from {conf[1]} (e.g. {conf[2]})."
|
||||
msgstr ""
|
||||
"Un numéro de téléphone doit soit être au format international, soit être "
|
||||
"composable depuis le pays de préfixe {code} ({country})."
|
||||
"Le numéro de téléphone doit être composable en {conf[1]} (par exemple "
|
||||
"{conf[2]})."
|
||||
|
||||
#: src/authentic2/attribute_kinds.py
|
||||
msgid "A french phone number must start with a zero then another nine digits."
|
||||
|
@ -2279,11 +2308,26 @@ msgstr "Les mots de passe ne sont pas identiques."
|
|||
msgid "The image is not valid"
|
||||
msgstr "L’image n’est pas valide"
|
||||
|
||||
#: src/authentic2/forms/fields.py
|
||||
msgid "Please enter a valid email address (example: john.doe@entrouvert.com)"
|
||||
msgstr ""
|
||||
"Veuillez indiquer une adresse courriel valide (exemple : jean."
|
||||
"dupont@access43.net)"
|
||||
|
||||
#: src/authentic2/forms/fields.py
|
||||
#, python-brace-format
|
||||
msgid "Item {0} is invalid: {1}"
|
||||
msgstr "L’élément {0} est invalide : {1}"
|
||||
|
||||
#: src/authentic2/forms/fields.py
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Invalid phone number. Phone number from {conf[1]} must respect local format "
|
||||
"(e.g. {conf[2]})."
|
||||
msgstr ""
|
||||
"Numéro de téléphone invalide. Un numéro de téléphone de {conf[1]} doit "
|
||||
"respecter son format local (par exemple {conf[2]})."
|
||||
|
||||
#: src/authentic2/forms/fields.py
|
||||
msgid "Invalid phone number."
|
||||
msgstr "Numéro de téléphone invalide."
|
||||
|
@ -2363,8 +2407,8 @@ msgid "A user with that username already exists."
|
|||
msgstr "Un utilisateur avec cet identifiant existe déjà."
|
||||
|
||||
#: src/authentic2/forms/registration.py
|
||||
msgid "Your email address"
|
||||
msgstr "Votre adresse courriel"
|
||||
msgid "Your email address (example: name@example.com)"
|
||||
msgstr "Votre adresse courriel (nom@example.net)"
|
||||
|
||||
#: src/authentic2/forms/registration.py
|
||||
msgid "You cannot register with this email."
|
||||
|
@ -2810,10 +2854,6 @@ msgstr "Ajouter des utilisateurs"
|
|||
msgid "Add some roles"
|
||||
msgstr "Ajouter des rôles"
|
||||
|
||||
#: src/authentic2/manager/forms.py
|
||||
msgid "This phone number identifier is already used."
|
||||
msgstr "Ce numéro de téléphone identifiant est déjà utilisé."
|
||||
|
||||
#: src/authentic2/manager/forms.py
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
|
@ -7073,12 +7113,13 @@ msgid "The form was out of date, please try again."
|
|||
msgstr "Ce formulaire était périmé, veuillez ré-essayer."
|
||||
|
||||
#: src/authentic2/validators.py
|
||||
msgid "Invalid email address."
|
||||
msgid "Invalid email address"
|
||||
msgstr "Adresse de courriel invalide."
|
||||
|
||||
#: src/authentic2/validators.py
|
||||
msgid "Email domain is invalid"
|
||||
msgstr "Le domaine de l’adresse de courriel est invalide."
|
||||
#, python-format
|
||||
msgid "Email domain (%(dom)s) does not exists"
|
||||
msgstr "Le nom de domaine (%(dom)s) du courriel n’existe pas"
|
||||
|
||||
#: src/authentic2/validators.py
|
||||
msgid "Null characters are not allowed."
|
||||
|
@ -7314,8 +7355,8 @@ msgid "SMS code validation"
|
|||
msgstr "Validation du code SMS"
|
||||
|
||||
#: src/authentic2/views.py
|
||||
msgid "Invalid request"
|
||||
msgstr "Requête invalide"
|
||||
msgid "Invalid token"
|
||||
msgstr "Token invalide"
|
||||
|
||||
#: src/authentic2/views.py
|
||||
msgid "Wrong SMS code."
|
||||
|
|
|
@ -405,7 +405,7 @@ class ServiceRoleSearchForm(CssClass, PrefixFormMixin, FormWithRequest):
|
|||
for word in (w.strip() for w in self.cleaned_data.get('text').split(' ')):
|
||||
if not word:
|
||||
continue
|
||||
qs = qs.filter(name__icontains=word)
|
||||
qs = qs.filter(name__immutable_unaccent__icontains=word)
|
||||
if not app_settings.SHOW_INTERNAL_ROLES and not self.cleaned_data.get('internals'):
|
||||
qs = qs.exclude(slug__startswith='_')
|
||||
return qs
|
||||
|
|
|
@ -20,7 +20,7 @@ from functools import reduce
|
|||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.exceptions import BadRequest, PermissionDenied, ValidationError
|
||||
from django.core.paginator import EmptyPage, Paginator
|
||||
from django.db import transaction
|
||||
from django.db.models import BooleanField, Count, ExpressionWrapper, F, Prefetch, Q, Value
|
||||
|
@ -772,6 +772,8 @@ class UserOrRoleSelect2View(DetailView):
|
|||
role = self.get_object()
|
||||
|
||||
field_id = self.kwargs.get('field_id', self.request.GET.get('field_id', None))
|
||||
if not field_id:
|
||||
raise BadRequest('Invalid ID')
|
||||
try:
|
||||
crypto.loads(field_id)
|
||||
except (crypto.SignatureExpired, crypto.BadSignature):
|
||||
|
|
|
@ -384,6 +384,7 @@ class InheritanceRolesTable(Table):
|
|||
'<input class="role-member{% if record.indeterminate %} indeterminate{% endif %}" name="role-{{ record.pk }}" '
|
||||
'type="checkbox" {% if record.checked %}checked{% endif %}/>',
|
||||
verbose_name='',
|
||||
orderable=False,
|
||||
attrs={'td': {'class': 'member'}},
|
||||
)
|
||||
|
||||
|
|
|
@ -32,7 +32,9 @@
|
|||
|
||||
{% block sidebar %}
|
||||
<aside id="sidebar">
|
||||
<h3>{% blocktrans %}Configuration & technical information{% endblocktrans %}</h3>
|
||||
{% if sidebar_entries %}
|
||||
<h3>{% blocktrans %}Configuration & technical information{% endblocktrans %}</h3>
|
||||
{% endif %}
|
||||
<div class="a2-manager-id-tools_content">
|
||||
{% for entry in sidebar_entries %}
|
||||
<a id="{{ entry.slug }}" class="button button-paragraph" href="{{ entry.href }}">{{ entry.label }}</a>
|
||||
|
|
|
@ -22,7 +22,7 @@ import pickle
|
|||
import random
|
||||
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.exceptions import BadRequest, PermissionDenied, ValidationError
|
||||
from django.db import transaction
|
||||
from django.forms import MediaDefiningClass
|
||||
from django.http import Http404, HttpResponse
|
||||
|
@ -837,10 +837,13 @@ class Select2View(AutoResponseView):
|
|||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'filter_by_perm'):
|
||||
raise Http404('Invalid user')
|
||||
field_data = self.kwargs.get('field_id', self.request.GET.get('field_id', None))
|
||||
if not field_data:
|
||||
raise BadRequest('Invalid ID')
|
||||
try:
|
||||
field_data = crypto.loads(field_data)
|
||||
except (crypto.SignatureExpired, crypto.BadSignature):
|
||||
raise Http404('Invalid or expired signature.')
|
||||
|
||||
widget_class = field_data.get('class')
|
||||
if not widget_class or not hasattr(widgets, widget_class):
|
||||
raise Http404('Missing or unknown widget class.')
|
||||
|
|
|
@ -43,6 +43,7 @@ from authentic2.a2_rbac.models import Role
|
|||
from authentic2.a2_rbac.utils import get_default_ou_pk
|
||||
from authentic2.custom_user.backends import DjangoRBACBackend
|
||||
from authentic2.utils.crypto import base64url_decode, base64url_encode
|
||||
from authentic2.utils.misc import get_password_authenticator
|
||||
from authentic2.validators import HexaColourValidator, PhoneNumberValidator
|
||||
|
||||
# install our natural_key implementation
|
||||
|
@ -818,7 +819,6 @@ class APIClient(models.Model):
|
|||
|
||||
|
||||
class SMSCode(models.Model):
|
||||
CODE_DURATION = 120
|
||||
KIND_REGISTRATION = 'registration'
|
||||
KIND_PASSWORD_LOST = 'password-reset'
|
||||
KIND_PHONE_CHANGE = 'phone-change'
|
||||
|
@ -860,7 +860,7 @@ class SMSCode(models.Model):
|
|||
if not kind:
|
||||
kind = cls.KIND_REGISTRATION
|
||||
if not duration:
|
||||
duration = cls.CODE_DURATION
|
||||
duration = get_password_authenticator().sms_code_duration or settings.SMS_CODE_DURATION
|
||||
expires = expires or (timezone.now() + datetime.timedelta(seconds=duration))
|
||||
return cls.objects.create(kind=kind, user=user, phone=phone, expires=expires, fake=fake)
|
||||
|
||||
|
|
|
@ -387,6 +387,7 @@ SMS_URL = ''
|
|||
# allowed character set in SMS codes, without visually ambiguous characters (no '0' or 'O', and no '1', 'I' or 'L').
|
||||
SMS_CODE_ALLOWED_CHARACTERS = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'
|
||||
SMS_CODE_LENGTH = 8
|
||||
SMS_CODE_DURATION = 180
|
||||
|
||||
# Get select2 from local copy.
|
||||
SELECT2_JS = '/static/xstatic/select2.min.js'
|
||||
|
@ -394,17 +395,50 @@ SELECT2_CSS = '/static/xstatic/select2.min.css'
|
|||
|
||||
# Phone prefixes by country for phone number as authentication identifier
|
||||
PHONE_COUNTRY_CODES = {
|
||||
'32': {'region': 'BE', 'region_desc': _('Belgium')},
|
||||
'33': {'region': 'FR', 'region_desc': _('Metropolitan France')},
|
||||
'262': {'region': 'RE', 'region_desc': _('Réunion')},
|
||||
'508': {'region': 'PM', 'region_desc': _('Saint Pierre and Miquelon')},
|
||||
'590': {'region': 'GP', 'region_desc': _('Guadeloupe')},
|
||||
'594': {'region': 'GF', 'region_desc': _('French Guiana')},
|
||||
'596': {'region': 'MQ', 'region_desc': _('Martinique')},
|
||||
'32': {
|
||||
'region': 'BE',
|
||||
'region_desc': _('Belgium'),
|
||||
'example_value': '042 11 22 33',
|
||||
},
|
||||
'33': {
|
||||
'region': 'FR',
|
||||
'region_desc': _('Metropolitan France'),
|
||||
'example_value': '06 39 98 01 23',
|
||||
},
|
||||
'262': {
|
||||
'region': 'RE',
|
||||
'region_desc': _('Réunion'),
|
||||
'example_value': '06 39 98 01 23',
|
||||
},
|
||||
'508': {
|
||||
'region': 'PM',
|
||||
'region_desc': _('Saint Pierre and Miquelon'),
|
||||
'example_value': '06 39 98 01 23',
|
||||
},
|
||||
'590': {
|
||||
'region': 'GP',
|
||||
'region_desc': _('Guadeloupe'),
|
||||
'example_value': '06 39 98 01 23',
|
||||
},
|
||||
'594': {
|
||||
'region': 'GF',
|
||||
'region_desc': _('French Guiana'),
|
||||
'example_value': '06 39 98 01 23',
|
||||
},
|
||||
'596': {
|
||||
'region': 'MQ',
|
||||
'region_desc': _('Martinique'),
|
||||
'example_value': '06 39 98 01 23',
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_COUNTRY_CODE = '33'
|
||||
|
||||
AUTHENTICATOR_SHOW_CONDITIONS = {
|
||||
'is_for_backoffice': "'backoffice' in login_hint",
|
||||
'is_for_frontoffice': "'backoffice' not in login_hint",
|
||||
}
|
||||
|
||||
#
|
||||
# Load configuration file
|
||||
#
|
||||
|
|
|
@ -47,6 +47,12 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
<div id="a2-profile" class="a2-profile-block">
|
||||
{% if completion_ratio is not None %}
|
||||
<div id="a2-profile-completion-ratio">
|
||||
{% widthratio completion_ratio 1 100 as completion_percent %}
|
||||
{% blocktranslate %}You have completed {{ completion_percent }}% of your user profile.{% endblocktranslate %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if attributes %}
|
||||
<dl>
|
||||
{% for attribute in attributes %}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
{% block registration %}
|
||||
<div>
|
||||
<form enctype="multipart/form-data" method="post" class="pk-mark-optional-fields">
|
||||
<form enctype="multipart/form-data" method="post" class="pk-mark-optional-fields" novalidate>
|
||||
{% csrf_token %}
|
||||
{{ form|with_template }}
|
||||
<div class="buttons">
|
||||
|
|
|
@ -50,7 +50,6 @@ accounts_urlpatterns = [
|
|||
views.ValidateDeletionView.as_view(),
|
||||
name='validate_deletion',
|
||||
),
|
||||
path('logged-in/', views.logged_in, name='logged-in'),
|
||||
path('edit/', views.edit_profile, name='profile_edit'),
|
||||
path('edit/required/', views.edit_required_profile, name='profile_required_edit'),
|
||||
re_path(r'^edit/(?P<scope>[-\w]+)/$', views.edit_profile, name='profile_edit_with_scope'),
|
||||
|
|
|
@ -1,10 +1,21 @@
|
|||
from django.contrib.postgres.lookups import Unaccent as PGUnaccent
|
||||
from django.db.models import CharField, TextField, Transform
|
||||
from django.db.models.functions import Concat
|
||||
from django.db.models.functions import ConcatPair as DjConcatPair
|
||||
|
||||
|
||||
class Unaccent(PGUnaccent):
|
||||
function = 'immutable_unaccent'
|
||||
function = 'public.immutable_unaccent'
|
||||
|
||||
|
||||
class UnaccentTransform(Transform):
|
||||
bilateral = True
|
||||
lookup_name = 'immutable_unaccent'
|
||||
function = 'public.immutable_unaccent'
|
||||
|
||||
|
||||
CharField.register_lookup(UnaccentTransform)
|
||||
TextField.register_lookup(UnaccentTransform)
|
||||
|
||||
|
||||
class ConcatPair(DjConcatPair):
|
||||
|
|
|
@ -70,7 +70,7 @@ class EmailValidator:
|
|||
smtp.mail('')
|
||||
status = smtp.rcpt(value)
|
||||
if status[0] // 100 == 5:
|
||||
raise ValidationError(_('Invalid email address.'), code='rcpt-check-failed')
|
||||
raise ValidationError(_('Invalid email address'), code='invalid-fails-rcpt')
|
||||
break
|
||||
except smtplib.SMTPServerDisconnected:
|
||||
continue
|
||||
|
@ -88,7 +88,9 @@ class EmailValidator:
|
|||
if app_settings.A2_VALIDATE_EMAIL_DOMAIN:
|
||||
mxs = self.query_mxs(hostname)
|
||||
if not mxs:
|
||||
raise ValidationError(_('Email domain is invalid'), code='invalid-domain')
|
||||
raise ValidationError(
|
||||
_('Email domain (%(dom)s) does not exists') % {'dom': hostname}, code='invalid-domain'
|
||||
)
|
||||
if self.rcpt_check and app_settings.A2_VALIDATE_EMAIL:
|
||||
self.check_rcpt(value, mxs)
|
||||
|
||||
|
|
|
@ -33,13 +33,7 @@ from django.db.models import Count
|
|||
from django.db.models.query import Q
|
||||
from django.db.transaction import atomic
|
||||
from django.forms import CharField
|
||||
from django.http import (
|
||||
Http404,
|
||||
HttpResponse,
|
||||
HttpResponseBadRequest,
|
||||
HttpResponseForbidden,
|
||||
HttpResponseRedirect,
|
||||
)
|
||||
from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.template import loader
|
||||
from django.template.loader import render_to_string
|
||||
|
@ -827,6 +821,29 @@ class ProfileView(HomeURLMixin, cbv.TemplateNamesMixin, TemplateView):
|
|||
and authenticator.phone_identifier_field.user_editable
|
||||
and not authenticator.phone_identifier_field.disabled
|
||||
)
|
||||
|
||||
completion_ratio = None
|
||||
if app_settings.A2_ACCOUNTS_DISPLAY_COMPLETION_RATIO and (
|
||||
total_attrs := models.Attribute.objects.filter(
|
||||
disabled=False,
|
||||
user_visible=True,
|
||||
user_editable=True,
|
||||
)
|
||||
):
|
||||
total_count = total_attrs.count()
|
||||
filled_attrs_count = (
|
||||
models.AttributeValue.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(get_user_model()),
|
||||
object_id=self.request.user.id,
|
||||
attribute_id__in=total_attrs,
|
||||
content__isnull=False,
|
||||
)
|
||||
.order_by('attribute_id')
|
||||
.distinct('attribute_id')
|
||||
.count()
|
||||
)
|
||||
completion_ratio = round(filled_attrs_count / total_count, 2)
|
||||
|
||||
context.update(
|
||||
{
|
||||
'frontends_block': blocks,
|
||||
|
@ -841,6 +858,7 @@ class ProfileView(HomeURLMixin, cbv.TemplateNamesMixin, TemplateView):
|
|||
# TODO: deprecated should be removed when publik-base-theme is updated
|
||||
'allow_password_change': utils_misc.user_can_change_password(request=request),
|
||||
'federation_management': federation_management,
|
||||
'completion_ratio': completion_ratio,
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -1074,30 +1092,6 @@ def login_password_profile(request, *args, **kwargs):
|
|||
)
|
||||
|
||||
|
||||
class LoggedInView(View):
|
||||
'''JSONP web service to detect if an user is logged'''
|
||||
|
||||
http_method_names = ['get']
|
||||
|
||||
def check_referrer(self):
|
||||
'''Check if the given referer is authorized'''
|
||||
referer = self.request.headers.get('Referer', '')
|
||||
for valid_referer in app_settings.VALID_REFERERS:
|
||||
if referer.startswith(valid_referer):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if not self.check_referrer():
|
||||
return HttpResponseForbidden()
|
||||
callback = request.GET.get('callback')
|
||||
content = f'{callback}({int(request.user.is_authenticated)})'
|
||||
return HttpResponse(content, content_type='application/json')
|
||||
|
||||
|
||||
logged_in = never_cache(LoggedInView.as_view())
|
||||
|
||||
|
||||
def csrf_failure_view(request, reason=''):
|
||||
messages.warning(request, _('The page is out of date, it was reloaded for you'))
|
||||
return HttpResponseRedirect(request.get_full_path())
|
||||
|
@ -1627,6 +1621,7 @@ class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
|
|||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
token = kwargs['token']
|
||||
self.authenticator = utils_misc.get_password_authenticator()
|
||||
try:
|
||||
self.code = models.SMSCode.objects.get(url_token=token)
|
||||
except models.SMSCode.DoesNotExist:
|
||||
|
@ -1635,7 +1630,7 @@ class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
|
|||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['duration'] = models.SMSCode.CODE_DURATION // 60
|
||||
ctx['duration'] = (self.authenticator.sms_code_duration or settings.SMS_CODE_DURATION) // 60
|
||||
return ctx
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
|
@ -43,7 +43,7 @@ class OIDCProviderEditForm(forms.ModelForm):
|
|||
|
||||
def save(self, commit=True):
|
||||
super().save(commit=commit)
|
||||
self.instance.log_jwkset_change(self.old_jwkset, self.instance.jwkset_json)
|
||||
self.instance.log_jwkset_change(self.old_jwkset, self.instance.jwkset_json or {})
|
||||
|
||||
|
||||
class OIDCProviderAdvancedForm(forms.ModelForm):
|
||||
|
|
|
@ -1441,6 +1441,82 @@ def test_api_authn_healthcheck(app, settings, superuser, simple_user):
|
|||
app.get('/api/authn-healthcheck/', status=403)
|
||||
|
||||
|
||||
def test_api_users_create_phone_identifier_unique(settings, app, admin, phone_activated_authn, simple_user):
|
||||
simple_user.attributes.phone = '+33122334455'
|
||||
simple_user.save()
|
||||
settings.A2_PHONE_IS_UNIQUE = True
|
||||
payload = {
|
||||
'username': 'janedoe',
|
||||
'email': 'jane.doe@nowhere.null',
|
||||
'first_name': 'Jane',
|
||||
'last_name': 'Doe',
|
||||
'email_verified': True,
|
||||
'phone': '+33122334455',
|
||||
}
|
||||
headers = basic_authorization_header(admin)
|
||||
resp = app.post_json('/api/users/', headers=headers, params=payload, status=400)
|
||||
assert resp.json['errors']['attributes'] == ['This phone number identifier is already used.']
|
||||
|
||||
|
||||
def test_api_users_create_phone_identifier_unique_by_ou(
|
||||
settings, app, admin, phone_activated_authn, simple_user, ou1, ou2
|
||||
):
|
||||
ou1.phone_is_unique = ou2.phone_is_unique = True
|
||||
ou1.save()
|
||||
ou2.save()
|
||||
simple_user.attributes.phone = '+33122334455'
|
||||
simple_user.ou = ou1
|
||||
simple_user.save()
|
||||
usercount = User.objects.count()
|
||||
settings.A2_PHONE_IS_UNIQUE = False
|
||||
payload = {
|
||||
'username': 'janedoe',
|
||||
'email': 'jane.doe@nowhere.null',
|
||||
'first_name': 'Jane',
|
||||
'last_name': 'Doe',
|
||||
'ou': 'ou1',
|
||||
'email_verified': True,
|
||||
'phone': '+33122334455',
|
||||
}
|
||||
headers = basic_authorization_header(admin)
|
||||
resp = app.post_json('/api/users/', headers=headers, params=payload, status=400)
|
||||
assert resp.json['errors']['attributes'] == ['This phone number identifier is already used.']
|
||||
assert set(
|
||||
AttributeValue.objects.filter(
|
||||
attribute=phone_activated_authn.phone_identifier_field,
|
||||
content='+33122334455',
|
||||
).values_list('object_id', flat=True)
|
||||
) == {simple_user.id}
|
||||
assert User.objects.count() == usercount
|
||||
|
||||
# change ou, where phone number isn't taken yet
|
||||
payload['ou'] = 'ou2'
|
||||
resp = app.post_json('/api/users/', headers=headers, params=payload, status=201)
|
||||
new_id = resp.json['id']
|
||||
assert new_id != simple_user.id
|
||||
assert set(
|
||||
AttributeValue.objects.filter(
|
||||
attribute=phone_activated_authn.phone_identifier_field,
|
||||
content='+33122334455',
|
||||
).values_list('object_id', flat=True)
|
||||
) == {simple_user.id, new_id}
|
||||
assert User.objects.count() == usercount + 1
|
||||
|
||||
# trying to create yet another user in that same last with the same phone number should fail:
|
||||
payload['username'] = 'bobdoe'
|
||||
payload['email'] = 'bobdoe@nowhere.null'
|
||||
resp = app.post_json('/api/users/', headers=headers, params=payload, status=400)
|
||||
assert resp.json['errors']['attributes'] == ['This phone number identifier is already used.']
|
||||
# no new phone attribute created
|
||||
assert set(
|
||||
AttributeValue.objects.filter(
|
||||
attribute=phone_activated_authn.phone_identifier_field,
|
||||
content='+33122334455',
|
||||
).values_list('object_id', flat=True)
|
||||
) == {simple_user.id, new_id}
|
||||
assert User.objects.count() == usercount + 1
|
||||
|
||||
|
||||
def test_api_users_create_no_phone_model_field_writes(settings, app, admin, phone_activated_authn):
|
||||
payload = {
|
||||
'username': 'janedoe',
|
||||
|
|
|
@ -186,6 +186,36 @@ def test_authenticators_oidc_claims(app, superuser):
|
|||
assert_event('authenticator.related_object.deletion', user=superuser, session=app.session)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_authenticators_oidc_hmac(app, superuser, ou1, ou2, kid_rsa):
|
||||
resp = login(app, superuser, path='/manage/authenticators/')
|
||||
|
||||
resp = resp.click('Add new authenticator')
|
||||
resp.form['name'] = 'Test'
|
||||
resp.form['authenticator'] = 'oidc'
|
||||
resp = resp.form.submit()
|
||||
assert '/edit/' in resp.location
|
||||
|
||||
provider = OIDCProvider.objects.filter(slug='test', ou=get_default_ou()).get()
|
||||
resp = app.get(provider.get_absolute_url())
|
||||
|
||||
resp = resp.click('Edit')
|
||||
resp.form['ou'] = ou1.pk
|
||||
resp.form['issuer'] = 'https://oidc.example.com'
|
||||
resp.form['scopes'] = 'profile email'
|
||||
resp.form['strategy'] = 'create'
|
||||
resp.form['authorization_endpoint'] = 'https://oidc.example.com/authorize'
|
||||
resp.form['token_endpoint'] = 'https://oidc.example.com/token'
|
||||
resp.form['userinfo_endpoint'] = 'https://oidc.example.com/user_info'
|
||||
resp.form['button_label'] = 'Test'
|
||||
resp.form['button_description'] = 'test'
|
||||
resp.form['client_id'] = 'auie'
|
||||
resp.form['client_secret'] = 'tsrn'
|
||||
resp.form['idtoken_algo'].select(text='HMAC')
|
||||
resp = resp.form.submit().follow()
|
||||
assert_event('authenticator.edit', user=superuser, session=app.session)
|
||||
|
||||
|
||||
def test_authenticators_oidc_claims_disabled_attribute(app, superuser):
|
||||
authenticator = OIDCProvider.objects.create(slug='idp1')
|
||||
attr = Attribute.objects.create(kind='string', name='test_attribute', label='Test attribute')
|
||||
|
|
|
@ -224,17 +224,25 @@ def test_change_phone_wrong_input(app, nomail_user, user_ou1, phone_activated_au
|
|||
resp.form.set('phone_1', '12244666')
|
||||
resp.form.set('password', nomail_user.username)
|
||||
resp = resp.form.submit()
|
||||
assert 'Invalid phone number.' in resp.pyquery('.error p')[0].text
|
||||
assert (
|
||||
'Invalid phone number. Phone number from Metropolitan France must respect local format (e.g. 06 39 98 01 23).'
|
||||
) == resp.pyquery('.error p')[0].text_content().strip()
|
||||
|
||||
resp.form.set('phone_0', '32')
|
||||
resp.form.set('phone_1', '12244')
|
||||
resp = resp.form.submit()
|
||||
assert (
|
||||
'Invalid phone number. Phone number from Belgium must respect local format (e.g. 042 11 22 33).'
|
||||
) == resp.pyquery('.error p')[0].text_content().strip()
|
||||
|
||||
assert not SMSCode.objects.count()
|
||||
assert not Token.objects.count()
|
||||
resp.form.set('phone_1', 'abc')
|
||||
resp.form.set('password', nomail_user.username)
|
||||
resp = resp.form.submit()
|
||||
assert (
|
||||
'Phone number must be either in E.164 globally unique format or dialable from 33 country code (FR).'
|
||||
in resp.pyquery('.error p')[0].text
|
||||
)
|
||||
assert ('Phone number must be dialable from Metropolitan France (e.g. 06 39 98 01 23).') == resp.pyquery(
|
||||
'.error p'
|
||||
)[0].text_content().strip()
|
||||
assert not SMSCode.objects.count()
|
||||
assert not Token.objects.count()
|
||||
|
||||
|
|
|
@ -334,6 +334,81 @@ def test_show_condition_with_headers(db, app, settings):
|
|||
assert 'name="login-password-submit"' in response
|
||||
|
||||
|
||||
def test_show_condition_is_for_backoffice(db, app, settings, caplog):
|
||||
response = app.get('/login/')
|
||||
assert 'name="login-password-submit"' in response
|
||||
|
||||
LoginPasswordAuthenticator.objects.update(show_condition='is_for_backoffice()')
|
||||
response = app.get('/login/')
|
||||
assert 'name="login-password-submit"' not in response
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
response = app.get('/manage/')
|
||||
response = response.follow()
|
||||
assert 'name="login-password-submit"' in response
|
||||
assert len(caplog.records) == 0
|
||||
app.reset()
|
||||
|
||||
# combine
|
||||
LoginPasswordAuthenticator.objects.update(
|
||||
show_condition="is_for_backoffice() and 'X-Entrouvert' in headers"
|
||||
)
|
||||
response = app.get('/manage/')
|
||||
response = response.follow()
|
||||
assert 'name="login-password-submit"' not in response
|
||||
assert len(caplog.records) == 0
|
||||
app.reset()
|
||||
|
||||
response = app.get('/manage/')
|
||||
response = response.follow(headers={'x-entrouvert': '1'})
|
||||
assert 'name="login-password-submit"' in response
|
||||
assert len(caplog.records) == 0
|
||||
app.reset()
|
||||
|
||||
# set a condition with error
|
||||
settings.AUTHENTICATOR_SHOW_CONDITIONS['is_for_backoffice'] = "'backoffice' in unknown"
|
||||
LoginPasswordAuthenticator.objects.update(show_condition='is_for_backoffice()')
|
||||
response = app.get('/manage/')
|
||||
response = response.follow()
|
||||
assert 'name="login-password-submit"' not in response
|
||||
assert len(caplog.records) == 1
|
||||
|
||||
|
||||
def test_show_condition_is_for_frontoffice(db, app, settings, caplog):
|
||||
response = app.get('/login/')
|
||||
assert 'name="login-password-submit"' in response
|
||||
|
||||
LoginPasswordAuthenticator.objects.update(show_condition='is_for_frontoffice()')
|
||||
response = app.get('/login/')
|
||||
assert 'name="login-password-submit"' in response
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
response = app.get('/manage/')
|
||||
response = response.follow()
|
||||
assert 'name="login-password-submit"' not in response
|
||||
assert len(caplog.records) == 0
|
||||
app.reset()
|
||||
|
||||
# combine
|
||||
LoginPasswordAuthenticator.objects.update(
|
||||
show_condition="is_for_frontoffice() and 'X-Entrouvert' in headers"
|
||||
)
|
||||
response = app.get('/login/')
|
||||
assert 'name="login-password-submit"' not in response
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
response = app.get('/login/', headers={'X-entrouvert': '1'})
|
||||
assert 'name="login-password-submit"' in response
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
# set a condition with error
|
||||
settings.AUTHENTICATOR_SHOW_CONDITIONS['is_for_frontoffice'] = "'backoffice' not in unknown"
|
||||
LoginPasswordAuthenticator.objects.update(show_condition='is_for_frontoffice()')
|
||||
response = app.get('/login/')
|
||||
assert 'name="login-password-submit"' not in response
|
||||
assert len(caplog.records) == 1
|
||||
|
||||
|
||||
def test_registration_url_on_login_page(db, app):
|
||||
response = app.get('/login/?next=/whatever')
|
||||
assert 'register/?next=/whatever"' in response
|
||||
|
|
|
@ -471,7 +471,7 @@ def test_manager_create_user_email_validation(superuser_or_admin, app, settings,
|
|||
resp.form.set('password1', 'ABcd1234')
|
||||
resp.form.set('password2', 'ABcd1234')
|
||||
resp = resp.form.submit()
|
||||
assert 'domain is invalid' in resp.text
|
||||
assert 'Email domain (entrouvert.com) does not exists' in resp.text
|
||||
|
||||
monkeypatch.setattr(EmailValidator, 'query_mxs', lambda x, y: ['mx1.entrouvert.org'])
|
||||
resp.form.submit()
|
||||
|
@ -926,6 +926,17 @@ def test_manager_homepage_import_export_hidden(admin, app):
|
|||
assert 'site-export' not in manager_home_page.text
|
||||
|
||||
|
||||
def test_manager_homepage_sidebar_title(app, simple_user, admin):
|
||||
user_admin_role = Role.objects.get(slug='_a2-manager-of-users')
|
||||
simple_user.roles.add(user_admin_role)
|
||||
manager_homepage = login(app, simple_user, reverse('a2-manager-homepage'))
|
||||
assert 'Configuration & technical information' not in manager_homepage
|
||||
logout(app)
|
||||
|
||||
manager_homepage = login(app, admin, reverse('a2-manager-homepage'))
|
||||
assert 'Configuration & technical information' in manager_homepage
|
||||
|
||||
|
||||
def test_manager_ou(app, superuser_or_admin, ou1):
|
||||
manager_home_page = login(app, superuser_or_admin, reverse('a2-manager-homepage'))
|
||||
ou_homepage = manager_home_page.click(href='organizational-units')
|
||||
|
@ -1476,3 +1487,9 @@ def test_manager_empty_kebab(app, admin, simple_user):
|
|||
|
||||
resp = login(app, admin, '/manage/users/')
|
||||
assert '"extra-actions-menu-opener"' in resp
|
||||
|
||||
|
||||
def test_manager_select2(app, superuser):
|
||||
login(app, superuser)
|
||||
response = app.get(reverse('django_select2-json'), expect_errors=True)
|
||||
assert response.status_code == 400
|
||||
|
|
|
@ -147,9 +147,17 @@ def test_authenticators_password(app, superuser_or_admin, settings):
|
|||
)
|
||||
|
||||
resp = app.get('/manage/authenticators/%s/edit/' % authenticator.pk)
|
||||
assert 'Time (in seconds, between 60 and 3600) after which SMS codes expire. Default is 180' in resp.text
|
||||
|
||||
settings.SMS_CODE_DURATION = 240
|
||||
|
||||
resp = app.get('/manage/authenticators/%s/edit/' % authenticator.pk)
|
||||
assert resp.form['sms_code_duration'].value == ''
|
||||
|
||||
resp.form['accept_email_authentication'] = False
|
||||
resp.form['accept_phone_authentication'] = True
|
||||
resp.form['sms_code_duration'] = '1200'
|
||||
assert 'Time (in seconds, between 60 and 3600) after which SMS codes expire. Default is 240' in resp.text
|
||||
assert resp.form['phone_identifier_field'].options == [
|
||||
(str(phone1.id), False, 'Another phone'),
|
||||
(str(phone2.id), False, 'Yet another phone'),
|
||||
|
@ -161,6 +169,38 @@ def test_authenticators_password(app, superuser_or_admin, settings):
|
|||
assert authenticator.accept_email_authentication is False
|
||||
assert authenticator.accept_phone_authentication is True
|
||||
assert authenticator.phone_identifier_field == phone2
|
||||
assert authenticator.sms_code_duration == 1200
|
||||
|
||||
resp = app.get('/manage/authenticators/%s/edit/' % authenticator.pk)
|
||||
resp.form['sms_code_duration'] = '4200' # too high
|
||||
resp = resp.form.submit()
|
||||
assert resp.pyquery('.error')[0].text_content().strip() == (
|
||||
'Ensure that this value is lower than 3600, or leave blank for default value.'
|
||||
)
|
||||
authenticator.refresh_from_db()
|
||||
assert authenticator.sms_code_duration == 1200
|
||||
|
||||
resp.form['sms_code_duration'] = '42' # too low
|
||||
resp = resp.form.submit()
|
||||
assert resp.pyquery('.error')[0].text_content().strip() == (
|
||||
'Ensure that this value is higher than 60, or leave blank for default value.'
|
||||
)
|
||||
authenticator.refresh_from_db()
|
||||
assert authenticator.sms_code_duration == 1200
|
||||
|
||||
resp.form['sms_code_duration'] = '2442' # new valid value
|
||||
resp = resp.form.submit()
|
||||
assert resp.location == f'/manage/authenticators/{authenticator.pk}/detail/'
|
||||
resp = resp.follow()
|
||||
assert not resp.pyquery('.error')
|
||||
authenticator.refresh_from_db()
|
||||
assert authenticator.sms_code_duration == 2442
|
||||
|
||||
resp = app.get('/manage/authenticators/%s/edit/' % authenticator.pk)
|
||||
resp.form.set('sms_code_duration', '')
|
||||
resp = resp.form.submit()
|
||||
authenticator.refresh_from_db()
|
||||
assert authenticator.sms_code_duration is None
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2022-04-19 14:00')
|
||||
|
@ -203,6 +243,7 @@ def test_authenticators_password_export(app, superuser):
|
|||
'related_objects': [],
|
||||
'accept_email_authentication': True,
|
||||
'accept_phone_authentication': False,
|
||||
'sms_code_duration': None,
|
||||
}
|
||||
|
||||
resp = app.get('/manage/authenticators/')
|
||||
|
|
|
@ -24,7 +24,7 @@ from django.urls import reverse
|
|||
|
||||
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
|
||||
from authentic2.models import Attribute, SMSCode, Token
|
||||
from authentic2.utils.misc import send_password_reset_mail
|
||||
from authentic2.utils.misc import get_password_authenticator, send_password_reset_mail
|
||||
|
||||
from . import utils
|
||||
|
||||
|
@ -88,6 +88,9 @@ def test_send_password_reset_by_sms_code(app, nomail_user, settings, phone_activ
|
|||
nomail_user.save()
|
||||
settings.SMS_URL = 'https://foo.whatever.none/'
|
||||
rsps = responses.post('https://foo.whatever.none/', status=200)
|
||||
authn = get_password_authenticator()
|
||||
authn.sms_code_duration = 300
|
||||
authn.save()
|
||||
|
||||
code_length = settings.SMS_CODE_LENGTH
|
||||
assert not SMSCode.objects.count()
|
||||
|
@ -102,7 +105,7 @@ def test_send_password_reset_by_sms_code(app, nomail_user, settings, phone_activ
|
|||
code = SMSCode.objects.get()
|
||||
assert rsps.call_count == 1
|
||||
assert body['message'][-code_length:] == code.value
|
||||
assert ('Your code is valid for the next %s minute' % (SMSCode.CODE_DURATION // 60)) in resp.text
|
||||
assert 'Your code is valid for the next 5 minutes' in resp.text
|
||||
assert 'The code you received by SMS.' in resp.text
|
||||
resp.form.set('sms_code', code.value)
|
||||
resp = resp.form.submit().follow()
|
||||
|
@ -444,7 +447,7 @@ def test_email_validation(app, db):
|
|||
resp = app.get('/password/reset/')
|
||||
resp.form.set('email', 'coin@')
|
||||
resp = resp.form.submit()
|
||||
assert 'Enter a valid email address.' in resp
|
||||
assert 'Please enter a valid email address (example: john.doe@entrouvert.com)' in resp
|
||||
|
||||
|
||||
def test_honeypot(app, db, settings, mailoutbox):
|
||||
|
|
|
@ -531,3 +531,63 @@ def test_account_view_boolean(app, simple_user, settings):
|
|||
simple_user.attributes.accept = False
|
||||
resp = app.get(reverse('account_management'))
|
||||
assert 'Vrai' not in resp.text
|
||||
|
||||
|
||||
def test_account_profile_completion_ratio(app, simple_user, settings):
|
||||
settings.A2_ACCOUNTS_DISPLAY_COMPLETION_RATIO = True
|
||||
Attribute.objects.all().delete()
|
||||
for i in range(8):
|
||||
Attribute.objects.create(
|
||||
name=f'attr_{i}',
|
||||
label=f'Attribute {i}',
|
||||
kind='string',
|
||||
disabled=False,
|
||||
multiple=False,
|
||||
user_visible=True,
|
||||
user_editable=True,
|
||||
)
|
||||
|
||||
utils.login(app, simple_user)
|
||||
resp = app.get(reverse('account_management'))
|
||||
assert (
|
||||
resp.pyquery('#a2-profile-completion-ratio')[0].text_content().strip()
|
||||
== 'You have completed 0% of your user profile.'
|
||||
)
|
||||
|
||||
simple_user.attributes.attr_0 = 'foo'
|
||||
resp = app.get(reverse('account_management'))
|
||||
assert (
|
||||
resp.pyquery('#a2-profile-completion-ratio')[0].text_content().strip()
|
||||
== 'You have completed 12% of your user profile.'
|
||||
)
|
||||
|
||||
simple_user.attributes.attr_1 = 'bar'
|
||||
resp = app.get(reverse('account_management'))
|
||||
assert (
|
||||
resp.pyquery('#a2-profile-completion-ratio')[0].text_content().strip()
|
||||
== 'You have completed 25% of your user profile.'
|
||||
)
|
||||
|
||||
# test that multiple attribute values don't jinx the stats
|
||||
attr_2 = Attribute.objects.get(name='attr_2')
|
||||
attr_2.multiple = True
|
||||
attr_2.save()
|
||||
simple_user.attributes.attr_2 = ['b', 'é', 'p', 'o']
|
||||
resp = app.get(reverse('account_management'))
|
||||
assert (
|
||||
resp.pyquery('#a2-profile-completion-ratio')[0].text_content().strip()
|
||||
== 'You have completed 38% of your user profile.'
|
||||
)
|
||||
|
||||
# remaining attributes up to 100% completion
|
||||
for i, percent in (('3', 50), ('4', 62), ('5', 75), ('6', 88), ('7', 100)):
|
||||
setattr(simple_user.attributes, f'attr_{i}', i)
|
||||
resp = app.get(reverse('account_management'))
|
||||
assert (
|
||||
resp.pyquery('#a2-profile-completion-ratio')[0].text_content().strip()
|
||||
== f'You have completed {percent}% of your user profile.'
|
||||
)
|
||||
|
||||
settings.A2_ACCOUNTS_DISPLAY_COMPLETION_RATIO = False
|
||||
resp = app.get(reverse('account_management'))
|
||||
assert not resp.pyquery('#a2-profile-completion-ratio')
|
||||
|
|
|
@ -181,7 +181,7 @@ def test_registration_email_validation(app, db, monkeypatch, settings):
|
|||
resp = app.get(reverse('registration_register'))
|
||||
resp.form.set('email', 'testbot@entrouvert.com')
|
||||
resp = resp.form.submit()
|
||||
assert 'domain is invalid' in resp.text
|
||||
assert 'Email domain (entrouvert.com) does not exists' in resp.text
|
||||
|
||||
|
||||
def test_username_settings(app, db, settings, mailoutbox):
|
||||
|
@ -426,10 +426,16 @@ def test_registration_bad_email(app, db, settings):
|
|||
settings.LANGUAGE_CODE = 'en-us'
|
||||
|
||||
response = app.post(reverse('registration_register'), params={'email': 'fred@0d..be'}, status=200)
|
||||
assert 'Enter a valid email address.' in response.context['form'].errors['email']
|
||||
assert (
|
||||
'Please enter a valid email address (example: john.doe@entrouvert.com)'
|
||||
in response.context['form'].errors['email']
|
||||
)
|
||||
|
||||
response = app.post(reverse('registration_register'), params={'email': 'ééééé'}, status=200)
|
||||
assert 'Enter a valid email address.' in response.context['form'].errors['email']
|
||||
assert (
|
||||
'Please enter a valid email address (example: john.doe@entrouvert.com)'
|
||||
in response.context['form'].errors['email']
|
||||
)
|
||||
|
||||
response = app.post(reverse('registration_register'), params={'email': ''}, status=200)
|
||||
assert response.pyquery('title')[0].text.endswith('there are errors in the form')
|
||||
|
@ -1006,10 +1012,9 @@ def test_registration_erroneous_phone_identifier(app, db, settings, phone_activa
|
|||
resp = app.get(reverse('registration_register'))
|
||||
resp.form.set('phone_1', 'thatsnotquiteit')
|
||||
resp = resp.form.submit()
|
||||
assert (
|
||||
'Phone number must be either in E.164 globally unique format or dialable from 33 country code (FR).'
|
||||
in resp.pyquery('.error')[0].text_content()
|
||||
)
|
||||
assert ('Phone number must be dialable from Metropolitan France (e.g. 06 39 98 01 23).') == resp.pyquery(
|
||||
'.error p'
|
||||
)[0].text_content().strip()
|
||||
|
||||
|
||||
@responses.activate
|
||||
|
@ -1076,6 +1081,26 @@ def test_phone_registration_cancel(app, db, settings, freezer, phone_activated_a
|
|||
assert not SMSCode.objects.count()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_phone_registration_wrong_input(app, db, settings, freezer, phone_activated_authn):
|
||||
settings.SMS_URL = 'https://foo.whatever.none/'
|
||||
responses.post('https://foo.whatever.none/', status=200)
|
||||
|
||||
resp = app.get(reverse('registration_register'))
|
||||
resp.form.set('phone_1', '12244666')
|
||||
resp = resp.form.submit()
|
||||
assert (
|
||||
'Invalid phone number. Phone number from Metropolitan France must respect local format (e.g. 06 39 98 01 23).'
|
||||
) == resp.pyquery('.error p')[0].text_content().strip()
|
||||
|
||||
resp.form.set('phone_0', '32')
|
||||
resp.form.set('phone_1', '12244')
|
||||
resp = resp.form.submit()
|
||||
assert (
|
||||
'Invalid phone number. Phone number from Belgium must respect local format (e.g. 042 11 22 33).'
|
||||
) == resp.pyquery('.error p')[0].text_content().strip()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_phone_registration_improperly_configured(app, db, settings, freezer, caplog, phone_activated_authn):
|
||||
settings.SMS_URL = ''
|
||||
|
@ -1308,6 +1333,9 @@ def test_phone_registration(app, db, settings, phone_activated_authn):
|
|||
settings.SMS_URL = 'https://foo.whatever.none/'
|
||||
rsps = responses.post('https://foo.whatever.none/', status=200)
|
||||
code_length = settings.SMS_CODE_LENGTH
|
||||
authn = utils_misc.get_password_authenticator()
|
||||
authn.sms_code_duration = 420
|
||||
authn.save()
|
||||
|
||||
assert not SMSCode.objects.count()
|
||||
assert not Token.objects.count()
|
||||
|
@ -1318,7 +1346,7 @@ def test_phone_registration(app, db, settings, phone_activated_authn):
|
|||
assert body['message'].startswith('Your code is')
|
||||
code = SMSCode.objects.get()
|
||||
assert body['message'][-code_length:] == code.value
|
||||
assert ('Your code is valid for the next %s minute' % (SMSCode.CODE_DURATION // 60)) in resp.text
|
||||
assert 'Your code is valid for the next 7 minutes' in resp.text
|
||||
assert 'The code you received by SMS.' in resp.text
|
||||
resp.form.set('sms_code', code.value)
|
||||
resp = resp.form.submit().follow()
|
||||
|
|
|
@ -390,7 +390,7 @@ def test_manager_role_csv_import(app, admin, ou1, ou2):
|
|||
'role_names,search_text,expt_found',
|
||||
[
|
||||
(
|
||||
['A random test role', 'Random test stuff', 'Some test role', 'Something else', 'SomeTest'],
|
||||
['A random test rôle', 'Random test stuff', 'Some test role', 'Something else', 'SomeTest'],
|
||||
' rand role',
|
||||
[0],
|
||||
),
|
||||
|
@ -419,6 +419,11 @@ def test_manager_role_csv_import(app, admin, ou1, ou2):
|
|||
' ',
|
||||
[0, 1, 2, 3, 4],
|
||||
),
|
||||
(
|
||||
['A random test role', 'Random test stuff', 'Some test role', 'Something else', 'SomeTest'],
|
||||
'rôle',
|
||||
[0, 2],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_manager_role_search(app, admin, role_names, search_text, expt_found):
|
||||
|
@ -513,6 +518,16 @@ def test_role_members_display_role_parents_search(app, superuser, simple_role):
|
|||
assert 'Managers of role "simple role"' in roles
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url_name', ('a2-manager-role-parents', 'a2-manager-role-children'))
|
||||
@pytest.mark.parametrize('sortkey', ('name', 'ou', 'members', 'member', 'via'))
|
||||
def test_role_members_inheritance_order_by(app, superuser, url_name, sortkey):
|
||||
role = Role.objects.create(name='Foobar', ou=get_default_ou())
|
||||
url = reverse(url_name, kwargs={'pk': role.pk})
|
||||
login(app, superuser)
|
||||
|
||||
app.get(url, params={'sort': sortkey}) # Simple 200 check (see #88249)
|
||||
|
||||
|
||||
def test_role_members_user_role_mixed_table(app, superuser, settings, simple_role, simple_user):
|
||||
simple_user.roles.add(simple_role)
|
||||
|
||||
|
@ -726,6 +741,18 @@ def test_role_members_user_role_add_remove(app, superuser, settings, simple_role
|
|||
resp = form.submit().maybe_follow()
|
||||
|
||||
|
||||
def test_role_members_select2(app, superuser, simple_user, settings):
|
||||
assert superuser.ou is None and simple_user.ou == get_default_ou()
|
||||
r = Role.objects.create(name='role', slug='role', ou=get_default_ou())
|
||||
url = reverse('a2-manager-role-members', kwargs={'pk': r.pk})
|
||||
|
||||
response = login(app, superuser, url)
|
||||
|
||||
select2_url = response.pyquery('select')[0].attrib['data-ajax--url']
|
||||
select2_response = app.get(select2_url, expect_errors=True)
|
||||
assert select2_response.status_code == 400
|
||||
|
||||
|
||||
def test_role_table_ordering(app, admin):
|
||||
Role.objects.create(name='a role')
|
||||
Role.objects.create(name='bD role')
|
||||
|
|
|
@ -59,10 +59,9 @@ def test_phone_number_change_invalid_number(settings, app, simple_user):
|
|||
assert resp.pyquery('input#id_mobile_1')[0].value == 'def'
|
||||
|
||||
resp = resp.form.submit()
|
||||
assert (
|
||||
'Phone number must be either in E.164 globally unique format or dialable from'
|
||||
in resp.pyquery('.error').text()
|
||||
)
|
||||
assert ('Phone number must be dialable from Metropolitan France (e.g. 06 39 98 01 23).') == resp.pyquery(
|
||||
'.error p'
|
||||
)[0].text_content().strip()
|
||||
|
||||
resp.form['mobile_1'] = '612345678'
|
||||
resp.form.submit().follow()
|
||||
|
|
Loading…
Reference in New Issue