From a7c8333f98f4030e02d434fa78e8fc79c0340939 Mon Sep 17 00:00:00 2001 From: Rob Crittenden Date: Wed, 2 Sep 2015 16:51:32 -0400 Subject: [PATCH] SP Portal administrative interface 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 Reviewed-by: Patrick Uiterwijk --- ipsilon/install/ipsilon-client-install | 58 +++++++++-- ipsilon/install/ipsilon-server-install | 2 + ipsilon/providers/saml2/admin.py | 59 ++++++++++- ipsilon/providers/saml2/provider.py | 67 ++++++++++++- ipsilon/providers/saml2/rest.py | 8 +- ipsilon/util/config.py | 106 +++++++++++++++++++- templates/admin/option_config.html | 34 ++++++- templates/admin/providers/saml2_sp_new.html | 27 +++++ 8 files changed, 342 insertions(+), 19 deletions(-) diff --git a/ipsilon/install/ipsilon-client-install b/ipsilon/install/ipsilon-client-install index 1d65b5f..d72d195 100755 --- a/ipsilon/install/ipsilon-client-install +++ b/ipsilon/install/ipsilon-client-install @@ -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, diff --git a/ipsilon/install/ipsilon-server-install b/ipsilon/install/ipsilon-server-install index b4a9085..809d4c8 100755 --- a/ipsilon/install/ipsilon-server-install +++ b/ipsilon/install/ipsilon-server-install @@ -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'] % { diff --git a/ipsilon/providers/saml2/admin.py b/ipsilon/providers/saml2/admin.py index 811af9f..9d06be1 100644 --- a/ipsilon/providers/saml2/admin.py +++ b/ipsilon/providers/saml2/admin.py @@ -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 diff --git a/ipsilon/providers/saml2/provider.py b/ipsilon/providers/saml2/provider.py index b70582e..6cbf5ab 100644 --- a/ipsilon/providers/saml2/provider.py +++ b/ipsilon/providers/saml2/provider.py @@ -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) diff --git a/ipsilon/providers/saml2/rest.py b/ipsilon/providers/saml2/rest.py index c332bf9..7ef5576 100644 --- a/ipsilon/providers/saml2/rest.py +++ b/ipsilon/providers/saml2/rest.py @@ -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)) diff --git a/ipsilon/util/config.py b/ipsilon/util/config.py index 18349a4..e426679 100644 --- a/ipsilon/util/config.py +++ b/ipsilon/util/config.py @@ -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): diff --git a/templates/admin/option_config.html b/templates/admin/option_config.html index 1f921f6..02babe6 100644 --- a/templates/admin/option_config.html +++ b/templates/admin/option_config.html @@ -28,6 +28,22 @@ $(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+")"); + } + } + }); + }); {% endblock %} {% block main %} @@ -48,7 +64,7 @@
-
+ {% for k, v in config.iteritems() %}
@@ -63,6 +79,22 @@ disabled {%- endif -%} > + {% elif v.__class__.__name__ == 'Image' -%} + + {%- if value %} + + {%- endif -%} +

+ + +

+
{% elif v.__class__.__name__ == 'List' -%}