From 64f1a1c9185d2b2451bdfca184e8003a856bb0de Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Thu, 20 Nov 2025 08:51:51 -0800 Subject: [PATCH] apache: Implement protecting apps using OpenID Connect - Use the excellent Apache module auth_openidc. - Implement macros that can be easily used to configure OpenID Connect. Tests: - Accessing /freedombox/apache/discover-idp/ shows - 'method' other than 'get' throw a 'bad request' error - oidc_callback should match host. Otherwise 'bad request' error is raised. - Mismatched host header is not allowed - Invalid domain setup is not allowed - target_link_uri is returned as is - method is returned as is and only 'get' is allowed. - x_csrf is returned as is - oidc_scopes is returned as 'email freedombox_groups' - HTTP request is answered and not redirected to https - When logging in with OIDC, authorization is skipped. When authorization is shown, it is shown as 'Web app protected by FreedomBox'. - libapache2-mod-auth-openidc is added a dependency for freedombox package. It is installable in stable, testing, and unstable distributions. - On applying patches, Apache setup configuration is run and OpenIDC component is created. - When patches are applied and setup install is run, auth_openidc module, 10-freedombox, freedombox-openidc config is enabled in Apache. - When setup is rerun, passphrase is not changed - metadata directory and parent are created when apache setup is run. Mode is 0o700 and ownership is www-data. - freedombox-openidc is created when apache setup is run and has 0o700 permissions. - Metadata directory will contain the client id and client passphrase when discovery happens for a particular domain. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- .../conf-available/freedombox-tls.conf | 1 + plinth/modules/apache/__init__.py | 115 ++++++++++++++- .../apache2/conf-available/10-freedombox.conf | 28 ++++ plinth/modules/apache/privileged.py | 134 +++++++++++++++++- plinth/modules/apache/urls.py | 14 +- plinth/modules/apache/views.py | 71 ++++++++++ plinth/tests/functional/__init__.py | 4 +- 7 files changed, 355 insertions(+), 12 deletions(-) create mode 100644 plinth/modules/apache/data/usr/share/freedombox/etc/apache2/conf-available/10-freedombox.conf create mode 100644 plinth/modules/apache/views.py diff --git a/data/etc/apache2/conf-available/freedombox-tls.conf b/data/etc/apache2/conf-available/freedombox-tls.conf index 1a3cf184b..1593fa561 100644 --- a/data/etc/apache2/conf-available/freedombox-tls.conf +++ b/data/etc/apache2/conf-available/freedombox-tls.conf @@ -12,6 +12,7 @@ # Don't redirect for onion sites as it is not needed and leads to # unnecessary warning. RewriteCond %{HTTP_HOST} !^.*\.onion$ [NC] + RewriteCond %{REQUEST_URI} !^/freedombox/apache/discover-idp/$ [NC] ReWriteCond %{HTTPS} !=on RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] diff --git a/plinth/modules/apache/__init__.py b/plinth/modules/apache/__init__.py index a73483c76..c64793f21 100644 --- a/plinth/modules/apache/__init__.py +++ b/plinth/modules/apache/__init__.py @@ -1,16 +1,79 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""FreedomBox app for Apache server.""" +"""FreedomBox app for Apache server. +This module implements a mechanism for protecting various URL paths using +FreedomBox's OpenID Connect implementation as Identity Provider and +mod_auth_oidc as Relying Party. The following is a simplified description of +how the flow works: + +- User accesses the URL /foo using a browser. /foo is a URL path which is + protected by this module's OpenID Connect SSO (using AuthType + openid-connect). + +- mod_auth_opendic seizes control and checks for authorization. Since this is + the first visit, it starts the authentication/authorization process. It first + redirects the browser to provider discovery URL + /freedombox/apache/discover-idp/. + +- This URL selects and Identity Provider based on incoming URL's host header. + It will select https://mydomain.example/freedombox/o as IDP if the original + URL is https://mydomain.example/foo. Or https://freedombox.local/freedombox/o + if the original URL is https://freedombox.local/foo. After selection it will + redirect the browser back to /apache/oidc/callback with the selected IDP in + the GET parameters. + +- /apache/oidc/callback is controlled by mod_auth_openidc which receives the + IDP selection. It will then query the IDP for further information such as + authorization URL, token URL, supported scopes and claims. This is done using + a backend call to /freedombox/o/.well-known/openid-configuration. + +- After determining the authorization end point (/freedombox/o/authorize/) from + the metadata, mod_auth_openidc will start the authentication/authorization + process by redirecting the browser to the URL. + +- FreedomBox shows login page if the user is not already logged in. User logs + in. + +- FreedomBox will show a page asking the user to authorize the application to + access information such as name and email. In case of Apache's + mod_auth_openidc, this is skipped. + +- FreedomBox will redirect back to /apache/oidc/callback after various checks. + This request will contain authorization grant token and OIDC claims in + parameters. + +- mod_auth_openidc connects using back channel HTTP call to token endpoint + (/freedombox/o/token/) with the authorization grant token and then obtains + access token and refresh token. OIDC claims are checked using client_secret + known only to FreedomBox IDP and mod_auth_openidc. + +- The OIDC claims contains username as part of 'sub' claim. This is exported as + REMOTE_USER header. 'freedombox_groups' contains the list of groups that + FreedomBox account is part of. These, along with 'Require claim' Apache + configuration directives, are used to determine if the user should get access + to /foo path or not. + +- The application providing /foo will have access to information such username + and groups as part of REMOTE_USER and other OIDC_* environment variables. + +- mod_auth_openidc also sets cookies that ensure that the whole process is not + repeated when a second request for the path /foo is received. +""" + +import ipaddress import os from django.utils.translation import gettext_lazy as _ +from plinth import action_utils from plinth import app as app_module from plinth import cfg from plinth.config import DropinConfigs from plinth.daemon import Daemon, RelatedDaemon +from plinth.modules import names from plinth.modules.firewall.components import Firewall from plinth.modules.letsencrypt.components import LetsEncrypt +from plinth.modules.oidc.components import OpenIDConnect from plinth.package import Packages from plinth.signals import domain_added, domain_removed from plinth.utils import format_lazy, is_valid_user_name @@ -23,7 +86,7 @@ class ApacheApp(app_module.App): app_id = 'apache' - _version = 14 + _version = 15 def __init__(self) -> None: """Create components for the app.""" @@ -34,11 +97,13 @@ class ApacheApp(app_module.App): self.add(info) packages = Packages('packages-apache', [ - 'apache2', 'php-fpm', 'ssl-cert', 'uwsgi', 'uwsgi-plugin-python3' + 'apache2', 'php-fpm', 'ssl-cert', 'uwsgi', 'uwsgi-plugin-python3', + 'libapache2-mod-auth-openidc' ]) self.add(packages) dropin_configs = DropinConfigs('dropin-configs-apache', [ + '/etc/apache2/conf-available/10-freedombox.conf', '/etc/apache2/conf-available/php-fpm-freedombox.conf', '/etc/fail2ban/jail.d/apache-auth-freedombox.conf', ]) @@ -59,6 +124,13 @@ class ApacheApp(app_module.App): daemons=['apache2'], reload_daemons=True) self.add(letsencrypt) + openidconnect = OpenIDConnect( + 'openidconnect-apache', 'apache', + _('Web app protected by FreedomBox'), + redirect_uris=['https://{domain}/apache/oidc/callback'], + skip_authorization=True) + self.add(openidconnect) + daemon = Daemon('daemon-apache', 'apache2') self.add(daemon) @@ -78,6 +150,43 @@ class ApacheApp(app_module.App): self.enable() +def validate_host(hostname: str): + """Check whether we are allowed to be called by a given name. + + This is to prevent DNS rebinding attacks and other poor consequences in the + OpenID Connect protoctol. + """ + if hostname in ('localhost', 'ip6-localhost', 'ip6-loopback'): + return + + if hostname == action_utils.get_hostname(): + return + + if hostname in names.components.DomainName.list_names(): + return + + try: + ipaddress.ip_address(hostname) + return + except ValueError: + pass + + raise ValueError(f'Server not configured to be called as {hostname}') + + +def setup_oidc_client(netloc: str, hostname: str): + """Setup OpenID Connect client configuration. + + netloc is hostname or IP address along with port as parsed by + urllib.parse.urlparse() method from a URL. + """ + validate_host(hostname) + + oidc = app_module.App.get('apache').get_component('openidconnect-apache') + privileged.setup_oidc_client(netloc, oidc.client_id, + oidc.get_client_secret()) + + def _on_domain_added(sender, domain_type, name='', description='', services=None, **kwargs): """Add site specific configuration for a domain.""" diff --git a/plinth/modules/apache/data/usr/share/freedombox/etc/apache2/conf-available/10-freedombox.conf b/plinth/modules/apache/data/usr/share/freedombox/etc/apache2/conf-available/10-freedombox.conf new file mode 100644 index 000000000..33e8f03c8 --- /dev/null +++ b/plinth/modules/apache/data/usr/share/freedombox/etc/apache2/conf-available/10-freedombox.conf @@ -0,0 +1,28 @@ +## SPDX-License-Identifier: AGPL-3.0-or-later +## +## DO NOT EDIT. If you do, FreedomBox will not automatically upgrade. +## +## Apache configuration managed by FreedomBox. If customization is needed, +## create a new configuration file with higher priority and override directives. +## + +## +## Macro to protect directories or locations (potentially backed by application +## proxies) with OpenID Connect. To use the macro add 'Use AuthOpenIDConnect'. +## By default, the visitor will need to be part of the 'admin' group to be able +## to access the resource. Add additional groups using 'Use RequireGroup +## '. To debug OpenID Connect related communication add 'LogLevel +## auth_openidc:debug'. +## + + + AuthType openid-connect + Require claim freedombox_groups:admin + + + + + + Require claim freedombox_groups:$group + + diff --git a/plinth/modules/apache/privileged.py b/plinth/modules/apache/privileged.py index f58a0e791..880757d89 100644 --- a/plinth/modules/apache/privileged.py +++ b/plinth/modules/apache/privileged.py @@ -2,12 +2,22 @@ """Configure Apache web server.""" import glob +import json import os import pathlib import re +import shutil +import urllib.parse -from plinth import action_utils -from plinth.actions import privileged +import augeas + +from plinth import action_utils, utils +from plinth.actions import privileged, secret_str + +openidc_config_path = pathlib.Path( + '/etc/apache2/conf-available/freedombox-openidc.conf') +metadata_dir_path = pathlib.Path( + '/var/cache/apache2/mod_auth_openidc/metadata/') def _get_sort_key_of_version(version): @@ -70,6 +80,8 @@ def setup(old_version: int): action_utils.run(['make-ssl-cert', 'generate-default-snakeoil'], check=True) + _setup_oidc_config() + with action_utils.WebserverChange() as webserver: # Disable mod_php as we have switched to mod_fcgi + php-fpm. Disable # before switching away from mpm_prefork otherwise switching fails due @@ -114,6 +126,7 @@ def setup(old_version: int): webserver.enable('headers', kind='module') # Various modules for authentication/authorization + webserver.enable('auth_openidc', kind='module') webserver.enable('authnz_ldap', kind='module') webserver.enable('auth_pubtkt', kind='module') @@ -135,9 +148,11 @@ def setup(old_version: int): webserver.enable('dav', kind='module') webserver.enable('dav_fs', kind='module') - # setup freedombox site + # setup freedombox configuration + webserver.enable('10-freedombox', kind='config') webserver.enable('freedombox', kind='config') webserver.enable('freedombox-tls', kind='config') + webserver.enable('freedombox-openidc.conf', kind='config') # enable serving Debian javascript libraries webserver.enable('javascript-common', kind='config') @@ -151,6 +166,119 @@ def setup(old_version: int): webserver.enable('freedombox-default', kind='site') +def _load_augeas(): + """Initialize augeas for this app's configuration file.""" + aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + + augeas.Augeas.NO_MODL_AUTOLOAD) + aug.transform('Httpd', str(openidc_config_path)) + aug.set('/augeas/context', '/files' + str(openidc_config_path)) + aug.load() + + return aug + + +def _get_mod_openidc_passphrase() -> str: + """Read existing mod-auth-openidc passphase. + + Instead of generating a new passphrase every time, use existing one. If the + passphrase changes, all the existing sessions will be logged out and users + will have login to apps again. + """ + aug = _load_augeas() + for directive in aug.match('*/directive'): + if aug.get(directive) == 'OIDCCryptoPassphrase': + return aug.get(directive + '/arg') + + # Does not exist already, generate new + return utils.generate_password(size=64) + + +@privileged +def setup_oidc_client(netloc: str, client_id: str, client_secret: secret_str): + """Setup client ID and secret for provided domain. + + netloc is hostname or IP address along with port as parsed by + urllib.parse.urlparse() method from a URL. + """ + issuer = f'{netloc}/freedombox/o' + issuer_quoted = urllib.parse.quote_plus(issuer) + client_path = metadata_dir_path / f'{issuer_quoted}.client' + if client_path.exists(): + try: + current_data = json.loads(client_path.read_text()) + if (current_data['client_id'] == client_id + and current_data['client_secret'] == client_secret): + return + except Exception: + pass + + client_configuration = { + 'client_id': client_id, + 'client_secret': client_secret + } + previous_umask = os.umask(0o077) + try: + client_path.write_text(json.dumps(client_configuration)) + finally: + os.umask(previous_umask) + + shutil.chown(client_path, 'www-data', 'www-data') + + +def _setup_oidc_config(): + """Setup Apache as a OpenID Connect Relying Party. + + Ensure that auth_openidc module's metadata directory is created. It will be + used to store provider-specific configuration. Since FreedomBox will be + configured with multiple domains and some of them may not be accessible due + to the access method, we need to configure a separate IDP for each domain. + This is also because auth_openidc does not allow IDP configuration with + relative URLs. + + Keep the metadata directory and configuration file unreadable by non-admin + users since they contain module's crypto secret and OIDC client secret. + """ + metadata_dir_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata_dir_path.mkdir(mode=0o700, exist_ok=True) + shutil.chown(metadata_dir_path.parent, 'www-data', 'www-data') + shutil.chown(metadata_dir_path, 'www-data', 'www-data') + + # XXX: Default cache type is 'shm' or shared memory. This is lost when + # Apache is restarted and users/apps will have to reauthenticate. Improve + # this by using file (in tmpfs), redis, or memache caches. + + passphrase = _get_mod_openidc_passphrase() + configuration = f'''## +## OpenID Connect related configuration +## + + OIDCCryptoPassphrase {passphrase} + OIDCMetadataDir {str(metadata_dir_path)} + # Use relative URL to redirect to the same origin as the resource + OIDCDiscoverURL /freedombox/apache/discover-idp/ + OIDCSSLValidateServer Off + OIDCProviderMetadataRefreshInterval 86400 + + # Use relative URL to return to the original domain + OIDCRedirectURI /apache/oidc/callback + OIDCRemoteUserClaim sub + + # The redirect URI must always be under a location protected by + # mod_openidc. + + AuthType openid-connect + # Checking audience is not necessary, but we need to check some claim. + Require claim aud:apache + + +''' + previous_umask = os.umask(0o077) + try: + openidc_config_path.write_text(configuration) + finally: + os.umask(previous_umask) + + # TODO: Check that the (name, kind) is a managed by FreedomBox before # performing operation. @privileged diff --git a/plinth/modules/apache/urls.py b/plinth/modules/apache/urls.py index 5bf690c8c..d09d2ee70 100644 --- a/plinth/modules/apache/urls.py +++ b/plinth/modules/apache/urls.py @@ -1,6 +1,12 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -""" -URLs for the Apache module. -""" +"""URLs for the Apache module.""" -urlpatterns: list = [] +from django.urls import re_path +from stronghold.decorators import public + +from .views import DiscoverIDPView + +urlpatterns = [ + re_path(r'^apache/discover-idp/$', public(DiscoverIDPView.as_view()), + name='discover-idp'), +] diff --git a/plinth/modules/apache/views.py b/plinth/modules/apache/views.py new file mode 100644 index 000000000..1a65925b9 --- /dev/null +++ b/plinth/modules/apache/views.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Views for the Apache app.""" + +from urllib.parse import urlencode, urlparse + +from django.http import (HttpResponseBadRequest, HttpResponseRedirect, + HttpResponseServerError) +from django.views import View + +from . import setup_oidc_client, validate_host + +# By default 'openid' scope already included by mod_auth_openidc +OIDC_SCOPES = 'email freedombox_groups' + + +class DiscoverIDPView(View): + """A view called by auth_openidc Apache module to find the IDP. + + According to documentation for auth_openidc: an Issuer selection can be + passed back to the callback URL as in: + ?iss=[${issuer}|${domain}|${e-mail-style-account-name}] + [parameters][&login_hint=][&scopes=] + [&auth_request_params=] + + where the parameter contains the URL-encoded issuer value of the + selected Provider (or...), [parameters] contains the additional parameters + that were passed in on the discovery request (e.g. + target_link_uri=&x_csrf=&method=&scopes=) + """ + + def get(self, request): + """Redirect back to auth_openidc module after selecting a IDP.""" + target_link_uri = request.GET.get('target_link_uri', '') + method = request.GET.get('method', 'get') + x_csrf = request.GET.get('x_csrf', '') + oidc_callback = request.GET.get('oidc_callback') + + if method != 'get': + return HttpResponseBadRequest(f'Cannot handle "{method}" method') + + oidc_callback_parts = urlparse(oidc_callback) + request_host = request.META['HTTP_HOST'] + if request_host != oidc_callback_parts.netloc: + return HttpResponseBadRequest( + f'Cannot redirect from {request_host} to a different host ' + f'{oidc_callback_parts.netloc}') + + try: + validate_host(oidc_callback_parts.hostname) + except ValueError: + return HttpResponseBadRequest( + f'Accessed using unknown domain {request_host}. Please add ' + 'the domain to list of configured domains.') + + try: + setup_oidc_client(oidc_callback_parts.netloc, + oidc_callback_parts.hostname) + except ValueError: + return HttpResponseServerError( + f'Server not configured to called as {request_host}') + + url = '/apache/oidc/callback' + params = { + 'iss': f'https://{request_host}/freedombox/o', + 'target_link_uri': target_link_uri, + 'method': method, + 'x_csrf': x_csrf, + 'scopes': OIDC_SCOPES, + } + params = urlencode(params) + return HttpResponseRedirect(f'{url}?{params}') diff --git a/plinth/tests/functional/__init__.py b/plinth/tests/functional/__init__.py index 78d3f348a..a3f21564e 100644 --- a/plinth/tests/functional/__init__.py +++ b/plinth/tests/functional/__init__.py @@ -239,12 +239,12 @@ def is_available(browser, site_name): browser.visit(url_to_visit) time.sleep(3) browser.reload() - if '404' in browser.title or 'Page not found' in browser.title: + if ('404' in browser.title or '401 Unauthorized' in browser.title + or 'Page not found' in browser.title): return False # The site might have a default path after the sitename, # e.g /mediawiki/Main_Page - print('URL =', browser.url, url_to_visit, browser.title) browser_url = browser.url.partition('://')[2] url_to_visit_without_proto = url_to_visit.strip('/').partition('://')[2] return browser_url.startswith(url_to_visit_without_proto) # not a redirect