diff --git a/.gitignore b/.gitignore index 1b524e6ca..fe23a6fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ plinth/tests/coverage/report/ .vagrant/ .idea/ .DS_Store +*.box diff --git a/actions/auth-pubtkt b/actions/auth-pubtkt new file mode 100755 index 000000000..0c148d6c7 --- /dev/null +++ b/actions/auth-pubtkt @@ -0,0 +1,136 @@ +#!/usr/bin/python3 +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Module with utilities to generate a auth_pubtkt ticket and +sign it with the FreedomBox server's private key. +""" + +import os +import time +import base64 +import datetime +import argparse + +from OpenSSL import crypto + +KEYS_DIRECTORY = '/etc/apache2/auth-pubtkt-keys' + + +def parse_arguments(): + """ Return parsed command line arguments as dictionary. """ + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + subparsers.add_parser( + 'create-key-pair', + help='create a key pair for the apache server ' + 'to sign auth_pubtkt tickets') + gen_tkt = subparsers.add_parser('generate-ticket', + help='generate auth_pubtkt ticket') + gen_tkt.add_argument('--uid', help='username of the user') + gen_tkt.add_argument('--private-key-file', + help='path of the private key file of the server') + gen_tkt.add_argument('--tokens', + help='tokens, usually containing the user groups') + + subparsers.required = True + return parser.parse_args() + + +def subcommand_create_key_pair(_): + """Create public/private key pair for signing the auth_pubtkt + tickets. + """ + private_key_file = os.path.join(KEYS_DIRECTORY, 'privkey.pem') + public_key_file = os.path.join(KEYS_DIRECTORY, 'pubkey.pem') + + os.path.exists(KEYS_DIRECTORY) or os.mkdir(KEYS_DIRECTORY) + + if not all([ + os.path.exists(key_file) + for key_file in [public_key_file, private_key_file] + ]): + pkey = crypto.PKey() + pkey.generate_key(crypto.TYPE_DSA, 1024) + + with open(private_key_file, 'w') as priv_key_file: + priv_key = crypto.dump_privatekey(crypto.FILETYPE_PEM, + pkey).decode() + priv_key_file.write(priv_key) + + with open(public_key_file, 'w') as pub_key_file: + pub_key = crypto.dump_publickey(crypto.FILETYPE_PEM, pkey).decode() + pub_key_file.write(pub_key) + + for fil in [public_key_file, private_key_file]: + os.chmod(fil, 0o440) + + +def create_ticket(pkey, uid, validuntil, ip=None, tokens=None, + udata=None, graceperiod=None, extra_fields=None): + """Create and return a signed mod_auth_pubtkt ticket.""" + fields = [ + 'uid={}'.format(uid), + 'validuntil={}'.format(validuntil, type='d'), + ip and 'cip={}'.format(ip), + tokens and 'tokens={}'.format(tokens), + graceperiod and 'graceperiod={}'.format(graceperiod, type='d'), + udata and 'udata={}'.format(udata), + extra_fields and ';'.join( + ['{}={}'.format(k, v) for k, v in extra_fields]) + ] + data = ';'.join(filter(None, fields)) + signature = 'sig={}'.format(sign(pkey, data)) + return ';'.join([data, signature]) + + +def sign(pkey, data): + """Calculates and returns ticket's signature.""" + sig = crypto.sign(pkey, data, 'sha1') + return base64.b64encode(sig).decode() + + +def subcommand_generate_ticket(arguments): + """Generate a mod_auth_pubtkt ticket using login credentials.""" + uid = arguments.uid + private_key_file = arguments.private_key_file + tokens = arguments.tokens + with open(private_key_file, 'r') as fil: + pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, fil.read().encode()) + valid_until = minutes_from_now(30) + grace_period = minutes_from_now(25) + print(create_ticket( + pkey, 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 (datetime.datetime.now() + datetime.timedelta(minutes)).timestamp() + + +def main(): + """Parse arguments and perform all duties.""" + arguments = parse_arguments() + + subcommand = arguments.subcommand.replace('-', '_') + subcommand_method = globals()['subcommand_' + subcommand] + subcommand_method(arguments) + + +if __name__ == '__main__': + main() diff --git a/actions/security b/actions/security index bb0afdcdd..be3ea966f 100755 --- a/actions/security +++ b/actions/security @@ -16,14 +16,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # - """ Helper for security configuration """ import argparse - ACCESS_CONF_FILE = '/etc/security/access.conf' ACCESS_CONF_SNIPPET = '-:ALL EXCEPT root fbx (admin) (sudo):ALL' diff --git a/data/etc/apache2/conf-available/repro-plinth.conf b/data/etc/apache2/conf-available/repro-plinth.conf index e1fa83b94..6ced94f09 100644 --- a/data/etc/apache2/conf-available/repro-plinth.conf +++ b/data/etc/apache2/conf-available/repro-plinth.conf @@ -4,7 +4,6 @@ ## ProxyPass http://localhost:5080 - - Include includes/freedombox-auth-ldap.conf - Require ldap-group cn=admin,ou=groups,dc=thisbox + Include includes/freedombox-single-sign-on.conf + TKTAuthToken "admin" diff --git a/data/etc/apache2/conf-available/syncthing-plinth.conf b/data/etc/apache2/conf-available/syncthing-plinth.conf index eae434c2a..f9f5e1d51 100644 --- a/data/etc/apache2/conf-available/syncthing-plinth.conf +++ b/data/etc/apache2/conf-available/syncthing-plinth.conf @@ -4,6 +4,8 @@ # Redirect /syncthing to /syncthing/ as the Syncthing server does not # work without a slash at the end. + + RewriteEngine On @@ -12,9 +14,8 @@ - - ProxyPass http://localhost:8384/ - Include includes/freedombox-auth-ldap.conf - Require ldap-group cn=admin,ou=groups,dc=thisbox + + Include includes/freedombox-single-sign-on.conf + ProxyPass http://localhost:8384/ diff --git a/data/etc/apache2/conf-available/tt-rss-plinth.conf b/data/etc/apache2/conf-available/tt-rss-plinth.conf index 685849830..0ec14d986 100644 --- a/data/etc/apache2/conf-available/tt-rss-plinth.conf +++ b/data/etc/apache2/conf-available/tt-rss-plinth.conf @@ -5,6 +5,5 @@ Alias /tt-rss /usr/share/tt-rss/www - Include includes/freedombox-auth-ldap.conf - Require valid-user + Include includes/freedombox-single-sign-on.conf diff --git a/data/etc/apache2/includes/freedombox-single-sign-on.conf b/data/etc/apache2/includes/freedombox-single-sign-on.conf new file mode 100644 index 000000000..a8ba00e70 --- /dev/null +++ b/data/etc/apache2/includes/freedombox-single-sign-on.conf @@ -0,0 +1,9 @@ +TKTAuthPublicKey /etc/apache2/auth-pubtkt-keys/pubkey.pem +TKTAuthLoginURL /plinth/accounts/sso/login/ +TKTAuthBackArgName next +TKTAuthDigest SHA1 +TKTAuthRefreshURL /plinth/accounts/sso/refresh/ +TKTAuthUnauthURL /plinth +AuthType mod_auth_pubtkt +AuthName "FreedomBox Single Sign On" +Require valid-user diff --git a/data/etc/plinth/modules-enabled/sso b/data/etc/plinth/modules-enabled/sso new file mode 100644 index 000000000..8f769e832 --- /dev/null +++ b/data/etc/plinth/modules-enabled/sso @@ -0,0 +1 @@ +plinth.modules.sso diff --git a/plinth/modules/sso/__init__.py b/plinth/modules/sso/__init__.py new file mode 100644 index 000000000..c22bcc964 --- /dev/null +++ b/plinth/modules/sso/__init__.py @@ -0,0 +1,45 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Plinth module to configure Single Sign On services. +""" + +from plinth import actions, action_utils +from django.utils.translation import ugettext_lazy as _ + +version = 1 + +is_essential = True + +depends = ['security'] + +title = _('Single Sign On') + +managed_packages = ['libapache2-mod-auth-pubtkt', 'openssl', 'python3-openssl'] + +first_boot_steps = [ + { + 'id': 'sso_firstboot', + 'url': 'sso:firstboot', + 'order': 1 + }, +] + + +def setup(helper, old_version=None): + """Install the required packages""" + helper.install(managed_packages) diff --git a/plinth/templates/login.html b/plinth/modules/sso/templates/login.html similarity index 100% rename from plinth/templates/login.html rename to plinth/modules/sso/templates/login.html diff --git a/plinth/modules/sso/urls.py b/plinth/modules/sso/urls.py new file mode 100644 index 000000000..a6b9c6254 --- /dev/null +++ b/plinth/modules/sso/urls.py @@ -0,0 +1,30 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +URLs for the Single Sign On module. +""" + +from django.conf.urls import url + +from .views import login, refresh, FirstBootView +from stronghold.decorators import public + +urlpatterns = [ + url(r'^accounts/sso/login/$', public(login), name='sso-login'), + url(r'^accounts/sso/refresh/$', refresh, name='sso-refresh'), + url(r'^accounts/sso/firstboot/$', public(FirstBootView.as_view()), name='firstboot'), +] diff --git a/plinth/modules/sso/views.py b/plinth/modules/sso/views.py new file mode 100644 index 000000000..7da3e5dbd --- /dev/null +++ b/plinth/modules/sso/views.py @@ -0,0 +1,86 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Views for the Single Sign On module of Plinth +""" + +import os +import urllib + +from plinth import actions +from plinth.modules import first_boot + +from django.urls import reverse +from django.http import HttpResponseRedirect +from django.views.generic.base import RedirectView +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import (login as auth_login, logout as + auth_logout) + +PRIVATE_KEY_FILE_NAME = 'privkey.pem' +SSO_COOKIE_NAME = 'auth_pubtkt' +KEYS_DIRECTORY = '/etc/apache2/auth-pubtkt-keys' + + +def set_ticket_cookie(user, response): + """Generate and set a mod_auth_pubtkt as a cookie in the provided + response. + """ + tokens = list(map(lambda g: g.name, user.groups.all())) + private_key_file = os.path.join(KEYS_DIRECTORY, PRIVATE_KEY_FILE_NAME) + ticket = actions.superuser_run('auth-pubtkt', [ + 'generate-ticket', '--uid', user.username, '--private-key-file', + private_key_file, '--tokens', ','.join(tokens) + ]) + response.set_cookie(SSO_COOKIE_NAME, urllib.parse.quote(ticket)) + return response + + +def login(request): + """Login to Plinth and set a auth_pubtkt cookie which will be + used to provide Single Sign On for some other applications + """ + response = auth_login( + request, template_name='login.html', redirect_authenticated_user=True) + return set_ticket_cookie( + request.user, response) if request.user.is_authenticated else response + + +def logout(request, next_page): + """Log out of Plinth and remove auth_pubtkt cookie""" + response = auth_logout(request, next_page=next_page) + response.delete_cookie(SSO_COOKIE_NAME) + return response + + +@login_required +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) + return set_ticket_cookie(request.user, response) + + +class FirstBootView(RedirectView): + """Create keys for Apache server during first boot""" + + def get_redirect_url(self, *args, **kwargs): + actions.superuser_run('auth-pubtkt', ['create-key-pair']) + first_boot.mark_step_done('sso_firstboot') + return reverse(first_boot.next_step()) diff --git a/plinth/modules/users/__init__.py b/plinth/modules/users/__init__.py index fc0f6861b..de65580e9 100644 --- a/plinth/modules/users/__init__.py +++ b/plinth/modules/users/__init__.py @@ -38,7 +38,7 @@ first_boot_steps = [ { 'id': 'users_firstboot', 'url': 'users:firstboot', - 'order': 1 + 'order': 2 }, ] diff --git a/plinth/modules/users/urls.py b/plinth/modules/users/urls.py index 5e3377268..0f548a2c1 100644 --- a/plinth/modules/users/urls.py +++ b/plinth/modules/users/urls.py @@ -20,11 +20,14 @@ URLs for the Users module """ from django.conf.urls import url -from django.contrib.auth import views as auth_views from django.urls import reverse_lazy from stronghold.decorators import public from plinth.utils import non_admin_view +from plinth.modules.sso.views import ( + login as sso_login, + logout as sso_logout +) from . import views @@ -39,9 +42,8 @@ urlpatterns = [ non_admin_view(views.UserChangePassword.as_view()), name='change_password'), # Add Django's login/logout urls - url(r'^accounts/login/$', public(auth_views.login), - {'template_name': 'login.html'}, name='login'), - url(r'^accounts/logout/$', public(auth_views.logout), + url(r'^accounts/login/$', public(sso_login), name='login'), + url(r'^accounts/logout/$', public(sso_logout), {'next_page': reverse_lazy('index')}, name='logout'), url(r'^users/firstboot/$', public(views.FirstBootView.as_view()), name='firstboot'), diff --git a/plinth/tests/runtests.py b/plinth/tests/runtests.py index b33edd70e..b218769df 100644 --- a/plinth/tests/runtests.py +++ b/plinth/tests/runtests.py @@ -39,7 +39,7 @@ def run_tests(pattern=None, return_to_caller=False): if pattern is None: pattern_list = [ 'plinth/tests', - 'plinth/modules' + 'plinth/modules', ] else: pattern_list = [pattern]