mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-09-19 04:59:01 +00:00
Add SSO using auth_pubtkt for 3 web apps
- Install mod_auth_pubtkt and generate public/private key-pair. - Redirect user to login page if no cookie is presented. - Add check for authenticated user for login page. - Temporarily switched to DSA because of a bug in mod_auth_pubtkt which causes it to accept only DSA and not RSA. Also had to use SHA1 instead of SHA256. - Enabled SSO for Syncthing, Repro and TT-RSS. - Using tokens to authorize by user groups. - Generate keys during first boot.
This commit is contained in:
parent
0cc8d3ec1d
commit
995365f3df
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,3 +23,4 @@ plinth/tests/coverage/report/
|
|||||||
.vagrant/
|
.vagrant/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.box
|
||||||
|
|||||||
136
actions/auth-pubtkt
Executable file
136
actions/auth-pubtkt
Executable file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
"""
|
||||||
|
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()
|
||||||
@ -16,14 +16,12 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Helper for security configuration
|
Helper for security configuration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
ACCESS_CONF_FILE = '/etc/security/access.conf'
|
ACCESS_CONF_FILE = '/etc/security/access.conf'
|
||||||
ACCESS_CONF_SNIPPET = '-:ALL EXCEPT root fbx (admin) (sudo):ALL'
|
ACCESS_CONF_SNIPPET = '-:ALL EXCEPT root fbx (admin) (sudo):ALL'
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
##
|
##
|
||||||
<Location /repro>
|
<Location /repro>
|
||||||
ProxyPass http://localhost:5080
|
ProxyPass http://localhost:5080
|
||||||
|
Include includes/freedombox-single-sign-on.conf
|
||||||
Include includes/freedombox-auth-ldap.conf
|
TKTAuthToken "admin"
|
||||||
Require ldap-group cn=admin,ou=groups,dc=thisbox
|
|
||||||
</Location>
|
</Location>
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
# Redirect /syncthing to /syncthing/ as the Syncthing server does not
|
# Redirect /syncthing to /syncthing/ as the Syncthing server does not
|
||||||
# work without a slash at the end.
|
# work without a slash at the end.
|
||||||
|
|
||||||
|
|
||||||
<Location /syncthing>
|
<Location /syncthing>
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
@ -12,9 +14,8 @@
|
|||||||
</IfModule>
|
</IfModule>
|
||||||
</Location>
|
</Location>
|
||||||
|
|
||||||
<Location /syncthing/>
|
|
||||||
ProxyPass http://localhost:8384/
|
|
||||||
|
|
||||||
Include includes/freedombox-auth-ldap.conf
|
<Location /syncthing/>
|
||||||
Require ldap-group cn=admin,ou=groups,dc=thisbox
|
Include includes/freedombox-single-sign-on.conf
|
||||||
|
ProxyPass http://localhost:8384/
|
||||||
</Location>
|
</Location>
|
||||||
|
|||||||
@ -5,6 +5,5 @@
|
|||||||
Alias /tt-rss /usr/share/tt-rss/www
|
Alias /tt-rss /usr/share/tt-rss/www
|
||||||
|
|
||||||
<Location /tt-rss>
|
<Location /tt-rss>
|
||||||
Include includes/freedombox-auth-ldap.conf
|
Include includes/freedombox-single-sign-on.conf
|
||||||
Require valid-user
|
|
||||||
</Location>
|
</Location>
|
||||||
|
|||||||
9
data/etc/apache2/includes/freedombox-single-sign-on.conf
Normal file
9
data/etc/apache2/includes/freedombox-single-sign-on.conf
Normal file
@ -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
|
||||||
1
data/etc/plinth/modules-enabled/sso
Normal file
1
data/etc/plinth/modules-enabled/sso
Normal file
@ -0,0 +1 @@
|
|||||||
|
plinth.modules.sso
|
||||||
45
plinth/modules/sso/__init__.py
Normal file
45
plinth/modules/sso/__init__.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
"""
|
||||||
|
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)
|
||||||
30
plinth/modules/sso/urls.py
Normal file
30
plinth/modules/sso/urls.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
"""
|
||||||
|
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'),
|
||||||
|
]
|
||||||
86
plinth/modules/sso/views.py
Normal file
86
plinth/modules/sso/views.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
"""
|
||||||
|
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())
|
||||||
@ -38,7 +38,7 @@ first_boot_steps = [
|
|||||||
{
|
{
|
||||||
'id': 'users_firstboot',
|
'id': 'users_firstboot',
|
||||||
'url': 'users:firstboot',
|
'url': 'users:firstboot',
|
||||||
'order': 1
|
'order': 2
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -20,11 +20,14 @@ URLs for the Users module
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.conf.urls import url
|
from django.conf.urls import url
|
||||||
from django.contrib.auth import views as auth_views
|
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from stronghold.decorators import public
|
from stronghold.decorators import public
|
||||||
|
|
||||||
from plinth.utils import non_admin_view
|
from plinth.utils import non_admin_view
|
||||||
|
from plinth.modules.sso.views import (
|
||||||
|
login as sso_login,
|
||||||
|
logout as sso_logout
|
||||||
|
)
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
@ -39,9 +42,8 @@ urlpatterns = [
|
|||||||
non_admin_view(views.UserChangePassword.as_view()),
|
non_admin_view(views.UserChangePassword.as_view()),
|
||||||
name='change_password'),
|
name='change_password'),
|
||||||
# Add Django's login/logout urls
|
# Add Django's login/logout urls
|
||||||
url(r'^accounts/login/$', public(auth_views.login),
|
url(r'^accounts/login/$', public(sso_login), name='login'),
|
||||||
{'template_name': 'login.html'}, name='login'),
|
url(r'^accounts/logout/$', public(sso_logout),
|
||||||
url(r'^accounts/logout/$', public(auth_views.logout),
|
|
||||||
{'next_page': reverse_lazy('index')}, name='logout'),
|
{'next_page': reverse_lazy('index')}, name='logout'),
|
||||||
url(r'^users/firstboot/$', public(views.FirstBootView.as_view()),
|
url(r'^users/firstboot/$', public(views.FirstBootView.as_view()),
|
||||||
name='firstboot'),
|
name='firstboot'),
|
||||||
|
|||||||
@ -39,7 +39,7 @@ def run_tests(pattern=None, return_to_caller=False):
|
|||||||
if pattern is None:
|
if pattern is None:
|
||||||
pattern_list = [
|
pattern_list = [
|
||||||
'plinth/tests',
|
'plinth/tests',
|
||||||
'plinth/modules'
|
'plinth/modules',
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
pattern_list = [pattern]
|
pattern_list = [pattern]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user