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: