From: Patrick Uiterwijk Date: Thu, 13 Nov 2014 09:18:05 +0000 (+0100) Subject: Add support for Persona Identity Provider X-Git-Tag: v0.3.0~7 X-Git-Url: http://git.cascardo.info/?p=cascardo%2Fipsilon.git;a=commitdiff_plain;h=943158d19f879eb6ad515edeb59017671e4252c5 Add support for Persona Identity Provider Signed-off-by: Patrick Uiterwijk Reviewed-by: Simo Sorce --- diff --git a/contrib/fedora/ipsilon.spec b/contrib/fedora/ipsilon.spec index a28cbf3..0546296 100644 --- a/contrib/fedora/ipsilon.spec +++ b/contrib/fedora/ipsilon.spec @@ -12,6 +12,7 @@ BuildRequires: python2-devel BuildRequires: python-setuptools BuildRequires: lasso-python BuildRequires: python-openid, python-openid-cla, python-openid-teams +BuildRequires: m2crypto Requires: ipsilon-tools = %{version}-%{release} Requires: ipsilon-provider = %{version}-%{release} Requires: mod_wsgi @@ -67,6 +68,17 @@ Requires: python-openid-teams Provides an OpenId provider plugin for the Ipsilon identity Provider +%package persona +Summary: Persona provider plugin +Group: System Environment/Base +License: GPLv3+ +Provides: ipsilon-provider = %{version}-%{release} +Requires: m2crypto + +%description persona +Provides a Persona provider plugin for the Ipsilon identity Provider + + %package authfas Summary: Fedora Authentication System login plugin Group: System Environment/Base @@ -192,6 +204,10 @@ fi %{python2_sitelib}/ipsilon/providers/openid* %{_datadir}/ipsilon/templates/openid/* +%files persona +%{python2_sitelib}/ipsilon/providers/persona* +%{_datadir}/ipsilon/templates/persona/* + %files authfas %{python2_sitelib}/ipsilon/login/authfas* diff --git a/ipsilon/install/ipsilon-server-install b/ipsilon/install/ipsilon-server-install index df2a965..1b9e58f 100755 --- a/ipsilon/install/ipsilon-server-install +++ b/ipsilon/install/ipsilon-server-install @@ -93,6 +93,9 @@ def install(plugins, args): args['httpd_conf'] = os.path.join(HTTPDCONFD, 'ipsilon-%s.conf' % args['instance']) args['data_dir'] = os.path.join(DATADIR, args['instance']) + args['public_data_dir'] = os.path.join(args['data_dir'], 'public') + args['wellknown_dir'] = os.path.join(args['public_data_dir'], + 'well-known') if os.path.exists(ipsilon_conf): shutil.move(ipsilon_conf, '%s.bakcup.%s' % (ipsilon_conf, now)) if os.path.exists(idp_conf): @@ -101,6 +104,8 @@ def install(plugins, args): os.makedirs(instance_conf, 0700) confopts = {'instance': args['instance'], 'datadir': args['data_dir'], + 'publicdatadir': args['public_data_dir'], + 'wellknowndir': args['wellknown_dir'], 'sysuser': args['system_user'], 'ipsilondir': BINDIR, 'staticdir': STATICDIR, @@ -142,6 +147,10 @@ def install(plugins, args): confopts) if not os.path.exists(args['httpd_conf']): os.symlink(idp_conf, args['httpd_conf']) + if not os.path.exists(args['public_data_dir']): + os.makedirs(args['public_data_dir'], 0755) + if not os.path.exists(args['wellknown_dir']): + os.makedirs(args['wellknown_dir'], 0755) sessdir = os.path.join(args['data_dir'], 'sessions') if not os.path.exists(sessdir): os.makedirs(sessdir, 0700) diff --git a/ipsilon/login/common.py b/ipsilon/login/common.py index b394fa0..ce921c5 100755 --- a/ipsilon/login/common.py +++ b/ipsilon/login/common.py @@ -179,16 +179,18 @@ class LoginFormBase(LoginPageBase): cookie = SecureCookie(USERNAME_COOKIE) cookie.receive() username = cookie.value - if username is None: - username = '' target = None if self.trans is not None: tid = self.trans.transaction_id target = self.trans.retrieve().get('login_target') + username = self.trans.retrieve().get('login_username') if tid is None: tid = '' + if username is None: + username = '' + context = { "title": 'Login', "action": '%s/%s' % (self.basepath, self.formpage), diff --git a/ipsilon/providers/persona/__init__.py b/ipsilon/providers/persona/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ipsilon/providers/persona/auth.py b/ipsilon/providers/persona/auth.py new file mode 100755 index 0000000..a8e771b --- /dev/null +++ b/ipsilon/providers/persona/auth.py @@ -0,0 +1,152 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 Ipsilon project Contributors, for licensee see COPYING + +from ipsilon.providers.common import ProviderPageBase +from ipsilon.util.trans import Transaction +from ipsilon.util.user import UserSession + +import base64 +import cherrypy +import time +import json +import M2Crypto + + +class AuthenticateRequest(ProviderPageBase): + + def __init__(self, *args, **kwargs): + super(AuthenticateRequest, self).__init__(*args, **kwargs) + self.trans = None + + def _preop(self, *args, **kwargs): + try: + # generate a new id or get current one + self.trans = Transaction('persona', **kwargs) + if self.trans.cookie.value != self.trans.provider: + self.debug('Invalid transaction, %s != %s' % ( + self.trans.cookie.value, self.trans.provider)) + except Exception, e: # pylint: disable=broad-except + self.debug('Transaction initialization failed: %s' % repr(e)) + raise cherrypy.HTTPError(400, 'Invalid transaction id') + + def pre_GET(self, *args, **kwargs): + self._preop(*args, **kwargs) + + def pre_POST(self, *args, **kwargs): + self._preop(*args, **kwargs) + + +class Sign(AuthenticateRequest): + + def _base64_url_decode(self, inp): + inp += '=' * (4 - (len(inp) % 4)) + return base64.urlsafe_b64decode(inp) + + def _base64_url_encode(self, inp): + return base64.urlsafe_b64encode(inp).replace('=', '') + + def _persona_sign(self, email, publicKey, certDuration): + self.debug('Signing for %s with duration of %s' % (email, + certDuration)) + header = {'alg': 'RS256'} + header = json.dumps(header) + header = self._base64_url_encode(header) + + claim = {} + # Valid from 10 seconds before now to account for clock skew + claim['iat'] = 1000 * int(time.time() - 10) + # Validity of at most 24 hours + claim['exp'] = 1000 * int(time.time() + + min(certDuration, 24 * 60 * 60)) + + claim['iss'] = self.cfg.issuer_domain + claim['public-key'] = json.loads(publicKey) + claim['principal'] = {'email': email} + + claim = json.dumps(claim) + claim = self._base64_url_encode(claim) + + certificate = '%s.%s' % (header, claim) + digest = M2Crypto.EVP.MessageDigest('sha256') + digest.update(certificate) + signature = self.cfg.key.sign(digest.digest(), 'sha256') + signature = self._base64_url_encode(signature) + signed_certificate = '%s.%s' % (certificate, signature) + + return signed_certificate + + def _willing_to_sign(self, email, username): + for domain in self.cfg.allowed_domains: + if email == ('%s@%s' % (username, domain)): + return True + return False + + def POST(self, *args, **kwargs): + if 'email' not in kwargs or 'publicKey' not in kwargs \ + or 'certDuration' not in kwargs or '@' not in kwargs['email']: + cherrypy.response.status = 400 + raise Exception('Invalid request: %s' % kwargs) + + us = UserSession() + user = us.get_user() + + if user.is_anonymous: + raise cherrypy.HTTPError(401, 'Not signed in') + + if not self._willing_to_sign(kwargs['email'], user.name): + self.log('Not willing to sign for %s, logged in as %s' % ( + kwargs['email'], user.name)) + raise cherrypy.HTTPError(403, 'Incorrect user') + + return self._persona_sign(kwargs['email'], kwargs['publicKey'], + kwargs['certDuration']) + + +class SignInResult(AuthenticateRequest): + def GET(self, *args, **kwargs): + user = UserSession().get_user() + + return self._template('persona/signin_result.html', + loggedin=not user.is_anonymous) + + +class SignIn(AuthenticateRequest): + def __init__(self, *args, **kwargs): + super(SignIn, self).__init__(*args, **kwargs) + self.result = SignInResult(*args, **kwargs) + self.trans = None + + def GET(self, *args, **kwargs): + username = None + domain = None + if 'email' in kwargs: + if '@' in kwargs['email']: + username, domain = kwargs['email'].split('@', 2) + self.debug('Persona SignIn requested for: %s@%s' % (username, + domain)) + + returl = '%s/persona/SignIn/result?%s' % ( + self.basepath, self.trans.get_GET_arg()) + data = {'login_return': returl, + 'login_target': 'Persona', + 'login_username': username} + self.trans.store(data) + redirect = '%s/login?%s' % (self.basepath, + self.trans.get_GET_arg()) + self.debug('Redirecting: %s' % redirect) + raise cherrypy.HTTPRedirect(redirect) + + +class Persona(AuthenticateRequest): + + def __init__(self, *args, **kwargs): + super(Persona, self).__init__(*args, **kwargs) + self.Sign = Sign(*args, **kwargs) + self.SignIn = SignIn(*args, **kwargs) + self.trans = None + + def GET(self, *args, **kwargs): + user = UserSession().get_user() + return self._template('persona/provisioning.html', + loggedin=not user.is_anonymous) diff --git a/ipsilon/providers/personaidp.py b/ipsilon/providers/personaidp.py new file mode 100755 index 0000000..355726d --- /dev/null +++ b/ipsilon/providers/personaidp.py @@ -0,0 +1,130 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 Ipsilon project Contributors, for licensee see COPYING + +from __future__ import absolute_import + +from ipsilon.providers.common import ProviderBase +from ipsilon.util.plugin import PluginObject +from ipsilon.util import config as pconfig +from ipsilon.info.common import InfoMapping +from ipsilon.providers.persona.auth import Persona +from ipsilon.tools import files + +import json +import M2Crypto +import os + + +class IdpProvider(ProviderBase): + + def __init__(self, *pargs): + super(IdpProvider, self).__init__('persona', 'persona', *pargs) + self.mapping = InfoMapping() + self.page = None + self.basepath = None + self.key = None + self.key_info = None + self.description = """ +Provides Persona authentication infrastructure. """ + + self.new_config( + self.name, + pconfig.String( + 'issuer domain', + 'The issuer domain of the Persona provider', + 'localhost'), + pconfig.String( + 'idp key file', + 'The key where the Persona key is stored.', + 'persona.key'), + pconfig.List( + 'allowed domains', + 'List of domains this IdP is willing to issue claims for.'), + ) + + @property + def issuer_domain(self): + return self.get_config_value('issuer domain') + + @property + def idp_key_file(self): + return self.get_config_value('idp key file') + + @property + def allowed_domains(self): + return self.get_config_value('allowed domains') + + def get_tree(self, site): + self.init_idp() + self.page = Persona(site, self) + # self.admin = AdminPage(site, self) + + return self.page + + def init_idp(self): + # Init IDP data + try: + self.key = M2Crypto.RSA.load_key(self.idp_key_file, + lambda *args: None) + except Exception, e: # pylint: disable=broad-except + self._debug('Failed to init Persona provider: %r' % e) + return None + + def on_enable(self): + super(IdpProvider, self).on_enable() + self.init_idp() + + +class Installer(object): + + def __init__(self, *pargs): + self.name = 'persona' + self.ptype = 'provider' + self.pargs = pargs + + def install_args(self, group): + group.add_argument('--persona', choices=['yes', 'no'], default='yes', + help='Configure Persona Provider') + + def configure(self, opts): + if opts['persona'] != 'yes': + return + + # Check storage path is present or create it + path = os.path.join(opts['data_dir'], 'persona') + if not os.path.exists(path): + os.makedirs(path, 0700) + + keyfile = os.path.join(path, 'persona.key') + exponent = 0x10001 + key = M2Crypto.RSA.gen_key(2048, exponent) + key.save_key(keyfile, cipher=None) + key_n = 0 + for c in key.n[4:]: + key_n = (key_n*256) + ord(c) + wellknown = dict() + wellknown['authentication'] = '/%s/persona/SignIn/' % opts['instance'] + wellknown['provisioning'] = '/%s/persona/' % opts['instance'] + wellknown['public-key'] = {'algorithm': 'RS', + 'e': str(exponent), + 'n': str(key_n)} + with open(os.path.join(opts['wellknown_dir'], 'browserid'), 'w') as f: + f.write(json.dumps(wellknown)) + + # Add configuration data to database + po = PluginObject(*self.pargs) + po.name = 'persona' + po.wipe_data() + po.wipe_config_values() + config = {'issuer domain': opts['hostname'], + 'idp key file': keyfile, + 'allowed domains': opts['hostname']} + po.save_plugin_config(config) + + # Update global config to add login plugin + po.is_enabled = True + po.save_enabled_state() + + # Fixup permissions so only the ipsilon user can read these files + files.fix_user_dirs(path, opts['system_user']) diff --git a/setup.py b/setup.py index b3d5e96..27fd395 100755 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ setup( 'ipsilon.providers', 'ipsilon.providers.saml2', 'ipsilon.providers.openid', 'ipsilon.providers.openid.extensions', + 'ipsilon.providers.persona', 'ipsilon.tools', 'ipsilon.helpers', 'tests', 'tests.helpers'], data_files = [('share/man/man7', ["man/ipsilon.7"]), @@ -50,6 +51,7 @@ setup( (DATA+'templates/login', glob('templates/login/*.html')), (DATA+'templates/saml2', glob('templates/saml2/*.html')), (DATA+'templates/openid', glob('templates/openid/*.html')), + (DATA+'templates/persona', glob('templates/persona/*.html')), (DATA+'templates/install', glob('templates/install/*.conf')), (DATA+'templates/install/saml2', glob('templates/install/saml2/*.conf')), diff --git a/templates/install/idp.conf b/templates/install/idp.conf index 19af096..9cf2595 100644 --- a/templates/install/idp.conf +++ b/templates/install/idp.conf @@ -1,4 +1,5 @@ Alias /${instance}/ui ${staticdir}/ui +Alias /.well-known %{wellknowndir} WSGIScriptAlias /${instance} ${ipsilondir}/ipsilon WSGIDaemonProcess ${instance} user=${sysuser} group=${sysuser} home=${datadir} ${wsgi_socket} @@ -15,3 +16,10 @@ ${sslrequiressl} Require all granted + + + Require all granted + + + ForceType application/json + diff --git a/templates/persona/provisioning.html b/templates/persona/provisioning.html new file mode 100644 index 0000000..a693cac --- /dev/null +++ b/templates/persona/provisioning.html @@ -0,0 +1,62 @@ +{% extends "master.html" %} +{% block main %} +
+
+

This page is used internally

+
+
+ + + +{% endblock %} diff --git a/templates/persona/signin_result.html b/templates/persona/signin_result.html new file mode 100644 index 0000000..cda130d --- /dev/null +++ b/templates/persona/signin_result.html @@ -0,0 +1,22 @@ +{% extends "master.html" %} +{% block main %} +
+
+

This page is used internally

+
+
+ + + +{% endblock %}