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 <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2025-11-20 08:51:51 -08:00 committed by James Valleroy
parent cdfbff0b6b
commit 64f1a1c918
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
7 changed files with 355 additions and 12 deletions

View File

@ -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]
</LocationMatch>

View File

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

View File

@ -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
## <group>'. To debug OpenID Connect related communication add 'LogLevel
## auth_openidc:debug'.
##
<Macro AuthOpenIDConnect>
<IfModule mod_auth_openidc.c>
AuthType openid-connect
Require claim freedombox_groups:admin
</IfModule>
</Macro>
<Macro RequireGroup $group>
<IfModule mod_auth_openidc.c>
Require claim freedombox_groups:$group
</IfModule>
</Macro>

View File

@ -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
##
<IfModule mod_auth_openidc.c>
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.
<Location /apache>
AuthType openid-connect
# Checking audience is not necessary, but we need to check some claim.
Require claim aud:apache
</Location>
</IfModule>
'''
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

View File

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

View File

@ -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:
<callback-url>?iss=[${issuer}|${domain}|${e-mail-style-account-name}]
[parameters][&login_hint=<login-hint>][&scopes=<scopes>]
[&auth_request_params=<params>]
where the <iss> 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=<url>&x_csrf=<x_csrf>&method=<method>&scopes=<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}')

View File

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