From 5c83646c681f31c7f986bdb8e8bd67432c5aef47 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 13 Apr 2022 13:58:40 +0200 Subject: [PATCH 2/3] authenticators: migrate login password authenticator (#53902) --- src/authentic2/apps/authenticators/forms.py | 24 +++- .../0002_loginpasswordauthenticator.py | 47 ++++++ .../migrations/0003_auto_20220413_1504.py | 33 +++++ src/authentic2/apps/authenticators/models.py | 37 +++++ .../authenticators/authenticator_detail.html | 4 +- src/authentic2/apps/authenticators/views.py | 6 + src/authentic2/authenticators.py | 134 ------------------ src/authentic2/forms/authentication.py | 7 +- src/authentic2/settings.py | 2 +- src/authentic2/utils/misc.py | 12 +- src/authentic2/views.py | 105 ++++++++++++++ tests/auth_fc/test_auth_fc.py | 5 +- tests/test_auth_oidc.py | 5 +- tests/test_auth_saml.py | 5 +- tests/test_ldap.py | 5 +- tests/test_login.py | 44 ++++-- tests/test_manager_authenticators.py | 47 ++++++ 17 files changed, 366 insertions(+), 156 deletions(-) create mode 100644 src/authentic2/apps/authenticators/migrations/0002_loginpasswordauthenticator.py create mode 100644 src/authentic2/apps/authenticators/migrations/0003_auto_20220413_1504.py diff --git a/src/authentic2/apps/authenticators/forms.py b/src/authentic2/apps/authenticators/forms.py index bb9f36109..47b125b87 100644 --- a/src/authentic2/apps/authenticators/forms.py +++ b/src/authentic2/apps/authenticators/forms.py @@ -15,15 +15,29 @@ # along with this program. If not, see . from django import forms +from django.core.exceptions import ValidationError +from django.template import Template, TemplateSyntaxError, VariableDoesNotExist +from django.utils.translation import ugettext as _ from authentic2.forms.mixins import SlugMixin -from .models import BaseAuthenticator +from .models import BaseAuthenticator, LoginPasswordAuthenticator + + +class AuthenticatorFormMixin: + def clean_show_condition(self): + condition = self.cleaned_data['show_condition'] + if condition: + try: + Template('{%% if %s %%}OK{%% endif %%}' % condition) + except (TemplateSyntaxError, VariableDoesNotExist) as e: + raise ValidationError(_('template syntax error: %s') % e) + return condition class AuthenticatorAddForm(SlugMixin, forms.ModelForm): field_order = ('authenticator', 'name', 'ou') - authenticators = {x.type: x for x in BaseAuthenticator.__subclasses__()} + authenticators = {x.type: x for x in BaseAuthenticator.__subclasses__() if not x.internal} authenticator = forms.ChoiceField(choices=[(k, v._meta.verbose_name) for k, v in authenticators.items()]) @@ -35,3 +49,9 @@ class AuthenticatorAddForm(SlugMixin, forms.ModelForm): Authenticator = self.authenticators[self.cleaned_data['authenticator']] self.instance = Authenticator(name=self.cleaned_data['name'], ou=self.cleaned_data['ou']) return super().save() + + +class LoginPasswordAuthenticatorEditForm(AuthenticatorFormMixin, forms.ModelForm): + class Meta: + model = LoginPasswordAuthenticator + exclude = ('name', 'slug', 'ou') diff --git a/src/authentic2/apps/authenticators/migrations/0002_loginpasswordauthenticator.py b/src/authentic2/apps/authenticators/migrations/0002_loginpasswordauthenticator.py new file mode 100644 index 000000000..5af8b95bf --- /dev/null +++ b/src/authentic2/apps/authenticators/migrations/0002_loginpasswordauthenticator.py @@ -0,0 +1,47 @@ +# Generated by Django 2.2.28 on 2022-04-13 12:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authenticators', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='LoginPasswordAuthenticator', + fields=[ + ( + 'baseauthenticator_ptr', + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to='authenticators.BaseAuthenticator', + ), + ), + ( + 'remember_me', + models.PositiveIntegerField( + blank=True, + help_text='Session duration as seconds when using the remember me checkbox. Leave blank to hide the checkbox.', + null=True, + verbose_name='Remember me duration', + ), + ), + ( + 'include_ou_selector', + models.BooleanField(default=False, verbose_name='Include OU selector in login form'), + ), + ], + options={ + 'verbose_name': 'Password', + }, + bases=('authenticators.baseauthenticator',), + ), + ] diff --git a/src/authentic2/apps/authenticators/migrations/0003_auto_20220413_1504.py b/src/authentic2/apps/authenticators/migrations/0003_auto_20220413_1504.py new file mode 100644 index 000000000..4a10785f0 --- /dev/null +++ b/src/authentic2/apps/authenticators/migrations/0003_auto_20220413_1504.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.28 on 2022-04-13 13:04 + +from django.db import migrations + +from authentic2 import app_settings + + +def create_login_password_authenticator(apps, schema_editor): + kwargs_settings = getattr(app_settings, 'AUTH_FRONTENDS_KWARGS', {}) + password_settings = kwargs_settings.get('password', {}) + + LoginPasswordAuthenticator = apps.get_model('authenticators', 'LoginPasswordAuthenticator') + LoginPasswordAuthenticator.objects.get_or_create( + slug='password-authenticator', + defaults={ + 'order': password_settings.get('priority', 0), + 'show_condition': password_settings.get('show_condition', ''), + 'enabled': app_settings.A2_AUTH_PASSWORD_ENABLE, + 'remember_me': app_settings.A2_USER_REMEMBER_ME, + 'include_ou_selector': app_settings.A2_LOGIN_FORM_OU_SELECTOR, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('authenticators', '0002_loginpasswordauthenticator'), + ] + + operations = [ + migrations.RunPython(create_login_password_authenticator, reverse_code=migrations.RunPython.noop), + ] diff --git a/src/authentic2/apps/authenticators/models.py b/src/authentic2/apps/authenticators/models.py index 8e0d4dbce..6e96d8282 100644 --- a/src/authentic2/apps/authenticators/models.py +++ b/src/authentic2/apps/authenticators/models.py @@ -23,6 +23,7 @@ from django.shortcuts import render, reverse from django.utils.formats import date_format from django.utils.translation import ugettext_lazy as _ +from authentic2 import views from authentic2.utils.evaluate import evaluate_condition from .query import AuthenticatorManager @@ -60,6 +61,7 @@ class BaseAuthenticator(models.Model): type = '' manager_form_class = None + internal = False description_fields = ['show_condition'] class Meta: @@ -106,3 +108,38 @@ class BaseAuthenticator(models.Model): except Exception as e: logger.error(e) return False + + +class LoginPasswordAuthenticator(BaseAuthenticator): + remember_me = models.PositiveIntegerField( + _('Remember me duration'), + blank=True, + null=True, + help_text=_( + 'Session duration as seconds when using the remember me checkbox. Leave blank to hide the checkbox.' + ), + ) + include_ou_selector = models.BooleanField(_('Include OU selector in login form'), default=False) + + type = 'password' + how = ['password', 'password-on-https'] + internal = True + + class Meta: + verbose_name = _('Password') + + @property + def manager_form_class(self): + from .forms import LoginPasswordAuthenticatorEditForm + + return LoginPasswordAuthenticatorEditForm + + def login(self, request, *args, **kwargs): + return views.login_password_login(request, self, *args, **kwargs) + + def profile(self, request, *args, **kwargs): + return views.login_password_profile(request, *args, **kwargs) + + def registration(self, request, *args, **kwargs): + context = kwargs.get('context', {}) + return render(request, 'authentic2/login_password_registration_form.html', context) diff --git a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html index 846850b2c..54d3d5527 100644 --- a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html @@ -9,7 +9,9 @@ {{ object.enabled|yesno:_("Disable,Enable") }} {% trans "Edit" %} {% endblock %} diff --git a/src/authentic2/apps/authenticators/views.py b/src/authentic2/apps/authenticators/views.py index 35aef34da..4f812d541 100644 --- a/src/authentic2/apps/authenticators/views.py +++ b/src/authentic2/apps/authenticators/views.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from django.contrib import messages +from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from django.urls import reverse_lazy from django.utils.translation import ugettext as _ @@ -79,6 +80,11 @@ class AuthenticatorDeleteView(AuthenticatorsMixin, DeleteView): model = BaseAuthenticator success_url = reverse_lazy('a2-manager-authenticators') + def dispatch(self, *args, **kwargs): + if self.get_object().internal: + raise PermissionDenied + return super().dispatch(*args, **kwargs) + delete = AuthenticatorDeleteView.as_view() diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py index 2e7be7675..941315d13 100644 --- a/src/authentic2/authenticators.py +++ b/src/authentic2/authenticators.py @@ -16,21 +16,7 @@ import logging -from django.db.models import Count -from django.shortcuts import render -from django.utils.translation import ugettext as _ -from django.utils.translation import ugettext_lazy - -from authentic2.a2_rbac.models import OrganizationalUnit as OU -from authentic2.a2_rbac.models import Role -from authentic2.custom_user.models import User - -from . import app_settings, views -from .forms import authentication as authentication_forms -from .utils import misc as utils_misc from .utils.evaluate import evaluate_condition -from .utils.service import get_service -from .utils.views import csrf_token_check logger = logging.getLogger(__name__) @@ -61,123 +47,3 @@ class BaseAuthenticator: def get_identifier(self): return self.id - - -class LoginPasswordAuthenticator(BaseAuthenticator): - id = 'password' - how = ['password', 'password-on-https'] - submit_name = 'login-password-submit' - priority = 0 - - def enabled(self): - return app_settings.A2_AUTH_PASSWORD_ENABLE - - def name(self): - return ugettext_lazy('Password') - - def get_service_ous(self, service): - roles = Role.objects.filter(allowed_services=service).children() - if not roles: - return [] - service_ou_ids = [] - qs = ( - User.objects.filter(roles__in=roles) - .values_list('ou') - .annotate(count=Count('ou')) - .order_by('-count') - ) - for ou_id, dummy_count in qs: - if not ou_id: - continue - service_ou_ids.append(ou_id) - if not service_ou_ids: - return [] - return OU.objects.filter(pk__in=service_ou_ids) - - def get_preferred_ous(self, request): - service = get_service(request) - preferred_ous_cookie = utils_misc.get_remember_cookie(request, 'preferred-ous') - preferred_ous = [] - if preferred_ous_cookie: - preferred_ous.extend(OU.objects.filter(pk__in=preferred_ous_cookie)) - # for the special case of services open to only one OU, pre-select it - if service: - for ou in self.get_service_ous(service): - if ou in preferred_ous: - continue - preferred_ous.append(ou) - return preferred_ous - - def login(self, request, *args, **kwargs): - context = kwargs.get('context', {}) - is_post = request.method == 'POST' and self.submit_name in request.POST - data = request.POST if is_post else None - initial = {} - preferred_ous = [] - request.failed_logins = {} - - # Special handling when the form contains an OU selector - if app_settings.A2_LOGIN_FORM_OU_SELECTOR: - preferred_ous = self.get_preferred_ous(request) - if preferred_ous: - initial['ou'] = preferred_ous[0] - - form = authentication_forms.AuthenticationForm( - request=request, data=data, initial=initial, preferred_ous=preferred_ous - ) - if request.user.is_authenticated and request.login_token.get('action'): - form.initial['username'] = request.user.username or request.user.email - form.fields['username'].widget.attrs['readonly'] = True - form.fields['password'].widget.attrs['autofocus'] = True - else: - form.fields['username'].widget.attrs['autofocus'] = not (bool(context.get('block_index'))) - if app_settings.A2_ACCEPT_EMAIL_AUTHENTICATION: - form.fields['username'].label = _('Username or email') - if app_settings.A2_USERNAME_LABEL: - form.fields['username'].label = app_settings.A2_USERNAME_LABEL - is_secure = request.is_secure - context['submit_name'] = self.submit_name - if is_post: - csrf_token_check(request, form) - if form.is_valid(): - if is_secure: - how = 'password-on-https' - else: - how = 'password' - if form.cleaned_data.get('remember_me'): - request.session['remember_me'] = True - request.session.set_expiry(app_settings.A2_USER_REMEMBER_ME) - response = utils_misc.login(request, form.get_user(), how) - if 'ou' in form.fields: - utils_misc.prepend_remember_cookie( - request, response, 'preferred-ous', form.cleaned_data['ou'].pk - ) - - if hasattr(request, 'needs_password_change'): - del request.needs_password_change - return utils_misc.redirect( - request, 'password_change', params={'next': response.url}, resolve=True - ) - - return response - else: - username = form.cleaned_data.get('username', '').strip() - if request.failed_logins: - for user, failure_data in request.failed_logins.items(): - request.journal.record( - 'user.login.failure', - user=user, - reason=failure_data.get('reason', None), - username=username, - ) - elif username: - request.journal.record('user.login.failure', username=username) - context['form'] = form - return render(request, 'authentic2/login_password_form.html', context) - - def profile(self, request, *args, **kwargs): - return views.login_password_profile(request, *args, **kwargs) - - def registration(self, request, *args, **kwargs): - context = kwargs.get('context', {}) - return render(request, 'authentic2/login_password_registration_form.html', context) diff --git a/src/authentic2/forms/authentication.py b/src/authentic2/forms/authentication.py index 2608c98da..8be142705 100644 --- a/src/authentic2/forms/authentication.py +++ b/src/authentic2/forms/authentication.py @@ -51,6 +51,7 @@ class AuthenticationForm(auth_forms.AuthenticationForm): def __init__(self, *args, **kwargs): preferred_ous = kwargs.pop('preferred_ous', []) + self.authenticator = kwargs.pop('authenticator') super().__init__(*args, **kwargs) @@ -60,10 +61,10 @@ class AuthenticationForm(auth_forms.AuthenticationForm): factor=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR, ) - if not app_settings.A2_USER_REMEMBER_ME: + if not self.authenticator.remember_me: del self.fields['remember_me'] - if not app_settings.A2_LOGIN_FORM_OU_SELECTOR: + if not self.authenticator.include_ou_selector: del self.fields['ou'] else: if preferred_ous: @@ -135,7 +136,7 @@ class AuthenticationForm(auth_forms.AuthenticationForm): def media(self): media = super().media media = media + Media(js=['authentic2/js/js_seconds_until.js']) - if app_settings.A2_LOGIN_FORM_OU_SELECTOR: + if self.authenticator.include_ou_selector: media = media + Media(js=['authentic2/js/ou_selector.js']) return media diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index 73649cf90..41fdb10d0 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -191,7 +191,7 @@ AUTH_FRONTENDS = ( 'authentic2_auth_saml.authenticators.SAMLAuthenticator', 'authentic2_auth_oidc.authenticators.OIDCAuthenticator', 'authentic2_auth_fc.authenticators.FcAuthenticator', -) + plugins.register_plugins_authenticators(('authentic2.authenticators.LoginPasswordAuthenticator',)) +) ########################### # RBAC settings diff --git a/src/authentic2/utils/misc.py b/src/authentic2/utils/misc.py index 92669766e..f9ed83d96 100644 --- a/src/authentic2/utils/misc.py +++ b/src/authentic2/utils/misc.py @@ -164,9 +164,17 @@ def get_backends(setting_name='IDP_BACKENDS'): '''Return the list of enabled cleaned backends.''' backends = [] if setting_name == 'AUTH_FRONTENDS': - from authentic2.apps.authenticators.models import BaseAuthenticator + from authentic2.apps.authenticators.models import BaseAuthenticator, LoginPasswordAuthenticator - backends = list(BaseAuthenticator.authenticators.filter(enabled=True)) + backends = list( + BaseAuthenticator.authenticators.filter(enabled=True).exclude(slug='password-authenticator') + ) + password_backend, dummy = LoginPasswordAuthenticator.objects.get_or_create( + slug='password-authenticator', + defaults={'enabled': True}, + ) + if password_backend.enabled: + backends.append(password_backend) for backend_path in getattr(app_settings, setting_name): kwargs = {} diff --git a/src/authentic2/views.py b/src/authentic2/views.py index bc647e777..082943057 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -28,6 +28,7 @@ from django.contrib.auth import logout as auth_logout from django.contrib.auth.decorators import login_required from django.contrib.auth.views import PasswordChangeView as DjPasswordChangeView from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.db.models import Count from django.db.models.query import Q from django.db.transaction import atomic from django.forms import CharField @@ -51,7 +52,9 @@ from django.views.generic.base import RedirectView, View from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from ratelimit.utils import is_ratelimited +from authentic2.a2_rbac.models import Role from authentic2.custom_user.models import iter_attributes +from authentic2.forms import authentication as authentication_forms from authentic2_idp_oidc.models import OIDCAuthorization from . import app_settings, attribute_kinds, cbv, constants, decorators, hooks, models, validators @@ -67,6 +70,7 @@ from .utils import switch_user as utils_switch_user from .utils.evaluate import make_condition_context from .utils.service import get_service, set_home_url from .utils.view_decorators import enable_view_restriction +from .utils.views import csrf_token_check User = get_user_model() @@ -697,6 +701,107 @@ def logout(request, next_url=None, do_local=True, check_referer=True): return response +def login_password_login(request, authenticator, *args, **kwargs): + def get_service_ous(service): + roles = Role.objects.filter(allowed_services=service).children() + if not roles: + return [] + service_ou_ids = [] + qs = ( + User.objects.filter(roles__in=roles) + .values_list('ou') + .annotate(count=Count('ou')) + .order_by('-count') + ) + for ou_id, dummy_count in qs: + if not ou_id: + continue + service_ou_ids.append(ou_id) + if not service_ou_ids: + return [] + return OU.objects.filter(pk__in=service_ou_ids) + + def get_preferred_ous(request): + service = get_service(request) + preferred_ous_cookie = utils_misc.get_remember_cookie(request, 'preferred-ous') + preferred_ous = [] + if preferred_ous_cookie: + preferred_ous.extend(OU.objects.filter(pk__in=preferred_ous_cookie)) + # for the special case of services open to only one OU, pre-select it + if service: + for ou in get_service_ous(service): + if ou in preferred_ous: + continue + preferred_ous.append(ou) + return preferred_ous + + context = kwargs.get('context', {}) + is_post = request.method == 'POST' and 'login-password-submit' in request.POST + data = request.POST if is_post else None + initial = {} + preferred_ous = [] + request.failed_logins = {} + + # Special handling when the form contains an OU selector + if authenticator.include_ou_selector: + preferred_ous = get_preferred_ous(request) + if preferred_ous: + initial['ou'] = preferred_ous[0] + + form = authentication_forms.AuthenticationForm( + request=request, data=data, initial=initial, preferred_ous=preferred_ous, authenticator=authenticator + ) + if request.user.is_authenticated and request.login_token.get('action'): + form.initial['username'] = request.user.username or request.user.email + form.fields['username'].widget.attrs['readonly'] = True + form.fields['password'].widget.attrs['autofocus'] = True + else: + form.fields['username'].widget.attrs['autofocus'] = not (bool(context.get('block_index'))) + if app_settings.A2_ACCEPT_EMAIL_AUTHENTICATION: + form.fields['username'].label = _('Username or email') + if app_settings.A2_USERNAME_LABEL: + form.fields['username'].label = app_settings.A2_USERNAME_LABEL + is_secure = request.is_secure + context['submit_name'] = 'login-password-submit' + if is_post: + csrf_token_check(request, form) + if form.is_valid(): + if is_secure: + how = 'password-on-https' + else: + how = 'password' + if form.cleaned_data.get('remember_me'): + request.session['remember_me'] = True + request.session.set_expiry(authenticator.remember_me) + response = utils_misc.login(request, form.get_user(), how) + if 'ou' in form.fields: + utils_misc.prepend_remember_cookie( + request, response, 'preferred-ous', form.cleaned_data['ou'].pk + ) + + if hasattr(request, 'needs_password_change'): + del request.needs_password_change + return utils_misc.redirect( + request, 'password_change', params={'next': response.url}, resolve=True + ) + + return response + else: + username = form.cleaned_data.get('username', '').strip() + if request.failed_logins: + for user, failure_data in request.failed_logins.items(): + request.journal.record( + 'user.login.failure', + user=user, + reason=failure_data.get('reason', None), + username=username, + ) + elif username: + request.journal.record('user.login.failure', username=username) + context['form'] = form + return render(request, 'authentic2/login_password_form.html', context) + + def login_password_profile(request, *args, **kwargs): context = kwargs.pop('context', {}) can_change_password = utils_misc.user_can_change_password(request=request) diff --git a/tests/auth_fc/test_auth_fc.py b/tests/auth_fc/test_auth_fc.py index c4e72ba5d..58b12b5c6 100644 --- a/tests/auth_fc/test_auth_fc.py +++ b/tests/auth_fc/test_auth_fc.py @@ -30,6 +30,7 @@ from django.utils.timezone import now from authentic2.a2_rbac.models import OrganizationalUnit as OU from authentic2.a2_rbac.utils import get_default_ou +from authentic2.apps.authenticators.models import LoginPasswordAuthenticator from authentic2.apps.journal.models import Event from authentic2.custom_user.models import DeletedUser from authentic2.models import Attribute @@ -76,7 +77,9 @@ def test_login_with_condition(settings, app, franceconnect): def test_login_autorun(settings, app, franceconnect): # hide password block - settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': 'remote_addr==\'0.0.0.0\''}} + LoginPasswordAuthenticator.objects.update_or_create( + slug='password-authenticator', defaults={'enabled': False} + ) response = app.get('/login/') assert response.location.startswith('https://fcp') diff --git a/tests/test_auth_oidc.py b/tests/test_auth_oidc.py index c39c2839d..f2c61015c 100644 --- a/tests/test_auth_oidc.py +++ b/tests/test_auth_oidc.py @@ -38,6 +38,7 @@ from jwcrypto.jwt import JWT from authentic2.a2_rbac.models import OrganizationalUnit from authentic2.a2_rbac.utils import get_default_ou +from authentic2.apps.authenticators.models import LoginPasswordAuthenticator from authentic2.custom_user.models import DeletedUser from authentic2.models import Attribute, AttributeValue from authentic2.utils.misc import last_authentication_event @@ -494,7 +495,9 @@ def test_login_autorun(oidc_provider, app, settings): assert 'Server' in response # hide password block - settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': 'remote_addr==\'0.0.0.0\''}} + LoginPasswordAuthenticator.objects.update_or_create( + slug='password-authenticator', defaults={'enabled': False} + ) response = app.get('/login/', status=302) assert response['Location'] == '/accounts/oidc/login/%s/' % oidc_provider.pk diff --git a/tests/test_auth_saml.py b/tests/test_auth_saml.py index cd95bb8da..4cb7a01b3 100644 --- a/tests/test_auth_saml.py +++ b/tests/test_auth_saml.py @@ -24,6 +24,7 @@ from django.contrib.auth import get_user_model from mellon.adapters import UserCreationError from mellon.models import Issuer, UserSAMLIdentifier +from authentic2.apps.authenticators.models import LoginPasswordAuthenticator from authentic2.custom_user.models import DeletedUser from authentic2.models import Attribute from authentic2_auth_saml.adapters import AuthenticAdapter, MappingError @@ -278,7 +279,9 @@ def test_login_autorun(db, app, settings): {"METADATA": os.path.join(os.path.dirname(__file__), 'metadata.xml')} ] # hide password block - settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': 'remote_addr==\'0.0.0.0\''}} + LoginPasswordAuthenticator.objects.update_or_create( + slug='password-authenticator', defaults={'enabled': False} + ) response = app.get('/login/', status=302) assert '/accounts/saml/login/?entityID=' in response['Location'] diff --git a/tests/test_ldap.py b/tests/test_ldap.py index 181b291ef..9c82e2ef7 100644 --- a/tests/test_ldap.py +++ b/tests/test_ldap.py @@ -36,6 +36,7 @@ from ldaptools.slapd import Slapd, has_slapd from authentic2 import models from authentic2.a2_rbac.models import OrganizationalUnit, Role from authentic2.a2_rbac.utils import get_default_ou +from authentic2.apps.authenticators.models import LoginPasswordAuthenticator from authentic2.backends import ldap_backend from authentic2.models import Service from authentic2.utils import crypto, switch_user @@ -1776,7 +1777,7 @@ def test_ou_selector(slapd, settings, app, ou1): 'use_tls': False, } ] - settings.A2_LOGIN_FORM_OU_SELECTOR = True + LoginPasswordAuthenticator.objects.update(include_ou_selector=True) # Check login to the wrong ou does not work response = app.get('/login/') @@ -1806,7 +1807,7 @@ def test_ou_selector_default_ou(slapd, settings, app, ou1): 'use_tls': False, } ] - settings.A2_LOGIN_FORM_OU_SELECTOR = True + LoginPasswordAuthenticator.objects.update(include_ou_selector=True) # Check login to the wrong ou does not work response = app.get('/login/') diff --git a/tests/test_login.py b/tests/test_login.py index 94262fb6a..12b5bfc30 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -20,6 +20,7 @@ import pytest from django.contrib.auth import get_user_model from authentic2 import models +from authentic2.apps.authenticators.models import LoginPasswordAuthenticator from authentic2.utils.misc import get_token_login_url from .utils import assert_event, login, set_service @@ -72,14 +73,14 @@ def test_show_condition(db, app, settings, caplog): response = app.get('/login/') assert 'name="login-password-submit"' in response - settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': 'False'}} + LoginPasswordAuthenticator.objects.update(show_condition='False') response = app.get('/login/') # login form must not be displayed assert 'name="login-password-submit"' not in response assert len(caplog.records) == 0 # set a condition with error - settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': '\'admin\' in unknown'}} + LoginPasswordAuthenticator.objects.update(show_condition='\'admin\' in unknown') response = app.get('/login/') assert 'name="login-password-submit"' in response assert len(caplog.records) == 1 @@ -88,7 +89,7 @@ def test_show_condition(db, app, settings, caplog): def test_show_condition_service(db, rf, app, settings): portal = models.Service.objects.create(pk=1, name='Service', slug='portal') service = models.Service.objects.create(pk=2, name='Service', slug='service') - settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': 'service_slug == \'portal\''}} + LoginPasswordAuthenticator.objects.update(show_condition='service_slug == \'portal\'') response = app.get('/login/') assert 'name="login-password-submit"' not in response @@ -104,9 +105,9 @@ def test_show_condition_service(db, rf, app, settings): assert 'name="login-password-submit"' not in response -def test_show_condition_with_headers(app, settings): +def test_show_condition_with_headers(db, app, settings): settings.A2_AUTH_OIDC_ENABLE = False # prevent db access by OIDC frontend - settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': '\'X-Entrouvert\' in headers'}} + LoginPasswordAuthenticator.objects.update(show_condition='\'X-Entrouvert\' in headers') response = app.get('/login/') assert 'name="login-password-submit"' not in response response = app.get('/login/', headers={'x-entrouvert': '1'}) @@ -172,7 +173,7 @@ def test_session_expire(app, simple_user, freezer): def test_session_remember_me_ok(app, settings, simple_user, freezer): - settings.A2_USER_REMEMBER_ME = 3600 * 24 * 30 + LoginPasswordAuthenticator.objects.update(remember_me=3600 * 24 * 30) freezer.move_to('2018-01-01') # Verify session are longer login(app, simple_user, remember_me=True) @@ -187,7 +188,7 @@ def test_session_remember_me_ok(app, settings, simple_user, freezer): def test_session_remember_me_nok(app, settings, simple_user, freezer): - settings.A2_USER_REMEMBER_ME = 3600 * 24 * 30 + LoginPasswordAuthenticator.objects.update(remember_me=3600 * 24 * 30) freezer.move_to('2018-01-01') # Verify session are longer login(app, simple_user, remember_me=True) @@ -202,7 +203,7 @@ def test_session_remember_me_nok(app, settings, simple_user, freezer): def test_ou_selector(app, settings, simple_user, ou1, ou2, user_ou1, role_ou1): - settings.A2_LOGIN_FORM_OU_SELECTOR = True + LoginPasswordAuthenticator.objects.update(include_ou_selector=True) response = app.get('/login/') # Check selector is here and there are no errors assert not response.pyquery('.errorlist') @@ -359,3 +360,30 @@ def test_token_login(app, simple_user): assert simple_user.first_name in resp.text assert app.session['_auth_user_id'] == str(simple_user.pk) assert_event('user.login', user=simple_user, session=app.session, how='token') + + +def test_password_authenticator_data_migration(migration, settings): + app = 'authenticators' + migrate_from = [(app, '0002_loginpasswordauthenticator')] + migrate_to = [(app, '0003_auto_20220413_1504')] + + old_apps = migration.before(migrate_from) + LoginPasswordAuthenticator = old_apps.get_model(app, 'LoginPasswordAuthenticator') + assert not LoginPasswordAuthenticator.objects.exists() + + settings.AUTH_FRONTENDS_KWARGS = { + "password": {"priority": -1, "show_condition": "'backoffice' not in login_hint"} + } + settings.A2_LOGIN_FORM_OU_SELECTOR = True + settings.A2_AUTH_PASSWORD_ENABLE = False + settings.A2_USER_REMEMBER_ME = 42 + + new_apps = migration.apply(migrate_to) + LoginPasswordAuthenticator = new_apps.get_model(app, 'LoginPasswordAuthenticator') + authenticator = LoginPasswordAuthenticator.objects.get() + assert authenticator.slug == 'password-authenticator' + assert authenticator.order == -1 + assert authenticator.show_condition == "'backoffice' not in login_hint" + assert authenticator.enabled is False + assert authenticator.remember_me == 42 + assert authenticator.include_ou_selector is True diff --git a/tests/test_manager_authenticators.py b/tests/test_manager_authenticators.py index 693643ebb..961243026 100644 --- a/tests/test_manager_authenticators.py +++ b/tests/test_manager_authenticators.py @@ -27,3 +27,50 @@ def test_authenticators_authorization(app, simple_user, superuser): resp = resp.click('Authenticators') assert 'Authenticators' in resp.text + + +def test_authenticators_password(app, superuser): + resp = login(app, superuser, path='/manage/authenticators/') + # Password authenticator already exists + assert 'Password' in resp.text + + resp = resp.click('Configure') + assert 'Click "Edit" to change configuration.' in resp.text + # cannot delete password authenticator + assert 'Delete' not in resp.text + app.get('/manage/authenticators/1/delete/', status=403) + + resp = resp.click('Edit') + assert list(resp.form.fields) == [ + 'csrfmiddlewaretoken', + 'order', + 'show_condition', + 'remember_me', + 'include_ou_selector', + None, + ] + + resp.form['show_condition'] = '}' + resp = resp.form.submit() + assert 'template syntax error: Could not parse' in resp.text + + resp.form['show_condition'] = "'backoffice' in login_hint or remotre_addr == '1.2.3.4'" + resp = resp.form.submit().follow() + assert 'Click "Edit" to change configuration.' not in resp.text + assert ( + "Show condition: 'backoffice' in login_hint or remotre_addr == '1.2.3.4'" in resp.text + ) + + resp = resp.click('Disable').follow() + assert 'Authenticator has been disabled.' in resp.text + + resp = app.get('/manage/authenticators/') + assert 'class="section disabled"' in resp.text + + resp = resp.click('Configure') + resp = resp.click('Enable').follow() + assert 'Authenticator has been enabled.' in resp.text + + # cannot add another password authenticator + resp = app.get('/manage/authenticators/add/') + assert 'Password' not in resp.text -- 2.30.2