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]