SP Portal administrative interface
authorRob Crittenden <rcritten@redhat.com>
Wed, 2 Sep 2015 20:51:32 +0000 (16:51 -0400)
committerPatrick Uiterwijk <puiterwijk@redhat.com>
Fri, 4 Sep 2015 02:50:27 +0000 (04:50 +0200)
Add database values for the SP: visible, image, SP link and
description.

Update REST interface to accept values for these attributes.

https://fedorahosted.org/ipsilon/ticket/148

Signed-off-by: Rob Crittenden <rcritten@redhat.com>
Reviewed-by: Patrick Uiterwijk <puiterwijk@redhat.com>
ipsilon/install/ipsilon-client-install
ipsilon/install/ipsilon-server-install
ipsilon/providers/saml2/admin.py
ipsilon/providers/saml2/provider.py
ipsilon/providers/saml2/rest.py
ipsilon/util/config.py
templates/admin/option_config.html
templates/admin/providers/saml2_sp_new.html

index 1d65b5f..d72d195 100755 (executable)
@@ -19,6 +19,7 @@ import requests
 import shutil
 import socket
 import sys
+import base64
 
 
 HTTPDCONFD = '/etc/httpd/conf.d'
@@ -73,6 +74,8 @@ def saml2():
     path = None
     if not args['saml_no_httpd']:
         path = os.path.join(SAML2_HTTPDIR, args['hostname'])
+        if os.path.exists(path):
+            raise Exception('Service Provider is already configured')
         os.makedirs(path, 0750)
     else:
         path = os.getcwd()
@@ -139,11 +142,23 @@ def saml2():
                          "Error: [%s]" % e)
             raise
 
+        sp_image = None
+        if args['saml_sp_image']:
+            try:
+                # FIXME: limit size
+                with open(args['saml_sp_image']) as f:
+                    sp_image = f.read()
+                sp_image = base64.b64encode(sp_image)
+            except Exception as e:  # pylint: disable=broad-except
+                logger.error("Failed to read SP Image file!\n" +
+                             "Error: [%s]" % e)
+
         # Register the SP
         try:
             saml2_register_sp(args['saml_idp_url'], args['admin_user'],
                               admin_password, args['saml_sp_name'],
-                              sp_metadata)
+                              sp_metadata, args['saml_sp_description'],
+                              args['saml_sp_visible'], sp_image)
         except Exception as e:  # pylint: disable=broad-except
             logger.error("Failed to register SP with IDP!\n" +
                          "Error: [%s]" % e)
@@ -209,7 +224,8 @@ def saml2():
                     ' configure your Service Provider')
 
 
-def saml2_register_sp(url, user, password, sp_name, sp_metadata):
+def saml2_register_sp(url, user, password, sp_name, sp_metadata,
+                      sp_description, sp_visible, sp_image):
     s = requests.Session()
 
     # Authenticate to the IdP
@@ -229,7 +245,15 @@ def saml2_register_sp(url, user, password, sp_name, sp_metadata):
     sp_url = '%s/rest/providers/saml2/SPS/%s' % (url.rstrip('/'), sp_name)
     sp_headers = {'Content-type': 'application/x-www-form-urlencoded',
                   'Referer': sp_url}
-    sp_data = urlencode({'metadata': sp_metadata})
+    sp_data = {'metadata': sp_metadata}
+    if sp_description:
+        sp_data['description'] = sp_description
+    if sp_visible:
+        sp_data['visible'] = sp_visible
+    if sp_image:
+        if sp_image:
+            sp_data['imagefile'] = sp_image
+    sp_data = urlencode(sp_data)
 
     r = s.post(sp_url, headers=sp_headers, data=sp_data)
     if r.status_code != 201:
@@ -243,14 +267,18 @@ def install():
 
 
 def saml2_uninstall():
-    try:
-        shutil.rmtree(os.path.join(SAML2_HTTPDIR, args['hostname']))
-    except Exception, e:  # pylint: disable=broad-except
-        log_exception(e)
-    try:
-        os.remove(SAML2_CONFFILE)
-    except Exception, e:  # pylint: disable=broad-except
-        log_exception(e)
+    path = os.path.join(SAML2_HTTPDIR, args['hostname'])
+    if os.path.exists(path):
+        try:
+            shutil.rmtree(path)
+        except Exception, e:  # pylint: disable=broad-except
+            log_exception(e)
+
+    if os.path.exists(SAML2_CONFFILE):
+        try:
+            os.remove(SAML2_CONFFILE)
+        except Exception, e:  # pylint: disable=broad-except
+            log_exception(e)
 
 
 def uninstall():
@@ -352,6 +380,14 @@ def parse_args():
                         help="SAML NameID format to use")
     parser.add_argument('--saml-sp-name', default=None,
                         help="The SP name to register with the IdP")
+    parser.add_argument('--saml-sp-description', default=None,
+                        help="The description of the SP to display on the " +
+                        "portal")
+    parser.add_argument('--saml-sp-visible', action='store_false',
+                        default=True,
+                        help="The SP is visible in the portal")
+    parser.add_argument('--saml-sp-image', default=None,
+                        help="Image to display for this SP on the portal")
     parser.add_argument('--debug', action='store_true', default=False,
                         help="Turn on script debugging")
     parser.add_argument('--config-profile', default=None,
index b4a9085..809d4c8 100755 (executable)
@@ -27,6 +27,7 @@ DATADIR = '/var/lib/ipsilon'
 HTTPDCONFD = '/etc/httpd/conf.d'
 BINDIR = '/usr/libexec'
 STATICDIR = '/usr/share/ipsilon'
+CACHEDIR = '/var/cache/httpd/ipsilon'
 WSGI_SOCKET_PREFIX = None
 
 
@@ -98,6 +99,7 @@ def install(plugins, args):
                 'sysuser': args['system_user'],
                 'ipsilondir': BINDIR,
                 'staticdir': STATICDIR,
+                'cachedir': CACHEDIR,
                 'admindb': args['admin_dburi'] or args['database_url'] % {
                     'datadir': args['data_dir'], 'dbname': 'adminconfig'},
                 'usersdb': args['users_dburi'] or args['database_url'] % {
index 811af9f..9d06be1 100644 (file)
@@ -14,6 +14,8 @@ from ipsilon.providers.saml2.provider import InvalidProviderId
 from copy import deepcopy
 import requests
 import logging
+import base64
+from urlparse import urlparse
 
 
 class NewSPAdminPage(AdminPage):
@@ -43,6 +45,10 @@ class NewSPAdminPage(AdminPage):
             #       set the owner in that case
             name = None
             meta = None
+            description = None
+            splink = None
+            visible = False
+            imagefile = None
             if 'content-type' not in cherrypy.request.headers:
                 self.debug("Invalid request, missing content-type")
                 message = "Malformed request"
@@ -55,6 +61,30 @@ class NewSPAdminPage(AdminPage):
             for key, value in kwargs.iteritems():
                 if key == 'name':
                     name = value
+                elif key == 'description':
+                    description = value
+                elif key == 'splink':
+                    # pylint: disable=unused-variable
+                    (scheme, netloc, path, params, query, frag) = urlparse(
+                        value
+                    )
+                    # minimum URL validation
+                    if (scheme not in ['http', 'https'] or not netloc):
+                        message = "Invalid URL for Service Provider link"
+                        message_type = ADMIN_STATUS_ERROR
+                        return self.form_new(message, message_type)
+                    splink = value
+                elif key == 'portalvisible' and value.lower() == 'on':
+                    visible = True
+                elif key == 'imagefile':
+                    if hasattr(value, 'content_type'):
+                        imagefile = value.fullvalue()
+                        if len(imagefile) == 0:
+                            imagefile = None
+                        else:
+                            imagefile = base64.b64encode(imagefile)
+                    else:
+                        self.debug("Invalid format for 'imagefile'")
                 elif key == 'metatext':
                     if len(value) > 0:
                         meta = value
@@ -78,7 +108,8 @@ class NewSPAdminPage(AdminPage):
             if name and meta:
                 try:
                     spc = ServiceProviderCreator(self.parent.cfg)
-                    sp = spc.create_from_buffer(name, meta)
+                    sp = spc.create_from_buffer(name, meta, description,
+                                                visible, imagefile, splink)
                     sp_page = self.parent.add_sp(name, sp)
                     message = "SP Successfully added"
                     message_type = ADMIN_STATUS_OK
@@ -194,7 +225,9 @@ class SPAdminPage(AdminPage):
                             raise UnauthorizedUser("Unauthorized to set owner")
                     elif key in ['User Owner', 'Default NameID',
                                  'Allowed NameIDs', 'Attribute Mapping',
-                                 'Allowed Attributes']:
+                                 'Allowed Attributes', 'Description',
+                                 'Service Provider link',
+                                 'Visible in Portal', 'Image File']:
                         if not self.user.is_admin:
                             raise UnauthorizedUser(
                                 "Unauthorized to set %s" % key
@@ -202,9 +235,12 @@ class SPAdminPage(AdminPage):
 
                 # Make changes in current config
                 for name, option in conf.iteritems():
+                    if name not in new_db_values:
+                        continue
                     value = new_db_values.get(name, False)
                     # A value of None means remove from the data store
-                    if value is False or value == []:
+                    if ((value is False or value == []) and
+                            name != 'Visible in Portal'):
                         continue
                     if name == 'Name':
                         if not self.sp.is_valid_name(value):
@@ -217,6 +253,12 @@ class SPAdminPage(AdminPage):
                         self.parent.rename_sp(option.get_value(), value)
                     elif name == 'User Owner':
                         self.sp.owner = value
+                    elif name == 'Description':
+                        self.sp.description = value
+                    elif name == 'Visible in Portal':
+                        self.sp.visible = value
+                    elif name == 'Service Provider link':
+                        self.sp.splink = value
                     elif name == 'Default NameID':
                         self.sp.default_nameid = value
                     elif name == 'Allowed NameIDs':
@@ -225,6 +267,17 @@ class SPAdminPage(AdminPage):
                         self.sp.attribute_mappings = value
                     elif name == 'Allowed Attributes':
                         self.sp.allowed_attributes = value
+                    elif name == 'Image File':
+                        if hasattr(value, 'content_type'):
+                            # pylint: disable=maybe-no-member
+                            blob = value.fullvalue()
+                            if len(blob) > 0:
+                                self.sp.imagefile = base64.b64encode(blob)
+                        else:
+                            raise InvalidValueFormat(
+                                'Invalid Image file format'
+                            )
+
             except InvalidValueFormat, e:
                 message = str(e)
                 message_type = ADMIN_STATUS_WARN
index b70582e..6cbf5ab 100644 (file)
@@ -68,6 +68,23 @@ class ServiceProvider(ServiceProviderConfig):
                 ' Only alphanumeric characters [A-Z,a-z,0-9] and spaces are'
                 '  accepted.',
                 self.name),
+            pconfig.String(
+                'Description',
+                'A description of the SP to show on the Portal.',
+                self.description),
+            pconfig.String(
+                'Service Provider link',
+                'A link to the Service Provider for the Portal.',
+                self.splink),
+            pconfig.Condition(
+                'Visible in Portal',
+                'This SP is visible in the Portal.',
+                self.visible),
+            pconfig.Image(
+                'Image File',
+                'Image to display for this SP in the Portal. Scale to '
+                '100x200 for best results.',
+                self.imagefile),
             pconfig.Pick(
                 'Default NameID',
                 'Default NameID used by Service Providers.',
@@ -106,6 +123,42 @@ class ServiceProvider(ServiceProviderConfig):
     def name(self, value):
         self._staging['name'] = value
 
+    @property
+    def description(self):
+        return self._properties.get('description', '')
+
+    @description.setter
+    def description(self, value):
+        self._staging['description'] = value
+
+    @property
+    def visible(self):
+        return self._properties.get('visible', True)
+
+    @visible.setter
+    def visible(self, value):
+        self._staging['visible'] = value
+
+    @property
+    def imagefile(self):
+        return self._properties.get('imagefile', '')
+
+    @imagefile.setter
+    def imagefile(self, value):
+        self._staging['imagefile'] = value
+
+    @property
+    def imageurl(self):
+        return pconfig.url_from_image(self._properties['imagefile'])
+
+    @property
+    def splink(self):
+        return self._properties.get('splink', '')
+
+    @splink.setter
+    def splink(self, value):
+        self._staging['splink'] = value
+
     @property
     def owner(self):
         if 'owner' in self._properties:
@@ -243,7 +296,8 @@ class ServiceProviderCreator(object):
     def __init__(self, config):
         self.cfg = config
 
-    def create_from_buffer(self, name, metabuf):
+    def create_from_buffer(self, name, metabuf, description='',
+                           visible=True, imagefile='', splink=''):
         '''Test and add data'''
 
         if re.search(VALID_IN_NAME, name):
@@ -260,7 +314,16 @@ class ServiceProviderCreator(object):
         data = self.cfg.get_data(name='id', value=spid)
         if len(data) != 0:
             raise InvalidProviderId("Provider Already Exists")
-        datum = {'id': spid, 'name': name, 'type': 'SP', 'metadata': metabuf}
+        datum = {
+            'id': spid,
+            'name': name,
+            'type': 'SP',
+            'metadata': metabuf,
+            'description': description,
+            'visible': visible,
+            'imagefile': imagefile,
+            'splink': splink,
+        }
         self.cfg.new_datum(datum)
 
         data = self.cfg.get_data(name='id', value=spid)
index c332bf9..7ef5576 100644 (file)
@@ -90,13 +90,19 @@ class SPS(RestProviderBase):
         if len(args) != 1:
             return rest_error(400, 'Invalid arguments. Found %d'
                                    ' there should be one.')
+        self.debug('REST POST %s' % kwargs)
         name = args[0]
         metadata = kwargs.get('metadata')
+        description = kwargs.get('description', '')
+        visible = kwargs.get('visible', True)
+        imagefile = kwargs.get('image', None)
+        splink = kwargs.get('splink', '')
 
         obj = self._site[FACILITY].available[self.parent.plugin_name]
         try:
             spc = ServiceProviderCreator(obj)
-            sp = spc.create_from_buffer(name, metadata)
+            sp = spc.create_from_buffer(name, metadata, description,
+                                        visible, imagefile, splink)
         except (InvalidProviderId, ServerAddProviderFailedError) as e:
             self.debug(repr(e))
             return rest_error(400, str(e))
index 18349a4..e426679 100644 (file)
@@ -1,7 +1,33 @@
 # Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING
 
 from ipsilon.util.log import Log
+import os
 import json
+import base64
+import imghdr
+import hashlib
+import cherrypy
+
+
+def name_from_image(image):
+    if image is None:
+        return None
+
+    fext = imghdr.what(None, base64.b64decode(image))
+    m = hashlib.sha1()
+    m.update(base64.b64decode(image))
+
+    return '%s.%s' % (m.hexdigest(), fext)
+
+
+def url_from_image(image):
+    if image is None:
+        return None
+
+    return '%s/cache/%s' % (
+        cherrypy.config.get('base.mount', ""),
+        name_from_image(image)
+    )
 
 
 class Config(Log):
@@ -136,6 +162,78 @@ class String(Option):
         self._str_import_value(value)
 
 
+class Image(Option):
+    """
+    An image has two components: the binary blob of the image itself and
+    the SHA1 sum of the image.
+
+    We only need the image blob when writing to the cache file or
+    updating the database.
+
+    For the purposes of the UI we only need the filename which is
+    the SHA1 sum of file type the blob + file type.
+    """
+
+    def __init__(self, name, description, default_value=None, readonly=False):
+        super(Image, self).__init__(name, description, readonly=readonly)
+        self._image = None
+
+        if default_value:
+            self._image = default_value
+
+        self._assigned_value = url_from_image(self._image)
+        self.__write_cache_file()
+
+    def set_value(self, value):
+        if value is None:
+            return None
+
+        if os.path.exists(self.__filename()):
+            try:
+                os.remove(self.__filename())
+            except IOError as e:
+                self.error('Error removing %s: %s' % (self.__filename(), e))
+
+        self._image = base64.b64encode(value)
+        self._assigned_value = url_from_image(value)
+
+    def export_value(self):
+        if self._image is None:
+            return None
+
+        self.__write_cache_file()
+        return base64.b64decode(self._image)
+
+    def import_value(self, value):
+        if value is None:
+            return None
+
+        if os.path.exists(self.__filename()):
+            try:
+                os.remove(self.__filename())
+            except IOError as e:
+                self.error('Error removing %s: %s' % (self.__filename(), e))
+        self._image = base64.b64encode(value)
+        self._assigned_value = url_from_image(self._image)
+        self.__write_cache_file()
+
+    def __filename(self):
+        if self._image is None:
+            return None
+
+        cdir = cherrypy.config.get('cache_dir', '/var/cache/ipsilon')
+
+        return '%s/%s' % (cdir, name_from_image(self._image))
+
+    def __write_cache_file(self):
+        if self._image is None:
+            return None
+
+        if not os.path.exists(self.__filename()):
+            with open(self.__filename(), 'w') as imagefile:
+                imagefile.write(base64.b64decode(self._image))
+
+
 class Template(Option):
 
     def __init__(self, name, description, default_template=None,
@@ -331,12 +429,18 @@ class Condition(Pick):
 
     def __init__(self, name, description, default_value=False,
                  readonly=False):
+        # The db stores 1/0. Convert the passed-in value if
+        # necessary
+        if default_value in [u'1', 'True', True]:
+            default_value = True
+        else:
+            default_value = False
         super(Condition, self).__init__(name, description,
                                         [True, False], default_value,
                                         readonly=readonly)
 
     def import_value(self, value):
-        self._assigned_value = value == 'True'
+        self._assigned_value = value
 
 
 class ConfigHelper(Log):
index 1f921f6..02babe6 100644 (file)
                 $(buttonRow).appendTo(ourTable)
             }
         );
+        $(function() {
+            $("#uploadFile").on("change", function()
+            {
+                var files = !!this.files ? this.files : [];
+                if (!files.length || !window.FileReader) return; // no file selected, or no FileReader support
+                if (/^image/.test( files[0].type)){ // only image file
+                    var reader = new FileReader(); // instance of the FileReader
+                    reader.readAsDataURL(files[0]); // read the local file
+                    reader.onloadend = function(){ // set image data as background of div
+                        $("#imagePreview").css("background-image", "url("+this.result+")");
+                    }
+                }
+            });
+        });
     </script>
 {% endblock %}
 {% block main %}
@@ -48,7 +64,7 @@
     <hr>
 
     <div id="options">
-        <form class="form-horizontal" role="form" id="{{ name }}" action="{{ action }}" method="post" enctype="application/x-www-form-urlencoded">
+        <form class="form-horizontal" role="form" id="{{ name }}" action="{{ action }}" method="post" enctype="multipart/form-data"">
         {% for k, v in config.iteritems() %}
             <div class="form-group">
               <label class="col-sm-2" for="{{ v.name }}">{{ v.name }}:</label>
                     disabled
                   {%- endif -%}
                 >
+              {% elif v.__class__.__name__ == 'Image' -%}
+                <!-- FIXME: This is limited to a single instance of Image -->
+                {%- if value %}
+                <img src="{{ value }}"
+                   height="100" width="200"
+                >
+                {%- endif -%}
+                <p></p>
+                <input type="file" name="{{ v.name }}"
+                     title="{{ v.name }}"
+                     accept=".png,.jpg"
+                     id="uploadFile"
+                     style="display: none;" />
+                <input type="button" value="Select Image..." onclick="document.getElementById('uploadFile').click();" />
+                <p></p>
+                <div id="imagePreview"></div>
               {% elif v.__class__.__name__ == 'List' -%}
                 <textarea class="form-control" name="{{ v.name }}"
                   {% if v.is_readonly() -%}
index ed8db1d..1c95355 100644 (file)
                           Only alphanumeric characters and spaces are accepted"/>
         </div>
 
+        <div class="form-group">
+            <label for="name">Description:</label>
+            <input type="text" class="form-control" name="description" value=""
+                   title="A description of the services this Service Provider provides"/>
+        </div>
+
+        <div class="form-group">
+            <label for="name">Visible in IdP Portal: </label>
+            <input type="checkbox" name="portalvisible" checked
+                   title="Show this Service Provider in the IdP Portal"/>
+        </div>
+
+        <div class="form-group">
+            <label for="image">Portal image:</label>
+            <input type="file" name="imagefile" id="image"
+                   title="Image to display for this Service Provider in the IdP Portal. Scale to 100x200 for best results."
+                   accept=".png,.jpg"
+            />
+        </div>
+
+        <div class="form-group">
+            <label for="splink">Link to Service Provider:</label>
+            <input type="text" class="form-control" name="splink" value=""
+                   title="Link to the Service Provider"
+            />
+        </div>
+
         <div class="form-group">
             <label for="metafile">Metadata file:</label>
             <input type="file" name="metafile" id="file"