Add support for Persona Identity Provider
authorPatrick Uiterwijk <puiterwijk@redhat.com>
Thu, 13 Nov 2014 09:18:05 +0000 (10:18 +0100)
committerSimo Sorce <simo@redhat.com>
Fri, 14 Nov 2014 18:06:27 +0000 (13:06 -0500)
Signed-off-by: Patrick Uiterwijk <puiterwijk@redhat.com>
Reviewed-by: Simo Sorce <simo@redhat.com>
contrib/fedora/ipsilon.spec
ipsilon/install/ipsilon-server-install
ipsilon/login/common.py
ipsilon/providers/persona/__init__.py [new file with mode: 0644]
ipsilon/providers/persona/auth.py [new file with mode: 0755]
ipsilon/providers/personaidp.py [new file with mode: 0755]
setup.py
templates/install/idp.conf
templates/persona/provisioning.html [new file with mode: 0644]
templates/persona/signin_result.html [new file with mode: 0644]

index a28cbf3..0546296 100644 (file)
@@ -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*
 
index df2a965..1b9e58f 100755 (executable)
@@ -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)
index b394fa0..ce921c5 100755 (executable)
@@ -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 (file)
index 0000000..e69de29
diff --git a/ipsilon/providers/persona/auth.py b/ipsilon/providers/persona/auth.py
new file mode 100755 (executable)
index 0000000..a8e771b
--- /dev/null
@@ -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 (executable)
index 0000000..355726d
--- /dev/null
@@ -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'])
index b3d5e96..27fd395 100755 (executable)
--- 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')),
index 19af096..9cf2595 100644 (file)
@@ -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}
 <Directory ${staticdir}>
     Require all granted
 </Directory>
+
+<Directory ${wellknowndir}>
+    Require all granted
+</Directory>
+<Location /.well-known/browserid>
+    ForceType application/json
+</Location>
diff --git a/templates/persona/provisioning.html b/templates/persona/provisioning.html
new file mode 100644 (file)
index 0000000..a693cac
--- /dev/null
@@ -0,0 +1,62 @@
+{% extends "master.html" %}
+{% block main %}
+<div class="col-sm-12">
+  <div id="welcome">
+      <p>This page is used internally</p>
+  </div>
+</div>
+
+<script type="text/javascript" src="https://login.persona.org/provisioning_api.js"></script>
+<script type="text/javascript">
+    var xmlhttp = new XMLHttpRequest()
+
+    var loggedin = {{ loggedin|lower }};
+
+    xmlhttp.onreadystatechange = function()
+    {
+        if(xmlhttp.readyState == 4)
+        {
+            if(xmlhttp.status == 200)
+            {
+                navigator.id.registerCertificate(xmlhttp.responseText);
+            }
+            else if((xmlhttp.status == 401) || (xmlhttp.status == 403))
+            {
+                navigator.id.raiseProvisioningFailure('Error in provisioning!');
+            }
+            else
+            {
+                alert("Response code: " + xmlhttp.status);
+                alert("Response text: " + xmlhttp.responseText);
+            }
+        }
+    }
+
+    function generateServerSide(email, publicKey, certDuration, callback)
+    {
+        xmlhttp.open("POST", "Sign/", true);
+        xmlhttp.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
+        xmlhttp.send("email=" + encodeURIComponent(email)
+                     + "&publicKey=" + encodeURIComponent(publicKey)
+                     + "&certDuration=" + encodeURIComponent(certDuration));
+    }
+
+    function startProvisioning()
+    {
+        navigator.id.beginProvisioning(function(email, certDuration)
+        {
+            if(loggedin)
+            {
+                navigator.id.genKeyPair(function(publicKey)
+                {
+                    generateServerSide(email, publicKey, certDuration);
+                });
+            } else {
+                navigator.id.raiseProvisioningFailure('user is not authenticated');
+            }
+        });
+    }
+
+    startProvisioning();
+</script>
+{% endblock %}
diff --git a/templates/persona/signin_result.html b/templates/persona/signin_result.html
new file mode 100644 (file)
index 0000000..cda130d
--- /dev/null
@@ -0,0 +1,22 @@
+{% extends "master.html" %}
+{% block main %}
+<div class="col-sm-12">
+  <div id="welcome">
+      <p>This page is used internally</p>
+  </div>
+</div>
+
+<script type="text/javascript" src="https://login.persona.org/authentication_api.js"></script>
+<script type="text/javascript">
+    var loggedin = {{ loggedin|lower }};
+
+    if(loggedin)
+    {
+        navigator.id.beginAuthentication(function(email) {
+            navigator.id.completeAuthentication();
+        });
+    } else {
+        navigator.id.raiseAuthenticationFailure('User cancelled signon');
+    }
+</script>
+{% endblock %}