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 <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2025-11-24 20:44:06 -08:00 committed by James Valleroy
parent 6fd85e3e46
commit a7584b465d
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
19 changed files with 124 additions and 414 deletions

View File

@ -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))

View File

@ -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()

View File

@ -1,21 +0,0 @@
<RequireALL>
<IfModule mod_auth_pubtkt.c>
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
</IfModule>
<IfModule !mod_auth_pubtkt.c>
Require all denied
</IfModule>
# Require that LDAP account is not locked
AuthLDAPUrl "ldap:///ou=users,dc=thisbox?uid"
Require not ldap-attribute pwdAccountLockedTime="000001010000Z"
</RequireAll>

View File

@ -1 +0,0 @@
plinth.modules.sso

View File

@ -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'))

View File

@ -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()

View File

@ -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')

View File

@ -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

View File

@ -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'),
]

View File

@ -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)

View File

@ -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'),

View File

@ -0,0 +1 @@
# Empty file to easier upgrade process

View File

@ -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."""

View File

@ -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')

View File

@ -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<slug>[\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'),
]

View File

@ -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: