diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py index aeda609326941428e483e05cabd6e5412b2832f7..030e56d6da582a522443a26a912be0af37eca091 100644 --- a/aleksis/core/apps.py +++ b/aleksis/core/apps.py @@ -1,6 +1,8 @@ from typing import Any, List, Optional, Tuple import django.apps +from django.conf import settings +from django.db import ProgrammingError from django.http import HttpRequest from django.utils.module_loading import autodiscover_modules @@ -12,7 +14,7 @@ from .registries import ( site_preferences_registry, ) from .util.apps import AppConfig -from .util.core_helpers import has_person +from .util.core_helpers import get_site_preferences, has_person, lazy_preference from .util.sass_helpers import clean_scss @@ -48,6 +50,26 @@ class CoreConfig(AppConfig): preference_models.register(personpreferencemodel, person_preferences_registry) preference_models.register(grouppreferencemodel, group_preferences_registry) + self._refresh_authentication_backends() + + @classmethod + def _refresh_authentication_backends(cls): + """Refresh config list of enabled authentication backends.""" + from .preferences import AuthenticationBackends # noqa + + idx = settings.AUTHENTICATION_BACKENDS.index("django.contrib.auth.backends.ModelBackend") + + try: + # Don't set array directly in order to keep object reference + settings._wrapped.AUTHENTICATION_BACKENDS.clear() + settings._wrapped.AUTHENTICATION_BACKENDS += settings.ORIGINAL_AUTHENTICATION_BACKENDS + + for backend in get_site_preferences()["auth__backends"]: + settings._wrapped.AUTHENTICATION_BACKENDS.insert(idx, backend) + idx += 1 + except ProgrammingError: + pass + def preference_updated( self, sender: Any, @@ -57,6 +79,9 @@ class CoreConfig(AppConfig): new_value: Optional[Any] = None, **kwargs, ) -> None: + if section == "auth" and name == "backends": + self._refresh_authentication_backends() + if section == "theme": if name in ("primary", "secondary"): clean_scss() diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index 6693aff1fba605180b6f83e7741fe1cb005267fe..f0aeb001062b0a1c0fc49d3bf8fcf6f25da67d32 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -3,7 +3,12 @@ from django.forms import EmailField, ImageField, URLField from django.utils.translation import gettext_lazy as _ from dynamic_preferences.preferences import Section -from dynamic_preferences.types import ChoicePreference, FilePreference, StringPreference +from dynamic_preferences.types import ( + ChoicePreference, + FilePreference, + MultipleChoicePreference, + StringPreference, +) from .models import Person from .registries import person_preferences_registry, site_preferences_registry @@ -16,6 +21,7 @@ mail = Section("mail") notification = Section("notification") footer = Section("footer") account = Section("account") +auth = Section("auth", verbose_name=_("Authentication")) @site_preferences_registry.register @@ -178,3 +184,14 @@ class SchoolNameOfficial(StringPreference): default = "" required = False verbose_name = _("Official name of the school, e.g. as given by supervisory authority") + + +@site_preferences_registry.register +class AuthenticationBackends(MultipleChoicePreference): + section = auth + name = "backends" + default = None + verbose_name = _("Enabled custom authentication backends") + + def get_choices(self): + return [(b, b) for b in settings.CUSTOM_AUTHENTICATION_BACKENDS] diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index e6de512b3be12e211fbc7f91453848f326e17da3..cf5adee484c91c5bc96ddf76d24f48a65f331e86 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -282,10 +282,17 @@ if _settings.get("ldap.uri", None): "is_superuser" ] +CUSTOM_AUTHENTICATION_BACKENDS = [] +merge_app_settings("AUTHENTICATION_BACKENDS", CUSTOM_AUTHENTICATION_BACKENDS) + # Add ModelBckend last so all other backends get a chance # to verify passwords first AUTHENTICATION_BACKENDS.append("django.contrib.auth.backends.ModelBackend") +# Structure of items: backend, URL name, icon name, button title +ALTERNATIVE_LOGIN_VIEWS = [] +merge_app_settings("ALTERNATIVE_LOGIN_VIEWS", ALTERNATIVE_LOGIN_VIEWS, True) + # Internationalization # https://docs.djangoproject.com/en/2.1/topics/i18n/ @@ -683,3 +690,5 @@ HEALTH_CHECK = { "DISK_USAGE_MAX": _settings.get("health.disk_usage_max_percent", 90), "MEMORY_MIN": _settings.get("health.memory_min_mb", 500), } + +ORIGINAL_AUTHENTICATION_BACKENDS = AUTHENTICATION_BACKENDS[:] diff --git a/aleksis/core/templates/core/partials/alternative_login_options.html b/aleksis/core/templates/core/partials/alternative_login_options.html new file mode 100644 index 0000000000000000000000000000000000000000..24d36e9ef76f3da11554169106fc1eafd3c7ac46 --- /dev/null +++ b/aleksis/core/templates/core/partials/alternative_login_options.html @@ -0,0 +1,10 @@ +{% if ALTERNATIVE_LOGIN_VIEWS %} + <p> + {% for backend, url, icon, text in ALTERNATIVE_LOGIN_VIEWS %} + <a class="btn-large waves-effect waves-light primary" href="{% url url %}"> + <i class="material-icons left">{{ icon }}</i> + {{ text }} + </a> + {% endfor %} + </p> +{% endif %} diff --git a/aleksis/core/templates/two_factor/core/login.html b/aleksis/core/templates/two_factor/core/login.html index 6e0df1a4e4fa35a411f9e71cb41868808c942045..0e62194597024dbb613d2c13669513c33dcb9d05 100644 --- a/aleksis/core/templates/two_factor/core/login.html +++ b/aleksis/core/templates/two_factor/core/login.html @@ -16,14 +16,26 @@ {% blocktrans %}You have no permission to view this page. Please login with an other account.{% endblocktrans %} </p> </div> - {% else %} + {% elif wizard.steps.current == 'auth' %} <div class="alert primary"> <p> <i class="material-icons left">info</i> - {% if wizard.steps.current == 'auth' %} - {% blocktrans %}Please login to see this page.{% endblocktrans %} - {% elif wizard.steps.current == 'token' %} + {% blocktrans %}Please login to see this page.{% endblocktrans %} + </p> + </div> + {% endif %} + + {% if wizard.steps.current == 'auth' and ALTERNATIVE_LOGIN_VIEWS %} + <h5>{% trans "Login with username and password" %}</h5> + {% endif %} + + {% if not wizard.steps.current == "auth" %} + <div class="alert primary"> + <p> + <i class="material-icons left">info</i> + + {% if wizard.steps.current == 'token' %} {% if device.method == 'call' %} {% blocktrans %}We are calling your phone right now, please enter the digits you hear.{% endblocktrans %} @@ -70,7 +82,16 @@ </p> {% endif %} - <button type="submit" class="btn green waves-effect waves-light">{% trans "Login" %}</button> + <button type="submit" class="btn green waves-effect waves-light"> + {% trans "Login" %} + <i class="material-icons right">send</i> + </button> </form> + + {% if wizard.steps.current == 'auth' and ALTERNATIVE_LOGIN_VIEWS %} + <h5>{% trans "Use alternative login options" %}</h5> + {% include "core/partials/alternative_login_options.html" %} + {% endif %} + {% endblock %} diff --git a/aleksis/core/tests/test_authentication_backends.py b/aleksis/core/tests/test_authentication_backends.py new file mode 100644 index 0000000000000000000000000000000000000000..227f48c1469d97f246132a4bfbef2f5af5f4dbb9 --- /dev/null +++ b/aleksis/core/tests/test_authentication_backends.py @@ -0,0 +1,64 @@ +from typing import Optional + +from django.contrib.auth import authenticate +from django.contrib.auth.backends import BaseBackend +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth.models import User + +import pytest + +from aleksis.core.apps import CoreConfig +from aleksis.core.util.core_helpers import get_site_preferences + +pytestmark = pytest.mark.django_db + + +class DummyBackend(BaseBackend): + def authenticate( + self, request, username: str, password: str, **kwargs + ) -> Optional[AbstractBaseUser]: + if username == "foo" and password == "baz": + return User.objects.get_or_create(username="foo")[0] + + +backend_name = "aleksis.core.tests.test_authentication_backends.DummyBackend" + + +def test_backends_simple(settings): + + assert not authenticate(username="foo", password="baz") + + assert backend_name not in settings.AUTHENTICATION_BACKENDS + + settings.AUTHENTICATION_BACKENDS.append(backend_name) + assert backend_name in settings.AUTHENTICATION_BACKENDS + + assert authenticate(username="foo", password="baz") + + settings.AUTHENTICATION_BACKENDS.remove(backend_name) + + assert not authenticate(username="foo", password="baz") + + +def test_backends_with_activation(settings): + assert not authenticate(username="foo", password="baz") + + settings.CUSTOM_AUTHENTICATION_BACKENDS.append(backend_name) + + assert backend_name not in get_site_preferences()["auth__backends"] + assert backend_name not in settings.AUTHENTICATION_BACKENDS + assert not authenticate(username="foo", password="baz") + + print(get_site_preferences()["auth__backends"]) + print(get_site_preferences()["auth__backends"].append(backend_name)) + + get_site_preferences()["auth__backends"] = [backend_name] + + assert backend_name in get_site_preferences()["auth__backends"] + assert backend_name in settings.AUTHENTICATION_BACKENDS + assert authenticate(username="foo", password="baz") + + get_site_preferences()["auth__backends"] = [] + + assert backend_name not in get_site_preferences()["auth__backends"] + assert backend_name not in settings.AUTHENTICATION_BACKENDS diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index 29711935cf420f1af690aeae6a13c1bcc49f97b5..d188f6cf29c6909809ec03f81a7601dbcc41c4f9 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -323,6 +323,14 @@ def custom_information_processor(request: HttpRequest) -> dict: return { "FOOTER_MENU": CustomMenu.get_default("footer"), + "ALTERNATIVE_LOGIN_VIEWS_LIST": [ + a[0] + for a in settings.ALTERNATIVE_LOGIN_VIEWS + if a[0] in settings.AUTHENTICATION_BACKENDS + ], + "ALTERNATIVE_LOGIN_VIEWS": [ + a for a in settings.ALTERNATIVE_LOGIN_VIEWS if a[0] in settings.AUTHENTICATION_BACKENDS + ], }