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