mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-03-11 09:04:54 +00:00
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:
parent
cdfbff0b6b
commit
64f1a1c918
@ -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>
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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>
|
||||
@ -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
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
71
plinth/modules/apache/views.py
Normal file
71
plinth/modules/apache/views.py
Normal 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}')
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user