#69 Add authopenid plugin to make Ipsilon a Relying Party
Closed 7 years ago by puiterwijk. Opened 8 years ago by puiterwijk.
puiterwijk/ipsilon authopenid  into  master

@@ -176,6 +176,18 @@ 

  Provides a login plugin to authenticate against the Fedora Authentication System

  

  

+ %package authopenid

+ Summary:        OpenID relying party login plugin

+ Group:          System Environment/Base

+ License:        GPLv3+

+ Requires:       %{name} = %{version}-%{release}

+ Requires:       %{name}-openid = %{version}-%{release}

+ BuildArch:      noarch

+ 

+ %description authopenid

+ Provides a login plugin to use Ipsilon as a Relying Party for external OpenID providers

+ 

+ 

  %package authform

  Summary:        mod_intercept_form_submit login plugin

  Group:          System Environment/Base
@@ -384,6 +396,10 @@ 

  %files authfas

  %{python2_sitelib}/ipsilon/login/authfas*

  

+ %files authopenid

+ %{python2_sitelib}/ipsilon/login/authopenid*

+ %{_datadir}/ipsilon/templates/login/openid.html

+ 

  %files authform

  %{python2_sitelib}/ipsilon/login/authform*

  

@@ -0,0 +1,253 @@ 

+ # Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING

+ 

+ from ipsilon.login.common import LoginFormBase, LoginManagerBase, \

+     LoginManagerInstaller

+ from ipsilon.util.plugin import PluginObject

+ from ipsilon.util.policy import Policy

+ from ipsilon.util import config as pconfig

+ from ipsilon.providers.openid.extensions.ax import AP_MAP

+ from ipsilon.providers.openid.store import OpenIDStore

+ import cherrypy

+ import logging

+ import pickle

+ 

+ from openid.consumer import consumer

+ from openid.extensions import pape, sreg, ax

+ 

+ # These are type URIs where we allow multiple results

+ multival_type_uris = ['http://fedoauth.org/openid/schema/SSH/key']

+ 

+ openid_mapping = [

+     ['fullname', 'fullname'],

+     ['email', 'email'],

+     ['lastname', 'surname'],

+     ['nickname', 'nickname'],

+     ['nickname', 'nickname'],

twice the nickname?

+ ]

+ 

+ 

+ class OpenID(LoginFormBase):

+ 

+     def __init__(self, site, mgr, page, forced_provider=None):

+         super(OpenID, self).__init__(site, mgr, page)

+         self.mapper = Policy(openid_mapping)

+         self.forced_provider = forced_provider

+ 

+     def root(self, *args, **kwargs):

+         self.trans = self.get_valid_transaction('login', **kwargs)

+         context = self.create_tmpl_context()

+         if self.forced_provider or cherrypy.request.method == 'POST':

+             if self.forced_provider:

+                 provider = self.forced_provider

+             else:

+                 provider = kwargs['login_name']

+ 

+             context['username'] = provider

+             openid_ses = {}

+             oidconsumer = consumer.Consumer(openid_ses, self.lm.datastore)

+             try:

+                 request = oidconsumer.begin(provider)

+             except consumer.DiscoveryFailure as exc:

+                 cherrypy.log.error('Failed discovery for %s: %s' %

+                                    (provider, exc),

+                                    severity=logging.WARN)

+                 return self._template('login/openid.html',

+                                       error_username=True,

+                                       error='Unable to discover OpenID',

+                                       forced_provider=self.forced_provider,

+                                       **context)

+ 

+             if request is None:

+                 cherrypy.log.error('No OpenID request generated')

+                 return self._template('login/openid.html',

+                                       error_username=True,

+                                       error='Unable to initiate OpenID',

+                                       forced_provider=self.forced_provider,

+                                       **context)

+ 

+             request.addExtension(sreg.SRegRequest(

+                 optional=['nickname', 'fullname', 'email', 'timezone']))

+             request.addExtension(pape.Request([]))

+ 

+             ax_req = ax.FetchRequest()

+             for prop in AP_MAP.keys():

+                 count = 1

+                 if prop in multival_type_uris:

+                     count = 'unlimited'

+                 ax_req.add(ax.AttrInfo(type_uri=prop, count=count))

+             request.addExtension(ax_req)

+ 

+             trust_root = cherrypy.url('/')

+             return_to = '%s/login/openid/response/?%s' % (

+                 cherrypy.url('/'), self.trans.get_GET_arg())

+ 

+             # Store the OpenID Session in the transaction

+             self.trans.store({'openid_ses': pickle.dumps(openid_ses)})

+ 

+             if request.shouldSendRedirect():

+                 redirect_url = request.redirectURL(trust_root, return_to,

+                                                    False)

+                 raise cherrypy.HTTPRedirect(redirect_url)

+             else:

+                 return request.htmlMarkup(

+                     trust_root, return_to,

+                     form_tag_attrs={'id': 'openid_mesage'}, immediate=False)

+ 

+         context['username'] = self.forced_provider or ''

+         return self._template('login/openid.html',

+                               forced_provider=self.forced_provider,

+                               **context)

+ 

+     def response(self, *args, **kwargs):

+         context = self.create_tmpl_context()

+ 

+         self.trans = self.get_valid_transaction('login', **kwargs)

+         openid_ses = pickle.loads(self.trans.retrieve().get('openid_ses'))

+         oidconsumer = consumer.Consumer(openid_ses, self.lm.datastore)

+         return_to = '%s/login/openid/response/?%s' % (

+             cherrypy.url('/'), self.trans.get_GET_arg())

+         # We need to make sure the transaction ID does not get passed on

+         info = oidconsumer.complete(kwargs, return_to)

+         display_identifier = info.getDisplayIdentifier()

+ 

+         if info.status == consumer.FAILURE and display_identifier:

+             cherrypy.log.error("OpenID auth failure: %s for %s" %

+                                (info.message, display_identifier),

+                                severity=logging.INFO)

+             return self._template('login/openid.html',

+                                   error='Unable to verify OpenID %s' %

+                                   display_identifier,

+                                   **context)

+         elif info.status == consumer.CANCEL:

+             cherrypy.log.error("OpenID auth cancelled", severity=logging.INFO)

+             return self._template('login/openid.html',

+                                   error='Login cancelled',

+                                   **context)

+         elif info.status == consumer.FAILURE:

+             cherrypy.log.error("OpenID auth failure: %s" % info.message,

+                                severity=logging.INFO)

+             return self._template('login/openid.html',

+                                   error='Unable to verify OpenID',

+                                   **context)

+         elif info.status == consumer.SUCCESS:

+             if self.forced_provider:

+                 if self.forced_provider != info.endpoint.server_url:

+                     cherrypy.log.error('Claim received for invalid provider:'

+                                        ' %s' % info.endpoint.server_url)

+                     return self._template('login/openid.html',

+                                           error='Invalid provider!',

+                                           error_username=True,

+                                           **context)

+ 

+             userdata = self.make_userdata(info)

+             return self.lm.auth_successful(self.trans,

+                                            info.getDisplayIdentifier(),

+                                            userdata=userdata)

+         else:

+             return 'Strange state: %s' % info.status

+     response.exposed = True

+ 

+     def make_userdata(self, openid_response):

+         sreg_resp = sreg.SRegResponse.fromSuccessResponse(openid_response)

+         ax_resp = ax.FetchResponse.fromSuccessResponse(openid_response)

+ 

+         user_info = {}

+ 

+         if sreg_resp:

+             user_info['username'] = sreg_resp.get('nickname')

+             user_info['fullname'] = sreg_resp.get('fullname')

+             user_info['email'] = sreg_resp.get('email')

+             user_info['timezone'] = sreg_resp.get('timezone')

+ 

+         if ax_resp:

+             for type_uri in AP_MAP:

+                 value = ax_resp.get(type_uri)

+                 if value:

+                     key = AP_MAP[type_uri]

+                     user_info[key] = value

+ 

+         userattrs, extras = self.mapper.map_attributes(user_info)

+         userattrs['_extras'] = {'openid': extras}

+ 

+         return userattrs

+ 

+ 

+ class LoginManager(LoginManagerBase):

+ 

+     def __init__(self, *args, **kwargs):

+         super(LoginManager, self).__init__(*args, **kwargs)

+         self.name = 'openid'

+         self.path = 'openid'

+         self.page = None

+         self.service_name = 'openid'

+         self.datastore = None

+         self.description = """

+ Login provider using OpenID

+ """

+         self.new_config(

+             self.name,

+             pconfig.String(

+                 'database url',

+                 'Database URL for OpenID temp storage',

+                 'openid.sqlite'),

+             pconfig.String(

+                 'username text',

+                 'Text used to ask for the identity URL at login time.',

+                 'OpenID Identity'),

+             pconfig.String(

+                 'help text',

+                 'Text used to guide the user at login time.',

+                 'Enter your OpenID identity')

+         )

+ 

+     def used_datastores(self):

+         return [self.datastore]

+ 

+     @property

+     def help_text(self):

+         return self.get_config_value('help text')

+ 

+     @property

+     def username_text(self):

+         return self.get_config_value('username text')

+ 

+     def get_tree(self, site):

+         self.page = OpenID(site, self, 'login/openid')

+         return self.page

+ 

+     def on_enable(self):

+         super(LoginManager, self).on_enable()

+         self.datastore = OpenIDStore(self.get_config_value('database url'))

+ 

+ 

+ class Installer(LoginManagerInstaller):

+ 

+     def __init__(self, *pargs):

+         super(Installer, self).__init__()

+         self.name = 'openid'

+         self.pargs = pargs

+ 

+     def install_args(self, group):

+         group.add_argument('--login-openid', choices=['yes', 'no'],

+                            default='no', help='Configure OpenID login')

+ 

+     def configure(self, opts, changes):

+         if opts['login_openid'] != 'yes':

+             return

+ 

+         # Add configuration data to database

+         po = PluginObject(*self.pargs)

+         po.name = 'openid'

+         po.wipe_data()

+         po.wipe_config_values()

+         # We reuse the same database used by the openid provider side of

+         # things. This will not cause any issues, and will result in less

+         # files on the disk.

+         config = {'database url': opts['openid_dburi'] or

+                   opts['database_url'] % {

+                       'datadir': opts['data_dir'], 'dbname': 'openid'}}

+         po.save_plugin_config(config)

+ 

+         # Update global config to add login plugin

+         po.is_enabled = True

+         po.save_enabled_state()

file modified
+3 -1
@@ -301,7 +301,6 @@ 

              "action": '%s/%s' % (self.basepath, self.formpage),

              "service_name": self.lm.service_name,

              "username_text": self.lm.username_text,

-             "password_text": self.lm.password_text,

              "description": self.lm.help_text,

              "other_stacks": other_stacks,

              "username": username,
@@ -309,6 +308,9 @@ 

              "cancel_url": '%s/login/cancel?%s' % (self.basepath,

                                                    self.trans.get_GET_arg()),

          }

+         if 'password_text' in dir(self.lm):

+             context["password_text"] = self.lm.password_text

+ 

          context.update(kwargs)

          if self.trans is not None:

              t = self.trans.get_POST_tuple()

@@ -23,16 +23,20 @@ 

          self.trans = None

  

      def _preop(self, *args, **kwargs):

-         try:

-             # generate a new id or get current one

-             self.trans = Transaction('openid', **kwargs)

-             if (self.trans.cookie and

-                     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')

+         # If we are doing openid check_auth, the other party does NOT need a

+         # tranaction, but they might send ipsilon_transaction_id if they are

+         # an Ipsilon instance using authopenid.

+         if kwargs.get('openid.mode') != 'check_authentication':

+             try:

+                 # generate a new id or get current one

+                 self.trans = Transaction('openid', **kwargs)

+                 if (self.trans.cookie and

+                         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)

@@ -94,6 +94,10 @@ 

                          trans=False)

          q.create()

          q._con.close()  # pylint: disable=protected-access

+         q = self._query(self._db, 'nonce', UNIQUE_DATA_TABLE,

+                         trans=False)

+         q.create()

+         q._con.close()  # pylint: disable=protected-access

  

      def _upgrade_schema(self, old_version):

          if old_version == 1:
@@ -110,5 +114,16 @@ 

              for index in table.indexes:

                  self._db.add_index(index)

              return 2

+         if old_version == 2:

+             # In schema version 3, we added the nonce table

+             q = self._query(self._db, 'nonce', UNIQUE_DATA_TABLE,

+                             trans=False)

+             q.create()

+             q._con.close()  # pylint: disable=protected-access

+             return 3

          else:

              raise NotImplementedError()

+ 

+     def _code_schema_version(self):

+         # In version 3, we added the nonce table.

+         return 3

@@ -0,0 +1,56 @@ 

+ {% extends "master.html" %}

+ {% block main %}

+ 

+ {% if error %}

+ <div class="col-sm-12">

+   <div class="alert alert-danger">

+     <p>{{ error }}</p>

+   </div>

+ </div>

+ 

+ {% endif %}

+ 

+ {% if login_target %}

+ <div class="col-md-10">

+   <h4>You are being asked to login by {{login_target}}</h4>

+   <hr>

+ </div>

+ {% endif %}

+ <div class="col-sm-7 col-md-6 col-lg-5 login">

+   <form class="form-horizontal" role="form" id="login_form" action="{{ action }}" method="post" enctype="application/x-www-form-urlencoded">

+     <input type="hidden" name="ipsilon_transaction_id" id="ipsilon_transaction_id" value="{{ ipsilon_transaction_id }}">

+     <div class="form-group {% if error_username %} has-error{% endif %}">

+       <label for="login_name" class="col-sm-2 col-md-2 control-label">{{ username_text }}</label>

+       <div class="col-sm-10 col-md-10">

+         <input type="text" class="form-control" name="login_name" id="login_name" placeholder="" tabindex="1" value="{{ username | e }}"

+             {% if forced_provider %}disabled{% endif %}>

+       </div>

+     </div>

+     <div class="form-group">

+       <div class="col-sm-offset-2 col-md-offset-2 col-xs-12 col-sm-10 col-md-10 submit">

+         {% if cancel_url %}

+           <a href="{{ cancel_url }}" title="Cancel" class="btn btn-link" tabindex="4">Cancel</a>

+         {% else %}

+           <a href="{{ basepath }}" title="Cancel" class="btn btn-link" tabindex="4">Cancel</a>

+         {% endif %}

+         <button type="submit" value="login" class="btn btn-primary btn-lg" tabindex="3">Log In</button>

+       </div>

+     </div>

+   </form>

+ </div>

+ 

+ <div class="col-sm-5 col-md-6 col-lg-7 details">

+   <p>{{description}}</p>

+ {% if other_stacks %}

+   <hr>

+   <p>Other authentication methods:

+   <ul>

+   {% for s in other_stacks %}

+     <li><a href="{{ s['url'] }}" class="btn btn-link" tabindex="5">{{ s['name'] }}</a></li>

+   {% endfor %}

+   </ul>

+   </p>

+ {% endif %}

+ </div>

+ 

+ {% endblock %}

file modified
+2 -1
@@ -145,7 +145,8 @@ 

  

          # replace known values

          payload['login_name'] = srv['user']

-         payload['login_password'] = srv['pwd']

+         if 'pwd' in srv:

+             payload['login_password'] = srv['pwd']

  

          return [method, self.new_url(referer, action_url),

                  {'headers': headers, 'data': payload}]

file modified
+52 -14
@@ -32,6 +32,18 @@ 

           'ipa': 'no',

           'server_debugging': 'True'}

  

+ idp_b = {'hostname': '${ADDRESS}:${PORT}',

+          'admin_user': '${TEST_USER}',

+          'system_user': '${TEST_USER}',

+          'instance': '${NAME}',

+          'secure': 'no',

+          'testauth': 'no',

+          'login_openid': 'yes',

+          'pam': 'no',

+          'gssapi': 'no',

+          'ipa': 'no',

+          'server_debugging': 'True'}

+ 

  

  def fixup_sp_httpd(httpdir, testdir):

      client_wsgi = """
@@ -60,20 +72,30 @@ 

          super(IpsilonTest, self).__init__('openid', __file__)

  

      def setup_servers(self, env=None):

-         print "Installing IDP server"

+         print "Installing IDP 1 server"

          name = 'idp1'

          addr = '127.0.0.10'

          port = '45080'

-         idp = self.generate_profile(idp_g, idp_a, name, addr, port)

-         conf = self.setup_idp_server(idp, name, addr, port, env)

+         idp1 = self.generate_profile(idp_g, idp_a, name, addr, port)

+         conf = self.setup_idp_server(idp1, name, addr, port, env)

  

-         print "Starting IDP's httpd server"

+         print "Starting IDP 1's httpd server"

          self.start_http_server(conf, env)

  

-         print "Installing first SP server"

-         name = 'sp1'

+         print "Installing IDP 2 server"

+         name = 'idp2'

          addr = '127.0.0.11'

          port = '45081'

+         idp2 = self.generate_profile(idp_g, idp_b, name, addr, port)

+         conf = self.setup_idp_server(idp2, name, addr, port, env)

+ 

+         print "Starting IDP 2's httpd server"

+         self.start_http_server(conf, env)

+ 

+         print "Installing first SP server"

+         name = 'sp1'

+         addr = '127.0.0.12'

+         port = '45082'

          conf = self.setup_http(name, addr, port)

          testdir = os.path.dirname(os.path.abspath(inspect.getfile(

              inspect.currentframe())))
@@ -85,17 +107,20 @@ 

  

  if __name__ == '__main__':

  

-     idpname = 'idp1'

+     idp1name = 'idp1'

+     idp2name = 'idp2'

      sp1name = 'sp1'

      user = pwd.getpwuid(os.getuid())[0]

  

      sess = HttpSessions()

-     sess.add_server(idpname, 'https://127.0.0.10:45080', user, 'ipsilon')

-     sess.add_server(sp1name, 'https://127.0.0.11:45081')

+     sess.add_server(idp1name, 'https://127.0.0.10:45080', user, 'ipsilon')

+     sess.add_server(idp2name, 'https://127.0.0.11:45081',

+                     'http://127.0.0.10:45080/idp1/openid/')

+     sess.add_server(sp1name, 'https://127.0.0.12:45082')

  

      print "openid: Authenticate to IDP ...",

      try:

-         sess.auth_to_idp(idpname)

+         sess.auth_to_idp(idp1name)

      except Exception as e:  # pylint: disable=broad-except

          print >> sys.stderr, " ERROR: %s" % repr(e)

          sys.exit(1)
@@ -103,8 +128,8 @@ 

  

      print "openid: Run OpenID Protocol ...",

      try:

-         page = sess.fetch_page(idpname,

-                                'https://127.0.0.11:45081/?extensions=NO')

+         page = sess.fetch_page(idp1name,

+                                'https://127.0.0.12:45082/?extensions=NO')

          page.expected_value('text()', 'SUCCESS, WITHOUT EXTENSIONS')

      except ValueError as e:

          print >> sys.stderr, " ERROR: %s" % repr(e)
@@ -113,10 +138,23 @@ 

  

      print "openid: Run OpenID Protocol with extensions ...",

      try:

-         page = sess.fetch_page(idpname,

-                                'https://127.0.0.11:45081/?extensions=YES')

+         page = sess.fetch_page(idp1name,

+                                'https://127.0.0.12:45082/?extensions=YES')

          page.expected_value('text()', 'SUCCESS, WITH EXTENSIONS')

      except ValueError as e:

          print >> sys.stderr, " ERROR: %s" % repr(e)

          sys.exit(1)

      print " SUCCESS"

+ 

+     print "openid: Run nested OpenID Protocol ...",

+     try:

+         page = sess.fetch_page(idp2name,

+                                'http://127.0.0.11:45081/idp2/login/')

+         page.expected_value('//div[@id="welcome"]/p/text()',

+                             'Welcome http://127.0.0.10:45080/idp1/'

+                             'openid/id/%s/!'

+                             % user)

+     except Exception as e:  # pylint: disable=broad-except

+         print >> sys.stderr, " ERROR: %s" % repr(e)

+         sys.exit(1)

+     print " SUCCESS"

no initial comment

By default we reuse the same database file as the openidp side of things.
This will not cause any issues, and is just to get as few databases as possible.

It looks fine to me, so :+1: here. But, it would probably benefit from someone more familiar with the protocol having a look at it.

Maybe you could provide an example of what could be an external OpenID providers?

rebased

7 years ago

(For the record: Simo had some suggestions that would require reworking this PR. I just rebased it for now, and will be looking at fixing those later).

I have not yet had time to fix the requests from Simo. I will look at fixing those sometime in the future, and reopen this when I do.

Pull-Request has been closed by puiterwijk

7 years ago