From a7584b465dadd1909abc0ac1039927b1ddf44d6c Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Mon, 24 Nov 2025 20:44:06 -0800 Subject: [PATCH] sso: Merge into users module, drop pubtkt related code Tests: - 'make install' removes enabled sso module - Already logged in users stay logged in after update - Apps need to re-authenticate of update (but this is transparent) - Login and logout work as expected - Failed login attempts lead to CAPTCHA form - CAPTCHA form can't be skipped - Answering CAPTCHA form will lead back to login page - Users functional tests work Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- Makefile | 3 +- plinth/modules/sso/__init__.py | 43 -------- .../includes/freedombox-single-sign-on.conf | 21 ---- .../usr/share/freedombox/modules-enabled/sso | 1 - plinth/modules/sso/forms.py | 28 ----- plinth/modules/sso/privileged.py | 104 ------------------ plinth/modules/sso/tests/__init__.py | 0 plinth/modules/sso/tests/test_functional.py | 30 ----- plinth/modules/sso/tests/test_privileged.py | 55 --------- plinth/modules/sso/urls.py | 22 ---- plinth/modules/sso/views.py | 97 ---------------- plinth/modules/users/__init__.py | 10 +- .../includes/freedombox-single-sign-on.conf | 1 + plinth/modules/users/forms.py | 21 ++++ .../templates/users_captcha.html} | 0 .../templates/users_login.html} | 0 plinth/modules/users/tests/test_functional.py | 21 ++++ plinth/modules/users/urls.py | 9 +- plinth/modules/users/views.py | 72 +++++++++++- 19 files changed, 124 insertions(+), 414 deletions(-) delete mode 100644 plinth/modules/sso/__init__.py delete mode 100644 plinth/modules/sso/data/usr/share/freedombox/etc/apache2/includes/freedombox-single-sign-on.conf delete mode 100644 plinth/modules/sso/data/usr/share/freedombox/modules-enabled/sso delete mode 100644 plinth/modules/sso/forms.py delete mode 100644 plinth/modules/sso/privileged.py delete mode 100644 plinth/modules/sso/tests/__init__.py delete mode 100644 plinth/modules/sso/tests/test_functional.py delete mode 100644 plinth/modules/sso/tests/test_privileged.py delete mode 100644 plinth/modules/sso/urls.py delete mode 100644 plinth/modules/sso/views.py create mode 100644 plinth/modules/users/data/usr/share/freedombox/etc/apache2/includes/freedombox-single-sign-on.conf rename plinth/modules/{sso/templates/captcha.html => users/templates/users_captcha.html} (100%) rename plinth/modules/{sso/templates/login.html => users/templates/users_login.html} (100%) diff --git a/Makefile b/Makefile index e5e5673b7..30ef0d97e 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,8 @@ DISABLED_APPS_TO_REMOVE := \ tahoe \ mldonkey \ i2p \ - ttrss + ttrss \ + sso APP_FILES_TO_REMOVE := $(foreach app,$(DISABLED_APPS_TO_REMOVE),$(ENABLED_APPS_PATH)/$(app)) diff --git a/plinth/modules/sso/__init__.py b/plinth/modules/sso/__init__.py deleted file mode 100644 index a649c2be2..000000000 --- a/plinth/modules/sso/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -"""FreedomBox app to configure Single Sign On services.""" - -from django.utils.translation import gettext_lazy as _ - -from plinth import app as app_module -from plinth.config import DropinConfigs -from plinth.package import Packages - -from . import privileged - - -class SSOApp(app_module.App): - """FreedomBox app for single sign on.""" - - app_id = 'sso' - - _version = 3 - - def __init__(self) -> None: - """Create components for the app.""" - super().__init__() - - info = app_module.Info(app_id=self.app_id, version=self._version, - is_essential=True, - depends=['security', - 'apache'], name=_('Single Sign On')) - self.add(info) - - packages = Packages( - 'packages-sso', - ['libapache2-mod-auth-pubtkt', 'python3-cryptography', 'flite']) - self.add(packages) - - dropin_configs = DropinConfigs('dropin-configs-sso', [ - '/etc/apache2/includes/freedombox-single-sign-on.conf', - ]) - self.add(dropin_configs) - - def setup(self, old_version): - """Install and configure the app.""" - super().setup(old_version) - privileged.create_key_pair() diff --git a/plinth/modules/sso/data/usr/share/freedombox/etc/apache2/includes/freedombox-single-sign-on.conf b/plinth/modules/sso/data/usr/share/freedombox/etc/apache2/includes/freedombox-single-sign-on.conf deleted file mode 100644 index 6ced5ea9f..000000000 --- a/plinth/modules/sso/data/usr/share/freedombox/etc/apache2/includes/freedombox-single-sign-on.conf +++ /dev/null @@ -1,21 +0,0 @@ - - - TKTAuthPublicKey /etc/apache2/auth-pubtkt-keys/pubkey.pem - TKTAuthLoginURL /freedombox/accounts/sso/login/ - TKTAuthBackArgName next - TKTAuthDigest SHA512 - TKTAuthRefreshURL /freedombox/accounts/sso/refresh/ - TKTAuthUnauthURL /freedombox - AuthType mod_auth_pubtkt - AuthName "FreedomBox Single Sign On" - Require valid-user - - - - Require all denied - - - # Require that LDAP account is not locked - AuthLDAPUrl "ldap:///ou=users,dc=thisbox?uid" - Require not ldap-attribute pwdAccountLockedTime="000001010000Z" - diff --git a/plinth/modules/sso/data/usr/share/freedombox/modules-enabled/sso b/plinth/modules/sso/data/usr/share/freedombox/modules-enabled/sso deleted file mode 100644 index 8f769e832..000000000 --- a/plinth/modules/sso/data/usr/share/freedombox/modules-enabled/sso +++ /dev/null @@ -1 +0,0 @@ -plinth.modules.sso diff --git a/plinth/modules/sso/forms.py b/plinth/modules/sso/forms.py deleted file mode 100644 index 605df651e..000000000 --- a/plinth/modules/sso/forms.py +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -""" -Forms for the Single Sign On app of FreedomBox. -""" - -from captcha.fields import CaptchaField -from django import forms -from django.contrib.auth.forms import \ - AuthenticationForm as DjangoAuthenticationForm -from django.utils.translation import gettext_lazy as _ - - -class AuthenticationForm(DjangoAuthenticationForm): - """Authentication form with an additional username field attributes.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['username'].widget.attrs.update({ - 'autofocus': 'autofocus', - 'autocapitalize': 'none', - 'autocomplete': 'username' - }) - - -class CaptchaForm(forms.Form): - """Form with a CAPTCHA field to use after 3 invalid login attempts.""" - captcha = CaptchaField( - label=_('Enter the letters in the image to proceed to the login page')) diff --git a/plinth/modules/sso/privileged.py b/plinth/modules/sso/privileged.py deleted file mode 100644 index 8ec1865a1..000000000 --- a/plinth/modules/sso/privileged.py +++ /dev/null @@ -1,104 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -"""Generate a auth_pubtkt ticket. - -Sign tickets with the FreedomBox server's private key. -""" - -import base64 -import datetime -import os -import pathlib - -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding, rsa - -from plinth.actions import privileged - -KEYS_DIRECTORY = '/etc/apache2/auth-pubtkt-keys' - - -@privileged -def create_key_pair(): - """Create public/private key pair for signing the tickets.""" - keys_directory = pathlib.Path(KEYS_DIRECTORY) - private_key_file = keys_directory / 'privkey.pem' - public_key_file = keys_directory / 'pubkey.pem' - - keys_directory.mkdir(exist_ok=True) - # Set explicitly in case permissions are incorrect - keys_directory.chmod(0o750) - if private_key_file.exists() and public_key_file.exists(): - # Set explicitly in case permissions are incorrect - public_key_file.chmod(0o440) - private_key_file.chmod(0o440) - return - - private_key = rsa.generate_private_key(public_exponent=65537, - key_size=4096) - - def opener(path, flags): - return os.open(path, flags, mode=0o440) - - with open(private_key_file, 'wb', opener=opener) as file_handle: - pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption()) - file_handle.write(pem) - - with open(public_key_file, 'wb', opener=opener) as file_handle: - public_key = private_key.public_key() - pem = public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo) - file_handle.write(pem) - - -def _create_ticket(private_key, uid, validuntil, ip=None, tokens=None, - udata=None, graceperiod=None, extra_fields=None): - """Create and return a signed mod_auth_pubtkt ticket.""" - tokens = ','.join(tokens) - fields = [ - f'uid={uid}', - f'validuntil={int(validuntil)}', - ip and f'cip={ip}', - tokens and f'tokens={tokens}', - graceperiod and f'graceperiod={int(graceperiod)}', - udata and f'udata={udata}', - extra_fields - and ';'.join(['{}={}'.format(k, v) for k, v in extra_fields]), - ] - data = ';'.join(filter(None, fields)) - signature = 'sig={}'.format(_sign(private_key, data)) - return ';'.join([data, signature]) - - -def _sign(private_key, data): - """Calculate and return ticket's signature.""" - signature = private_key.sign(data.encode(), padding.PKCS1v15(), - hashes.SHA512()) - return base64.b64encode(signature).decode() - - -@privileged -def generate_ticket(uid: str, private_key_file: str, tokens: list[str]) -> str: - """Generate a mod_auth_pubtkt ticket using login credentials.""" - with open(private_key_file, 'rb') as fil: - private_key = serialization.load_pem_private_key( - fil.read(), password=None) - - valid_until = _minutes_from_now(12 * 60) - grace_period = _minutes_from_now(11 * 60) - return _create_ticket(private_key, uid, valid_until, tokens=tokens, - graceperiod=grace_period) - - -def _minutes_from_now(minutes): - """Return a timestamp at the given number of minutes from now.""" - return _seconds_from_now(minutes * 60) - - -def _seconds_from_now(seconds): - """Return a timestamp at the given number of seconds from now.""" - return (datetime.datetime.now() + - datetime.timedelta(0, seconds)).timestamp() diff --git a/plinth/modules/sso/tests/__init__.py b/plinth/modules/sso/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/plinth/modules/sso/tests/test_functional.py b/plinth/modules/sso/tests/test_functional.py deleted file mode 100644 index 631853720..000000000 --- a/plinth/modules/sso/tests/test_functional.py +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -""" -Functional, browser based tests for sso app. -""" - -import pytest -from plinth.tests import functional - -pytestmark = [pytest.mark.system, pytest.mark.essential, pytest.mark.sso] - - -@pytest.fixture(scope='module', autouse=True) -def fixture_background(session_browser): - """Login and install the app.""" - functional.login(session_browser) - functional.install(session_browser, 'syncthing') - functional.app_enable(session_browser, 'syncthing') - yield - functional.app_disable(session_browser, 'syncthing') - - -def test_app_access(session_browser): - """Test that only logged-in users can access Syncthing web interface.""" - functional.logout(session_browser) - functional.access_url(session_browser, 'syncthing') - assert functional.is_login_prompt(session_browser) - - functional.login(session_browser) - functional.access_url(session_browser, 'syncthing') - assert functional.is_available(session_browser, 'syncthing') diff --git a/plinth/modules/sso/tests/test_privileged.py b/plinth/modules/sso/tests/test_privileged.py deleted file mode 100644 index 1784eef97..000000000 --- a/plinth/modules/sso/tests/test_privileged.py +++ /dev/null @@ -1,55 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -""" -Test module for sso module operations. -""" - -import os -import pathlib - -import pytest - -from plinth.modules.sso import privileged -from plinth.modules.sso.views import PRIVATE_KEY_FILE_NAME - -pytestmark = pytest.mark.usefixtures('mock_privileged') -privileged_modules_to_mock = ['plinth.modules.sso.privileged'] - - -@pytest.fixture(autouse=True) -def fixture_keys_directory(tmpdir): - """Set keys directory in the actions module.""" - privileged.KEYS_DIRECTORY = str(tmpdir) - - -@pytest.fixture(name='existing_key_pair') -def fixture_existing_key_pair(): - """A fixture to create key pair if needed.""" - privileged.create_key_pair() - keys_directory = pathlib.Path(privileged.KEYS_DIRECTORY) - assert keys_directory.stat().st_mode == 0o40750 - assert (keys_directory / 'privkey.pem').stat().st_mode == 0o100440 - assert (keys_directory / 'pubkey.pem').stat().st_mode == 0o100440 - - -def test_generate_ticket(existing_key_pair): - """Test generating a ticket.""" - username = 'tester' - groups = ['freedombox-share', 'syncthing', 'web-search'] - - private_key_file = os.path.join(privileged.KEYS_DIRECTORY, - PRIVATE_KEY_FILE_NAME) - ticket = privileged.generate_ticket(username, private_key_file, groups) - - fields = {} - for item in ticket.split(';'): - try: - key, value = item.split('=') - fields[key] = value - except ValueError: - # The 'sig' field can also contain '='. - continue - - assert fields['uid'] == username - assert int(fields['validuntil']) > 0 - assert fields['tokens'] == ','.join(groups) - assert int(fields['graceperiod']) > 0 diff --git a/plinth/modules/sso/urls.py b/plinth/modules/sso/urls.py deleted file mode 100644 index 8e4441649..000000000 --- a/plinth/modules/sso/urls.py +++ /dev/null @@ -1,22 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -""" -URLs for the Single Sign On module. -""" - -from django.urls import re_path -from stronghold.decorators import public - -from plinth.utils import non_admin_view - -from .views import CaptchaView, SSOLoginView, refresh - -urlpatterns = [ - re_path(r'^accounts/sso/login/$', public(SSOLoginView.as_view()), - name='sso-login'), - re_path(r'^accounts/sso/refresh/$', non_admin_view(refresh), - name='sso-refresh'), - - # Locked URL from django-axes - re_path(r'accounts/sso/login/locked/$', public(CaptchaView.as_view()), - name='locked_out'), -] diff --git a/plinth/modules/sso/views.py b/plinth/modules/sso/views.py deleted file mode 100644 index f94d0411f..000000000 --- a/plinth/modules/sso/views.py +++ /dev/null @@ -1,97 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -"""Views for the Single Sign On app of FreedomBox.""" - -import logging -import os -import urllib - -import axes.utils -from django import shortcuts -from django.contrib import messages -from django.contrib.auth import REDIRECT_FIELD_NAME -from django.contrib.auth import logout as auth_logout -from django.contrib.auth.views import LoginView -from django.http import HttpResponseRedirect -from django.utils.translation import gettext as _ -from django.views.decorators.http import require_POST -from django.views.generic.edit import FormView - -from plinth import translation - -from . import privileged -from .forms import AuthenticationForm, CaptchaForm - -PRIVATE_KEY_FILE_NAME = 'privkey.pem' -SSO_COOKIE_NAME = 'auth_pubtkt' -KEYS_DIRECTORY = '/etc/apache2/auth-pubtkt-keys' - -logger = logging.getLogger(__name__) - - -def set_ticket_cookie(user, response): - """Generate and set a mod_auth_pubtkt as a cookie in the response.""" - tokens = list(map(lambda g: g.name, user.groups.all())) - private_key_file = os.path.join(KEYS_DIRECTORY, PRIVATE_KEY_FILE_NAME) - ticket = privileged.generate_ticket(user.username, private_key_file, - tokens) - response.set_cookie(SSO_COOKIE_NAME, urllib.parse.quote(ticket)) - return response - - -class SSOLoginView(LoginView): - """View to login to FreedomBox and set a auth_pubtkt cookie. - - Cookie will be used to provide Single Sign On for some other applications. - """ - - redirect_authenticated_user = True - template_name = 'login.html' - form_class = AuthenticationForm - - def dispatch(self, request, *args, **kwargs): - """Handle a request and return a HTTP response.""" - response = super().dispatch(request, *args, **kwargs) - if request.user.is_authenticated: - translation.set_language(request, response, - request.user.userprofile.language) - return set_ticket_cookie(request.user, response) - - return response - - -class CaptchaView(FormView): - """A simple form view with a CAPTCHA image. - - When a user performs too many login attempts, they will no longer be able - to login with the typical login view. They will be redirected to this view. - On successfully solving the CAPTCHA in this form, their ability to use the - login form will be reset. - """ - - template_name = 'captcha.html' - form_class = CaptchaForm - - def form_valid(self, form): - """Reset login attempts and redirect to login page.""" - axes.utils.reset_request(self.request) - return shortcuts.redirect('users:login') - - -@require_POST -def logout(request): - """Logout an authenticated user, remove SSO cookie and redirect to home.""" - auth_logout(request) - response = shortcuts.redirect('index') - response.delete_cookie(SSO_COOKIE_NAME) - messages.success(request, _('Logged out successfully.')) - return response - - -def refresh(request): - """Simulate cookie refresh - redirect logged in user with a new cookie.""" - redirect_url = request.GET.get(REDIRECT_FIELD_NAME, '') - response = HttpResponseRedirect(redirect_url) - response.delete_cookie(SSO_COOKIE_NAME) - # Redirect with cookie doesn't work with 300 series - response.status_code = 200 - return set_ticket_cookie(request.user, response) diff --git a/plinth/modules/users/__init__.py b/plinth/modules/users/__init__.py index 67543f3cd..c8318f8e4 100644 --- a/plinth/modules/users/__init__.py +++ b/plinth/modules/users/__init__.py @@ -73,9 +73,13 @@ class UsersApp(app_module.App): ]) self.add(packages) - dropin_configs = DropinConfigs('dropin-configs-users', [ - '/etc/apache2/includes/freedombox-auth-ldap.conf', - ]) + dropin_configs = DropinConfigs( + 'dropin-configs-users', + [ + '/etc/apache2/includes/freedombox-auth-ldap.conf', + # Empty file kept for easier upgrade + '/etc/apache2/includes/freedombox-single-sign-on.conf', + ]) self.add(dropin_configs) daemon = Daemon('daemon-users', 'slapd', listen_ports=[(389, 'tcp4'), diff --git a/plinth/modules/users/data/usr/share/freedombox/etc/apache2/includes/freedombox-single-sign-on.conf b/plinth/modules/users/data/usr/share/freedombox/etc/apache2/includes/freedombox-single-sign-on.conf new file mode 100644 index 000000000..a3b2f25c7 --- /dev/null +++ b/plinth/modules/users/data/usr/share/freedombox/etc/apache2/includes/freedombox-single-sign-on.conf @@ -0,0 +1 @@ +# Empty file to easier upgrade process diff --git a/plinth/modules/users/forms.py b/plinth/modules/users/forms.py index 40419dda4..7ac9e8d03 100644 --- a/plinth/modules/users/forms.py +++ b/plinth/modules/users/forms.py @@ -4,8 +4,11 @@ import pwd import re +from captcha.fields import CaptchaField from django import forms from django.contrib import auth, messages +from django.contrib.auth.forms import \ + AuthenticationForm as DjangoAuthenticationForm from django.contrib.auth.forms import SetPasswordForm, UserCreationForm from django.contrib.auth.hashers import check_password from django.contrib.auth.models import Group, User @@ -25,6 +28,24 @@ from . import get_last_admin_user, privileged from .components import UsersAndGroups +class AuthenticationForm(DjangoAuthenticationForm): + """Authentication form with an additional username field attributes.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['username'].widget.attrs.update({ + 'autofocus': 'autofocus', + 'autocapitalize': 'none', + 'autocomplete': 'username' + }) + + +class CaptchaForm(forms.Form): + """Form with a CAPTCHA field to use after 3 invalid login attempts.""" + captcha = CaptchaField( + label=_('Enter the letters in the image to proceed to the login page')) + + class ValidNewUsernameCheckMixin: """Mixin to check if a username is valid for created new user.""" diff --git a/plinth/modules/sso/templates/captcha.html b/plinth/modules/users/templates/users_captcha.html similarity index 100% rename from plinth/modules/sso/templates/captcha.html rename to plinth/modules/users/templates/users_captcha.html diff --git a/plinth/modules/sso/templates/login.html b/plinth/modules/users/templates/users_login.html similarity index 100% rename from plinth/modules/sso/templates/login.html rename to plinth/modules/users/templates/users_login.html diff --git a/plinth/modules/users/tests/test_functional.py b/plinth/modules/users/tests/test_functional.py index b18f537c9..f11511f67 100644 --- a/plinth/modules/users/tests/test_functional.py +++ b/plinth/modules/users/tests/test_functional.py @@ -66,6 +66,27 @@ def fixture_login(session_browser): functional.login(session_browser) +@pytest.fixture(name='syncthing_installed') +def fixture_syncthing_installed(session_browser): + """Login and install the app.""" + functional.login(session_browser) + functional.install(session_browser, 'syncthing') + functional.app_enable(session_browser, 'syncthing') + yield + functional.app_disable(session_browser, 'syncthing') + + +def test_app_access(session_browser, syncthing_installed): + """Test that only logged-in users can access Syncthing web interface.""" + functional.logout(session_browser) + functional.access_url(session_browser, 'syncthing') + assert functional.is_login_prompt(session_browser) + + functional.login(session_browser) + functional.access_url(session_browser, 'syncthing') + assert functional.is_available(session_browser, 'syncthing') + + def test_create_user(session_browser): """Test creating a user.""" _delete_user(session_browser, 'alice') diff --git a/plinth/modules/users/urls.py b/plinth/modules/users/urls.py index 8123cd21d..3849d948d 100644 --- a/plinth/modules/users/urls.py +++ b/plinth/modules/users/urls.py @@ -6,7 +6,6 @@ URLs for the Users module from django.urls import re_path from stronghold.decorators import public -from plinth.modules.sso.views import CaptchaView, SSOLoginView, logout from plinth.utils import non_admin_view from . import views @@ -19,13 +18,11 @@ urlpatterns = [ re_path(r'^sys/users/(?P[\w.@+-]+)/change_password/$', non_admin_view(views.UserChangePassword.as_view()), name='change_password'), - - # Authnz is handled by SSO - re_path(r'^accounts/login/$', public(SSOLoginView.as_view()), + re_path(r'^accounts/login/$', public(views.LoginView.as_view()), name='login'), - re_path(r'^accounts/logout/$', public(logout), name='logout'), + re_path(r'^accounts/logout/$', public(views.logout), name='logout'), re_path(r'^users/firstboot/$', public(views.FirstBootView.as_view()), name='firstboot'), - re_path(r'accounts/login/locked/$', public(CaptchaView.as_view()), + re_path(r'accounts/login/locked/$', public(views.CaptchaView.as_view()), name='locked_out'), ] diff --git a/plinth/modules/users/views.py b/plinth/modules/users/views.py index 357bf1820..3d65a10cf 100644 --- a/plinth/modules/users/views.py +++ b/plinth/modules/users/views.py @@ -1,15 +1,22 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Django views for user app.""" +import axes.utils import django.views.generic +from django import shortcuts +from django.contrib import messages +from django.contrib.auth import logout as auth_logout from django.contrib.auth import update_session_auth_hash from django.contrib.auth.models import User +from django.contrib.auth.views import LoginView as DjangoLoginView from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from django.urls import reverse, reverse_lazy +from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy -from django.views.generic.edit import (CreateView, FormView, UpdateView) +from django.views.decorators.http import require_POST +from django.views.generic.edit import CreateView, FormView, UpdateView import plinth.modules.ssh.privileged as ssh_privileged from plinth import translation @@ -18,8 +25,67 @@ from plinth.utils import is_user_admin from plinth.views import AppView from . import privileged -from .forms import (CreateUserForm, FirstBootForm, UserChangePasswordForm, - UserUpdateForm) +from .forms import (AuthenticationForm, CaptchaForm, CreateUserForm, + FirstBootForm, UserChangePasswordForm, UserUpdateForm) + + +class LoginView(DjangoLoginView): + """View to login to FreedomBox and set language preference.""" + + redirect_authenticated_user = True + template_name = 'users_login.html' + form_class = AuthenticationForm + + def dispatch(self, request, *args, **kwargs): + """Handle a request and return a HTTP response.""" + response = super().dispatch(request, *args, **kwargs) + if request.user.is_authenticated: + translation.set_language(request, response, + request.user.userprofile.language) + + return response + + +class CaptchaView(FormView): + """A simple form view with a CAPTCHA image. + + When a user performs too many login attempts, they will no longer be able + to login with the typical login view. They will be redirected to this view. + On successfully solving the CAPTCHA in this form, their ability to use the + login form will be reset. + """ + + template_name = 'users_captcha.html' + form_class = CaptchaForm + + def form_valid(self, form): + """Reset login attempts and redirect to login page.""" + axes.utils.reset_request(self.request) + return shortcuts.redirect('users:login') + + +@require_POST +def logout(request): + """Logout an authenticated user, remove SSO cookie and redirect to home.""" + auth_logout(request) + response = shortcuts.redirect('index') + + # HACK: Remove Apache OpenID Connect module's session. This will logout all + # the apps using mod_auth_openidc for their authentication and + # authorization. A better way to do this is to implement OpenID Connect's + # Back-Channel Logout[1] or using OpenID Connect Session Management[2]. + # With this scheme, each application will register a logout URL during + # client registration. The OpenID Provider (FreedomBox service) will call + # this URL with appropriate parameters to perform logout on all the apps. + # Support for OpenID Connect Back-Channel Logout is currently under + # review[3]. + # 1. https://openid.net/specs/openid-connect-backchannel-1_0.html + # 2. https://openid.net/specs/openid-connect-session-1_0.html + # 3. https://github.com/django-oauth/django-oauth-toolkit/pull/1573 + response.delete_cookie('mod_auth_openidc_session') + + messages.success(request, _('Logged out successfully.')) + return response class ContextMixin: