Compare commits

...

24 Commits

Author SHA1 Message Date
Paul Marillonnet dbf77a8ee6 hide fields' requisiteness on phone-enabled password-reset (#88147)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-18 17:09:32 +02:00
Paul Marillonnet 5cc35503e6 password_reset: provide phone authn config in template ctx (#88158) 2024-04-18 17:08:48 +02:00
Paul Marillonnet 033c85d2d6 hide fields' requisiteness on phone-enabled registration (#88146)
gitea/authentic/pipeline/head Build queued... Details
2024-04-18 17:06:52 +02:00
Paul Marillonnet 154214b07b login registration: provide phone authn config in template ctx (#88144)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-18 17:05:17 +02:00
Paul Marillonnet 6db2b57b4b translation update (#88287)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-18 11:25:29 +02:00
Paul Marillonnet ad67e67417 /accounts/: compute profile completion ratio (#88287)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-18 11:18:12 +02:00
Benjamin Dauvergne 2b3d04a6d1 manager: search role with unaccent lookup (#87906)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-15 15:19:04 +02:00
Paul Marillonnet 1d966eab30 translation update (#88786)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-04 10:57:17 +02:00
Paul Marillonnet f3e57d5089 login/pwd authenticator: allow setting sms code duration (#88786)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-03 10:42:05 +02:00
Paul Marillonnet a6df1a2750 translation update (#88045)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-02 12:03:18 +02:00
Paul Marillonnet fe998389ec misc: add skipped i18n str fragment (#88045) 2024-04-02 11:57:23 +02:00
Paul Marillonnet 6a7a4814a9 forms/fields: provide clearer validation error for PhoneField (#88045)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-02 11:37:17 +02:00
Paul Marillonnet 0abfbc9480 api: check phone uniqueness at user serializer validation (#83700)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-02 11:22:37 +02:00
Benjamin Dauvergne d0420218bb auth_oidc: prevent trace when jwkset_json is None (#88885)
gitea/authentic/pipeline/head This commit looks good Details
2024-04-01 23:44:22 +02:00
Thomas NOËL 16b714c01f Revert manager: search role with unaccent lookup (#87906)
gitea/authentic/pipeline/head This commit looks good Details
This reverts commit 67674f56f9.
2024-03-30 14:53:08 +01:00
Thomas NOËL 67674f56f9 manager: search role with unaccent lookup (#87906)
gitea/authentic/pipeline/head This commit looks good Details
2024-03-29 23:30:58 +01:00
Emmanuel Cazenave 36bc55d3ce authenticators: add helper functions for show condition (#67986)
gitea/authentic/pipeline/head This commit looks good Details
2024-03-22 11:08:32 +01:00
Benjamin Dauvergne 4dc8f6aab7 misc: remove dead logged-in JSONP endpoint (#88195)
gitea/authentic/pipeline/head This commit looks good Details
2024-03-20 16:37:06 +01:00
Yann Weber 95701c5e76 manager: add display condition on homepage sidebar title (#87961)
gitea/authentic/pipeline/head This commit looks good Details
2024-03-20 09:28:37 +01:00
Yann Weber 0334e56117 widgets: add check on field_id parameter for select2.json urls (#88250)
gitea/authentic/pipeline/head This commit looks good Details
2024-03-20 09:20:55 +01:00
Yann Weber 634211e1b7 translation update
gitea/authentic/pipeline/head This commit looks good Details
2024-03-19 18:00:08 +01:00
Yann Weber cbbc6b78c4 translation update
gitea/authentic/pipeline/head This commit looks good Details
2024-03-19 17:53:03 +01:00
Yann Weber 137a5898b4 forms: add an example of email address in registration form (#83254)
gitea/authentic/pipeline/head This commit looks good Details
2024-03-19 17:30:08 +01:00
Yann Weber 891dd6a1de manager: forbid sort on role inheritance table member column (#88249)
gitea/authentic/pipeline/head This commit looks good Details
2024-03-19 11:13:29 +01:00
37 changed files with 741 additions and 114 deletions

View File

@ -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',
),
),
]

View File

@ -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 [

View File

@ -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

View File

@ -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,

View File

@ -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',
)

View File

@ -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)',
),
),
]

View File

@ -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']
@ -437,4 +477,5 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
def registration(self, request, *args, **kwargs):
context = kwargs.get('context', {})
context['is_phone_authn_active'] = self.is_phone_authn_active
return render(request, 'authentic2/login_password_registration_form.html', context)

View File

@ -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(

View File

@ -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 ''

View File

@ -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,
)

View File

@ -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-18 11:25+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 "Lidentifiant est obligatoire"
msgid "Username is required in this ou"
msgstr "Lidentifiant 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 dun objet lié à un moyen dauthentification"
#: 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 dun objet « {related_object} » dans le moyen "
"dauthentification « {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 nest "
"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 denvois 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 "Limage nest 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 ""
@ -5981,6 +6021,11 @@ msgstr "Authentification à un service tiers"
msgid "You logged in from service %(name)s."
msgstr "Vous vous connectez en arrivant depuis le service %(name)s."
#: src/authentic2/templates/authentic2/accounts.html
#, python-format
msgid "You have completed %(completion_percent)s%% of your user profile."
msgstr "Vous avez complété %(completion_percent)s%% de votre profil usager."
#: src/authentic2/templates/authentic2/accounts.html
msgid "Change email"
msgstr "Modifier votre adresse électronique"
@ -7073,12 +7118,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 ladresse de courriel est invalide."
#, python-format
msgid "Email domain (%(dom)s) does not exists"
msgstr "Le nom de domaine (%(dom)s) du courriel nexiste pas"
#: src/authentic2/validators.py
msgid "Null characters are not allowed."
@ -7314,8 +7360,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."

View File

@ -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

View File

@ -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):

View File

@ -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'}},
)

View File

@ -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>

View File

@ -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.')

View File

@ -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)

View File

@ -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
#

View 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 %}

View File

@ -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="{% if not is_phone_authn_active %}pk-mark-optional-fields{% else %}pk-hide-requisiteness{% endif %}">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">

View File

@ -9,7 +9,7 @@
<h2>{% trans "Resetting password" %}</h2>
<form method="post" class="pk-mark-optional-fields">
<form method="post" class="{% if not is_phone_authn_active %}pk-mark-optional-fields{% else %}pk-hide-requisiteness{% endif %}">
{% csrf_token %}
{{ form|with_template }}

View File

@ -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'),

View File

@ -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):

View File

@ -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)

View File

@ -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())
@ -1144,6 +1138,7 @@ class PasswordResetView(FormView):
if app_settings.A2_USER_CAN_RESET_PASSWORD is False:
raise Http404('Password reset is not allowed.')
ctx['title'] = _('Password reset')
ctx['is_phone_authn_active'] = self.authenticator.is_phone_authn_active
return ctx
def form_valid(self, form):
@ -1627,6 +1622,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 +1631,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):

View File

@ -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):

View File

@ -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',

View File

@ -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')

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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/')

View File

@ -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()
@ -95,6 +98,8 @@ def test_send_password_reset_by_sms_code(app, nomail_user, settings, phone_activ
url = reverse('password_reset')
resp = app.get(url, status=200)
assert not resp.pyquery('.pk-mark-optional-fields')
assert resp.pyquery('.pk-hide-requisiteness')
resp.form.set('phone_1', '0123456789')
resp = resp.form.submit().follow().maybe_follow()
body = json.loads(rsps.calls[-1].request.body)
@ -102,7 +107,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 +449,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):

View File

@ -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')

View File

@ -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,17 +1333,22 @@ 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()
resp = app.get(reverse('registration_register'))
assert not resp.pyquery('.pk-mark-optional-fields')
assert resp.pyquery('.pk-hide-requisiteness')
resp.form.set('phone_1', '612345678')
resp = resp.form.submit().follow()
body = json.loads(rsps.calls[-1].request.body)
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()

View File

@ -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')

View File

@ -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()