From a300d512843999241d122ce36f4d076844b8f2c5 Mon Sep 17 00:00:00 2001 From: Carsten Clasohm Date: Tue, 25 Jun 2013 09:42:16 -0700 Subject: initial commit --- README | 299 ++++++++++++++++++ apachekerbauth/apachekerbauth.spec | 50 +++ .../etc/httpd/conf.d/swift-auth.conf | 9 + .../apachekerbauth/var/www/cgi-bin/memcached.py | 318 +++++++++++++++++++ .../apachekerbauth/var/www/cgi-bin/swift-auth | 103 ++++++ apachekerbauth/build.sh | 7 + swiftkerbauth/build.sh | 7 + swiftkerbauth/swiftkerbauth.spec | 54 ++++ swiftkerbauth/swiftkerbauth/swiftkerbauth.py | 346 +++++++++++++++++++++ 9 files changed, 1193 insertions(+) create mode 100644 README create mode 100644 apachekerbauth/apachekerbauth.spec create mode 100644 apachekerbauth/apachekerbauth/etc/httpd/conf.d/swift-auth.conf create mode 100644 apachekerbauth/apachekerbauth/var/www/cgi-bin/memcached.py create mode 100755 apachekerbauth/apachekerbauth/var/www/cgi-bin/swift-auth create mode 100755 apachekerbauth/build.sh create mode 100755 swiftkerbauth/build.sh create mode 100644 swiftkerbauth/swiftkerbauth.spec create mode 100644 swiftkerbauth/swiftkerbauth/swiftkerbauth.py diff --git a/README b/README new file mode 100644 index 0000000..34b4ea7 --- /dev/null +++ b/README @@ -0,0 +1,299 @@ +Kerberos Authentication Filter for Red Hat Storage and OpenStack Swift +---------------------------------------------------------------------- + +Red Hat Storage not only provides file system access to its data, but +also object-level access. The latter is implemented with OpenStack +Swift, and allows containers and objects to be stored and retrieved +with an HTTP-based API. + +Red Hat Storage 2.0 comes with a simple authentication filter that +defines user accounts as a static list in the Swift configuration +file. For this project, we implemented a new authentication filter +that uses Kerberos tickets for single sign on authentication, and +grants administrator permissions based on the user's group membership +in a directory service like Red Hat Enterprise Linux Identity +Management or Microsoft Active Directory. + +* Building + +To build the swiftkerbauth and apachekerbauth RPM packages, change +into the respective directory and run + + ./build.sh + +* Installation + +** Swift Server + +Install the swiftkerbauth RPM on all Red Hat Storage nodes that will +provide object-level access via Swift. + +To active the Kerberos authentication filter, add "kerbauth" in the +/etc/swift/proxy-server.conf pipeline parameter: + + [pipeline:main] + pipeline = healthcheck cache kerbauth proxy-server + +Set the URL of the Apache server that will be used for authentication +with the ext_authentication_url parameter in the same file: + + [filter:kerbauth] + paste.filter_factory = swiftkerbauth:filter_factory + ext_authentication_url = http://AUTHENTICATION_SERVER/cgi-bin/swift-auth + +If the Swift server is not one of your Gluster nodes, edit +/etc/swift/fs.conf and change the following lines in the DEFAULT +section: + + mount_ip = RHS_NODE_HOSTNAME + remote_cluster = yes + +Activate the changes by running + + swift-init main restart + +For troubleshooting, check /var/log/messages. + +** Authentication Server + +On the authentication server, install the apachekerbauth package. + +Edit /etc/httpd/conf.d/swift-auth.conf and set the KrbAuthRealms and +Krb5KeyTab parameters. + +The keytab must contain a HTTP/$HOSTNAME principal. Usually, you will +have to create the Kerberos principal on the KDC, export it, and copy +it to a keytab file on the Apache server. + +If SELinux is enabled, allow Apache to connect to memcache and +activate the changes by running + + setsebool -P httpd_can_network_connect 1 + setsebool -P httpd_can_network_memcache 1 + + service httpd reload + +For troubleshooting, see /var/log/httpd/error_log. + +* Testing + +The tests were done with curl on a machine set up as an IDM client, +using the Gluster volume rhs_ufo1. + +In IDM, we created the following user groups: + +- auth_reseller_admin + Users in this group get full access to all Swift accounts. + +- auth_rhs_ufo1 + Users in this group get full access to the rhs_ufo1 Swift account. + +Next, we created the following users in IDM: + +- auth_admin + Member of the auth_reseller_admin group + +- rhs_ufo1_admin + Member of the auth_rhs_ufo1 group + +- jsmith + No relevant group membership + +The authentication tokens were then retrieved with the following +commands: + + kinit auth_admin + curl -v -u : --negotiate --location-trusted \ + http://rhs1.example.com:8080/auth/v1.0 + + kinit rhs_ufo1_admin + curl -v -u : --negotiate --location-trusted \ + http://rhs1.example.com:8080/auth/v1.0 + + kinit jsmith + curl -v -u : --negotiate --location-trusted \ + http://rhs1.example.com:8080/auth/v1.0 + +Each of these commands should output the following two lines: + +< X-Auth-Token: AUTH_tk4097146ed3814e026209556eeb121fe0 +... +
1365195860 / auth_admin,auth_reseller_admin
+ +The first line contains the authentication token that is used in +subsequent requests. + +The second line is printed by the swift-auth CGI script for debugging +- it lists the token expiration (in seconds since January 1, 1970) and +the user's groups. + +Next, we try to get information about the Swift account, replacing the +AUTH_tk* with one of the tokens we got with the commands above. This +should display statistics, and the list of container names when used +with the the admin users. For jsmith, you should get a 403 Forbidden +error. + + curl -v -X GET \ + -H 'X-Auth-Token: AUTH_tk4097146ed3814e026209556eeb121fe0' \ + http://rhs1.example.com:8080/v1/AUTH_rhs_ufo1 + +With one of the admin accounts, create a new container and a new +object in that container: + + curl -v -X PUT \ + -H 'X-Auth-Token: AUTH_tk4097146ed3814e026209556eeb121fe0' \ + http://rhs1.example.com:8080/v1/AUTH_rhs_ufo1/pictures + + curl -v -X PUT \ + -H 'X-Auth-Token: AUTH_tk4097146ed3814e026209556eeb121fe0' \ + -H 'Content-Length: 0' \ + http://rhs1.example.com:8080/v1/AUTH_rhs_ufo1/pictures/pic1.png + +Grant permission for jsmith to list and download objects from the +pictures container: + + curl -v -X POST \ + -H 'X-Auth-Token: AUTH_tkdbf7725c1e4ad1ebe9ab0d7098d425f2' \ + -H 'X-Container-Read: jsmith' \ + http://rhs1.example.com:8080/v1/AUTH_rhs_ufo1/pictures + +List the container contents using the authentication token for jsmith: + + curl -v -X GET \ + -H 'X-Auth-Token: AUTH_tkef8b417ac0c2a73a80ab3b8db85254e2' \ + http://rhs1.example.com:8080/v1/AUTH_rhs_ufo1/pictures + +Try to access a resource without an authentication token. This will +return a 303 redirect: + + curl -v -X GET \ + http://rhs1.example.com:8080/v1/AUTH_rhs_ufo1/pictures/pic1.png + +For curl to follow the redirect, you need to specify additional +options. With these, and with a current Kerberos ticket, you should +get the Kerberos user's cached authentication token, or a new one if +the previous token has expired. + + curl -v -u : --negotiate --location-trusted -X GET \ + http://rhs1.example.com:8080/v1/AUTH_rhs_ufo1/pictures/pic1.png + +* Implementation Details + +** Architecture + +The Swift API is HTTP-based. As described in the Swift documentation +[1], clients first make a request to an authentication URL, providing +a username and password. The reply contains a token which is used in +all subsequent requests. + +Swift has a chain of filters through which all client requests go. The +filters to use are configured with the pipeline parameter in +/etc/swift/proxy-server.conf: + + [pipeline:main] + pipeline = healthcheck cache tempauth proxy-server + +For the single sign authentication, we added a new filter called +"kerbauth" and put it into the filter pipeline in place of tempauth. + +The filter checks the URL for each client request. If it matches the +authentication URL, the client is redirected to a URL on a different +server. The URL is handled by a CGI script, which is set up to +authenticate the client with Kerberos negotiation, retrieve the user's +system groups [2], store them in a memcache ring shared with the Swift +server, and return the authentication token to the client. + +When the client provides the token as part of a resource request, the +kerbauth filter checks it against its memcache, grants administrator +rights based on the group membership retrieved from memcache, and +either grants or denies the resource access. + +[1] http://docs.openstack.org/api/openstack-object-storage/1.0/content/authentication-object-dev-guide.html + +[2] The user data and system groups are usually provided by Red Hat + Enterprise Linux identity Management or Microsoft Active + Directory. The script relies on the system configuration to be set + accordingly (/etc/nsswitch.conf). + +** swiftkerbauth.py + +The script /usr/lib/python2.6/site-packages/swiftkerbauth.py began as +a copy of the tempauth.py script from +/usr/lib/python2.6/site-packages/swift/common/middleware. It contains +the following modifications, among others: + +In the __init__ method, we read the ext_authentication_url parameter +from /etc/swift/proxy-server.conf. This is the URL that clients are +redirected to when they access either the Swift authentication URL, or +when they request a resource without a valid authentication token. + +The configuration in proxy-server.conf looks like this: + + [filter:kerbauth] + paste.filter_factory = swiftkerbauth:filter_factory + ext_authentication_url = http://rhel6-4.localdomain/cgi-bin/swift-auth + +The authorize method was changed so that global administrator rights +are granted if the user is a member of the auth_reseller_admin +group. Administrator rights for a specific account like vol1 are +granted if the user is a member of the auth_vol1 group. [3] + +The denied_response method was changed to return a HTTP redirect to +the external authentication URL if no valid token was provided by the +client. + +Most of the handle_get_token method was moved to the external +authentication script. This method now returns a HTTP redirect. + +In the __call__ and get_groups method, we removed support for the +HTTP_AUTHORIZATION header, which is only needed when Amazon S3 is +used. + +Like tempauth.py, swiftkerbauth.py uses a Swift wrapper to access +memcache. This wrapper converts the key to an MD5 hash and uses the +hash value to determine on which of a pre-defined list of servers to +store the data. + +[3] "auth" is the default reseller prefix, and would be different if + the reseller_prefix parameter in proxy-server.conf was set. + +** swift-auth CGI Script + +swift-auth resides on an Apache server and assumes that Apache is +configured to authenticate the user before this script is +executed. The script retrieves the username from the REMOTE_USER +environment variable, and checks if there already is a token for this +user in the memcache ring. If not, it generates a new one, retrieves +the user's system groups with "id -Gn USERNAME", stores this +information in the memcache ring, and returns the token to the client. + +For the Swift filter to be able to find the information, it was +important to use the Swift memcached module. Because we don't want to +require a full Swift installation on the authentication server, +/usr/lib/python2.6/site-packages/swift/common/memcached.py from the +Swift server was copied to /var/www/cgi-bin on the Apache server. + +To allow the CGI script to connect to memcache, the SELinux booleans +httpd_can_network_connect and httpd_can_network_memcache had to be +set. + +The tempauth filter uses the uuid module to generate token +strings. This module creates and runs temporary files, which leads to +AVC denial messages in /var/log/audit/audit.log when used from an +Apache CGI script. While the module still works, the audit log would +grow quickly. Instead of writing an SELinux policy module to allow or +to silently ignore these accesses, the swift-auth script uses the +"random" module for generating token strings. + +Red Hat Enterprise Linux 6 comes with Python 2.6 which only provides +method to list the locally defined user groups. To include groups from +Red Hat Enterprise Linux Identity Management and in the future from +Active Directory, the "id" command is run in a subprocess. + +* Reference Material + +Red Hat Storage Administration Guide: +https://access.redhat.com/knowledge/docs/Red_Hat_Storage/ + +Swift Documentation: +http://docs.openstack.org/developer/swift/ diff --git a/apachekerbauth/apachekerbauth.spec b/apachekerbauth/apachekerbauth.spec new file mode 100644 index 0000000..cc6210a --- /dev/null +++ b/apachekerbauth/apachekerbauth.spec @@ -0,0 +1,50 @@ +Name: apachekerbauth +Version: 1.0 +Release: 3 +Summary: Kerberos authentication filter for Swift + +Group: System Environment/Base +License: GPL +Source: %{name}.tar.gz +BuildRoot: %{_tmppath}/%{name}-root + +Requires: httpd >= 2.2.15 +Requires: mod_auth_kerb >= 5.4 + +%description +Python CGI script which is used by the swiftkerbauth package to +authenticate client requests using Kerberos. + +%prep +%setup -q -n %{name} + +%build + +%install +rm -rf $RPM_BUILD_ROOT + +mkdir -p \ + $RPM_BUILD_ROOT/etc/httpd/conf.d \ + $RPM_BUILD_ROOT/var/www/cgi-bin + +install -m 644 etc/httpd/conf.d/* \ + $RPM_BUILD_ROOT/etc/httpd/conf.d + +install -m 644 var/www/cgi-bin/memcached.py \ + $RPM_BUILD_ROOT/var/www/cgi-bin + +install var/www/cgi-bin/swift-auth \ + $RPM_BUILD_ROOT/var/www/cgi-bin + +%clean +rm -rf $RPM_BUILD_ROOT + +%files +%defattr(-,root,root,-) +%config /etc/httpd/conf.d/swift-auth.conf +/var/www/cgi-bin/memcached.py +/var/www/cgi-bin/swift-auth + +%changelog +* Fri Apr 5 2013 Carsten Clasohm - 1.0-1 +- initial build diff --git a/apachekerbauth/apachekerbauth/etc/httpd/conf.d/swift-auth.conf b/apachekerbauth/apachekerbauth/etc/httpd/conf.d/swift-auth.conf new file mode 100644 index 0000000..ba2b249 --- /dev/null +++ b/apachekerbauth/apachekerbauth/etc/httpd/conf.d/swift-auth.conf @@ -0,0 +1,9 @@ + + AuthType Kerberos + AuthName "Swift Authentication" + KrbMethodNegotiate On + KrbMethodK5Passwd On + KrbAuthRealms EXAMPLE.COM + Krb5KeyTab /etc/httpd/conf/apache.keytab + require valid-user + diff --git a/apachekerbauth/apachekerbauth/var/www/cgi-bin/memcached.py b/apachekerbauth/apachekerbauth/var/www/cgi-bin/memcached.py new file mode 100644 index 0000000..ecd9332 --- /dev/null +++ b/apachekerbauth/apachekerbauth/var/www/cgi-bin/memcached.py @@ -0,0 +1,318 @@ +# Copyright (c) 2010-2012 OpenStack, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Lucid comes with memcached: v1.4.2. Protocol documentation for that +version is at: + +http://github.com/memcached/memcached/blob/1.4.2/doc/protocol.txt +""" + +import cPickle as pickle +import logging +import socket +import time +from bisect import bisect +from hashlib import md5 + +DEFAULT_MEMCACHED_PORT = 11211 + +CONN_TIMEOUT = 0.3 +IO_TIMEOUT = 2.0 +PICKLE_FLAG = 1 +NODE_WEIGHT = 50 +PICKLE_PROTOCOL = 2 +TRY_COUNT = 3 + +# if ERROR_LIMIT_COUNT errors occur in ERROR_LIMIT_TIME seconds, the server +# will be considered failed for ERROR_LIMIT_DURATION seconds. +ERROR_LIMIT_COUNT = 10 +ERROR_LIMIT_TIME = 60 +ERROR_LIMIT_DURATION = 60 + + +def md5hash(key): + return md5(key).hexdigest() + + +class MemcacheConnectionError(Exception): + pass + + +class MemcacheRing(object): + """ + Simple, consistent-hashed memcache client. + """ + + def __init__(self, servers, connect_timeout=CONN_TIMEOUT, + io_timeout=IO_TIMEOUT, tries=TRY_COUNT): + self._ring = {} + self._errors = dict(((serv, []) for serv in servers)) + self._error_limited = dict(((serv, 0) for serv in servers)) + for server in sorted(servers): + for i in xrange(NODE_WEIGHT): + self._ring[md5hash('%s-%s' % (server, i))] = server + self._tries = tries if tries <= len(servers) else len(servers) + self._sorted = sorted(self._ring.keys()) + self._client_cache = dict(((server, []) for server in servers)) + self._connect_timeout = connect_timeout + self._io_timeout = io_timeout + + def _exception_occurred(self, server, e, action='talking'): + if isinstance(e, socket.timeout): + logging.error(_("Timeout %(action)s to memcached: %(server)s"), + {'action': action, 'server': server}) + else: + logging.exception(_("Error %(action)s to memcached: %(server)s"), + {'action': action, 'server': server}) + now = time.time() + self._errors[server].append(time.time()) + if len(self._errors[server]) > ERROR_LIMIT_COUNT: + self._errors[server] = [err for err in self._errors[server] + if err > now - ERROR_LIMIT_TIME] + if len(self._errors[server]) > ERROR_LIMIT_COUNT: + self._error_limited[server] = now + ERROR_LIMIT_DURATION + logging.error(_('Error limiting server %s'), server) + + def _get_conns(self, key): + """ + Retrieves a server conn from the pool, or connects a new one. + Chooses the server based on a consistent hash of "key". + """ + pos = bisect(self._sorted, key) + served = [] + while len(served) < self._tries: + pos = (pos + 1) % len(self._sorted) + server = self._ring[self._sorted[pos]] + if server in served: + continue + served.append(server) + if self._error_limited[server] > time.time(): + continue + try: + fp, sock = self._client_cache[server].pop() + yield server, fp, sock + except IndexError: + try: + if ':' in server: + host, port = server.split(':') + else: + host = server + port = DEFAULT_MEMCACHED_PORT + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + sock.settimeout(self._connect_timeout) + sock.connect((host, int(port))) + sock.settimeout(self._io_timeout) + yield server, sock.makefile(), sock + except Exception, e: + self._exception_occurred(server, e, 'connecting') + + def _return_conn(self, server, fp, sock): + """ Returns a server connection to the pool """ + self._client_cache[server].append((fp, sock)) + + def set(self, key, value, serialize=True, timeout=0): + """ + Set a key/value pair in memcache + + :param key: key + :param value: value + :param serialize: if True, value is pickled before sending to memcache + :param timeout: ttl in memcache + """ + key = md5hash(key) + if timeout > 0: + timeout += time.time() + flags = 0 + if serialize: + value = pickle.dumps(value, PICKLE_PROTOCOL) + flags |= PICKLE_FLAG + for (server, fp, sock) in self._get_conns(key): + try: + sock.sendall('set %s %d %d %s noreply\r\n%s\r\n' % \ + (key, flags, timeout, len(value), value)) + self._return_conn(server, fp, sock) + return + except Exception, e: + self._exception_occurred(server, e) + + def get(self, key): + """ + Gets the object specified by key. It will also unpickle the object + before returning if it is pickled in memcache. + + :param key: key + :returns: value of the key in memcache + """ + key = md5hash(key) + value = None + for (server, fp, sock) in self._get_conns(key): + try: + sock.sendall('get %s\r\n' % key) + line = fp.readline().strip().split() + while line[0].upper() != 'END': + if line[0].upper() == 'VALUE' and line[1] == key: + size = int(line[3]) + value = fp.read(size) + if int(line[2]) & PICKLE_FLAG: + value = pickle.loads(value) + fp.readline() + line = fp.readline().strip().split() + self._return_conn(server, fp, sock) + return value + except Exception, e: + self._exception_occurred(server, e) + + def incr(self, key, delta=1, timeout=0): + """ + Increments a key which has a numeric value by delta. + If the key can't be found, it's added as delta or 0 if delta < 0. + If passed a negative number, will use memcached's decr. Returns + the int stored in memcached + Note: The data memcached stores as the result of incr/decr is + an unsigned int. decr's that result in a number below 0 are + stored as 0. + + :param key: key + :param delta: amount to add to the value of key (or set as the value + if the key is not found) will be cast to an int + :param timeout: ttl in memcache + :raises MemcacheConnectionError: + """ + key = md5hash(key) + command = 'incr' + if delta < 0: + command = 'decr' + delta = str(abs(int(delta))) + for (server, fp, sock) in self._get_conns(key): + try: + sock.sendall('%s %s %s\r\n' % (command, key, delta)) + line = fp.readline().strip().split() + if line[0].upper() == 'NOT_FOUND': + add_val = delta + if command == 'decr': + add_val = '0' + sock.sendall('add %s %d %d %s\r\n%s\r\n' % \ + (key, 0, timeout, len(add_val), add_val)) + line = fp.readline().strip().split() + if line[0].upper() == 'NOT_STORED': + sock.sendall('%s %s %s\r\n' % (command, key, delta)) + line = fp.readline().strip().split() + ret = int(line[0].strip()) + else: + ret = int(add_val) + else: + ret = int(line[0].strip()) + self._return_conn(server, fp, sock) + return ret + except Exception, e: + self._exception_occurred(server, e) + raise MemcacheConnectionError("No Memcached connections succeeded.") + + def decr(self, key, delta=1, timeout=0): + """ + Decrements a key which has a numeric value by delta. Calls incr with + -delta. + + :param key: key + :param delta: amount to subtract to the value of key (or set the + value to 0 if the key is not found) will be cast to + an int + :param timeout: ttl in memcache + :raises MemcacheConnectionError: + """ + self.incr(key, delta=-delta, timeout=timeout) + + def delete(self, key): + """ + Deletes a key/value pair from memcache. + + :param key: key to be deleted + """ + key = md5hash(key) + for (server, fp, sock) in self._get_conns(key): + try: + sock.sendall('delete %s noreply\r\n' % key) + self._return_conn(server, fp, sock) + return + except Exception, e: + self._exception_occurred(server, e) + + def set_multi(self, mapping, server_key, serialize=True, timeout=0): + """ + Sets multiple key/value pairs in memcache. + + :param mapping: dictonary of keys and values to be set in memcache + :param servery_key: key to use in determining which server in the ring + is used + :param serialize: if True, value is pickled before sending to memcache + :param timeout: ttl for memcache + """ + server_key = md5hash(server_key) + if timeout > 0: + timeout += time.time() + msg = '' + for key, value in mapping.iteritems(): + key = md5hash(key) + flags = 0 + if serialize: + value = pickle.dumps(value, PICKLE_PROTOCOL) + flags |= PICKLE_FLAG + msg += ('set %s %d %d %s noreply\r\n%s\r\n' % + (key, flags, timeout, len(value), value)) + for (server, fp, sock) in self._get_conns(server_key): + try: + sock.sendall(msg) + self._return_conn(server, fp, sock) + return + except Exception, e: + self._exception_occurred(server, e) + + def get_multi(self, keys, server_key): + """ + Gets multiple values from memcache for the given keys. + + :param keys: keys for values to be retrieved from memcache + :param servery_key: key to use in determining which server in the ring + is used + :returns: list of values + """ + server_key = md5hash(server_key) + keys = [md5hash(key) for key in keys] + for (server, fp, sock) in self._get_conns(server_key): + try: + sock.sendall('get %s\r\n' % ' '.join(keys)) + line = fp.readline().strip().split() + responses = {} + while line[0].upper() != 'END': + if line[0].upper() == 'VALUE': + size = int(line[3]) + value = fp.read(size) + if int(line[2]) & PICKLE_FLAG: + value = pickle.loads(value) + responses[line[1]] = value + fp.readline() + line = fp.readline().strip().split() + values = [] + for key in keys: + if key in responses: + values.append(responses[key]) + else: + values.append(None) + self._return_conn(server, fp, sock) + return values + except Exception, e: + self._exception_occurred(server, e) diff --git a/apachekerbauth/apachekerbauth/var/www/cgi-bin/swift-auth b/apachekerbauth/apachekerbauth/var/www/cgi-bin/swift-auth new file mode 100755 index 0000000..d04ebb2 --- /dev/null +++ b/apachekerbauth/apachekerbauth/var/www/cgi-bin/swift-auth @@ -0,0 +1,103 @@ +#!/usr/bin/python + +# Requires the python-memcached package to be installed. +# +# Requires the following command to be run: +# setsebool -P httpd_can_network_connect 1 +# setsebool -P httpd_can_network_memcache 1 + +import cgi +import json +from memcached import MemcacheRing +import os +import random +import re +import subprocess +from time import time + +# After how many seconds the cached information about an authentication +# token is discarded. +TOKEN_LIFE = 86400 + +# This is used as a prefix for tokens and memcache keys. We use the default +# value from the Swift tempauth filter. In the future, this should be turned +# into a configuration parameter. +RESELLER_PREFIX = 'AUTH_' + +MEMCACHE_SERVERS = ['rhs1.example.com:11211'] + +def main(): + remote_user = os.environ['REMOTE_USER'] + + matches = re.match('([^@]+)@.*', remote_user) + if not matches: + raise RuntimeError("Malformed REMOTE_USER \"%s\"" % remote_user) + + username = matches.group(1) + + mc = MemcacheRing(MEMCACHE_SERVERS) + + # Check if we already got a token for this user. + memcache_user_key = '%s/user/%s' % (RESELLER_PREFIX, username) + token = None + candidate_token = mc.get(memcache_user_key) + if candidate_token: + memcache_token_key = '%s/token/%s' % (RESELLER_PREFIX, candidate_token) + cached_auth_data = mc.get(memcache_token_key) + if cached_auth_data: + expires, groups = cached_auth_data + if expires > time(): + token = candidate_token + + if not token: + # We don't use uuid.uuid4() here because importing the uuid module + # causes (harmless) SELinux denials in the audit log on RHEL 6. If this + # is a security concern, a custom SELinux policy module could be written + # to not log those denials. + r = random.SystemRandom() + token = '%stk%s' % (RESELLER_PREFIX, + ''.join(r.choice('abcdef0123456789') for x in range(32))) + + # Retrieve the numerical group IDs. We cannot list the group names + # because group names from Active Directory may contain spaces, and + # we wouldn't be able to split the list of group names into its + # elements. + p = subprocess.Popen(['id', '-G', username], stdout=subprocess.PIPE) + if p.wait() != 0: + raise RuntimeError("Failure running id -G for %s" % remote_user) + + (p_stdout, p_stderr) = p.communicate() + + # Convert the group numbers into group names. + groups = [] + for gid in p_stdout.strip().split(" "): + groups.append(grp.getgrgid(int(gid))[0]) + + # The first element of the list is considered a unique identifier + # for the user. We add the username to accomplish this. + if username in groups: + groups.remove(username) + groups = [username] + groups + + groups = ','.join(groups) + + expires = time() + TOKEN_LIFE + auth_data = (expires, groups) + + memcache_token_key = "%s/token/%s" % (RESELLER_PREFIX, token) + mc.set(memcache_token_key, auth_data, timeout=TOKEN_LIFE) + + # Record the token with the user info for future use. + memcache_user_key = '%s/user/%s' % (RESELLER_PREFIX, username) + mc.set(memcache_user_key, token, timeout=TOKEN_LIFE) + + print "X-Auth-Token: %s\n" % token + + # For debugging. + print "
%i / %s
" % mc.get(memcache_token_key) + +try: + print("Content-Type: text/html") + main() +except: + cgi.print_exception() diff --git a/apachekerbauth/build.sh b/apachekerbauth/build.sh new file mode 100755 index 0000000..6db04ac --- /dev/null +++ b/apachekerbauth/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +tar -cz --exclude=.svn -f ~/rpmbuild/SOURCES/apachekerbauth.tar.gz apachekerbauth + +rpmbuild --target noarch --clean -bb apachekerbauth.spec + +rm ~/rpmbuild/SOURCES/apachekerbauth.tar.gz diff --git a/swiftkerbauth/build.sh b/swiftkerbauth/build.sh new file mode 100755 index 0000000..0daff96 --- /dev/null +++ b/swiftkerbauth/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +tar -cz --exclude=.svn -f ~/rpmbuild/SOURCES/swiftkerbauth.tar.gz swiftkerbauth + +rpmbuild --target noarch --clean -bb swiftkerbauth.spec + +rm ~/rpmbuild/SOURCES/swiftkerbauth.tar.gz diff --git a/swiftkerbauth/swiftkerbauth.spec b/swiftkerbauth/swiftkerbauth.spec new file mode 100644 index 0000000..f01dc8d --- /dev/null +++ b/swiftkerbauth/swiftkerbauth.spec @@ -0,0 +1,54 @@ +Name: swiftkerbauth +Version: 1.0 +Release: 1 +Summary: Kerberos authentication filter for Swift + +Group: System Environment/Base +License: GPL +Source: %{name}.tar.gz +BuildRoot: %{_tmppath}/%{name}-root + +Requires: gluster-swift >= 1.4.8 +Requires: python-webob1.0 >= 1.0.8 + +%description +Python script which implements an authentication filter for Swift, the +object-level access layer of Red Hat Storage. + +Relies on an external authentication server, which comes with the +apachekerbauth package. + +%prep +%setup -q -n %{name} + +%build + +%install +rm -rf $RPM_BUILD_ROOT + +mkdir -p \ + $RPM_BUILD_ROOT/usr/lib/python2.6/site-packages + +install swiftkerbauth.py \ + $RPM_BUILD_ROOT/usr/lib/python2.6/site-packages + +%clean +rm -rf $RPM_BUILD_ROOT + +%files +%defattr(-,root,root,-) +/usr/lib/python2.6/site-packages/swiftkerbauth.py + +%post +if ! grep -q "filter:kerbauth" /etc/swift/proxy-server.conf; then +cat >>/etc/swift/proxy-server.conf < - 1.0-1 +- initial build diff --git a/swiftkerbauth/swiftkerbauth/swiftkerbauth.py b/swiftkerbauth/swiftkerbauth/swiftkerbauth.py new file mode 100644 index 0000000..05ff6e7 --- /dev/null +++ b/swiftkerbauth/swiftkerbauth/swiftkerbauth.py @@ -0,0 +1,346 @@ +# Copyright (c) 2011 OpenStack, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from time import gmtime, strftime, time +from traceback import format_exc +from urllib import quote, unquote + +from eventlet import Timeout + +from pkg_resources import require +require("WebOb>=1.0.8") + +from webob import Response, Request +from webob.exc import HTTPBadRequest, HTTPForbidden, HTTPNotFound, \ + HTTPSeeOther + +from swift.common.middleware.acl import clean_acl, parse_acl, referrer_allowed +from swift.common.utils import cache_from_env, get_logger, get_remote_client, \ + split_path, TRUE_VALUES + +class KerbAuth(object): + """ + Test authentication and authorization system. + + Add to your pipeline in proxy-server.conf, such as:: + + [pipeline:main] + pipeline = catch_errors cache kerbauth proxy-server + + Set account auto creation to true in proxy-server.conf:: + + [app:proxy-server] + account_autocreate = true + + And add a kerbauth filter section, such as:: + + [filter:kerbauth] + use = egg:swift#kerbauth + + See the proxy-server.conf-sample for more information. + + :param app: The next WSGI app in the pipeline + :param conf: The dict of configuration values + """ + + def __init__(self, app, conf): + self.app = app + self.conf = conf + self.logger = get_logger(conf, log_route='kerbauth') + self.log_headers = conf.get('log_headers') == 'True' + self.reseller_prefix = conf.get('reseller_prefix', 'AUTH').strip() + if self.reseller_prefix and self.reseller_prefix[-1] != '_': + self.reseller_prefix += '_' + self.auth_prefix = conf.get('auth_prefix', '/auth/') + if not self.auth_prefix: + self.auth_prefix = '/auth/' + if self.auth_prefix[0] != '/': + self.auth_prefix = '/' + self.auth_prefix + if self.auth_prefix[-1] != '/': + self.auth_prefix += '/' + self.token_life = int(conf.get('token_life', 86400)) + self.allowed_sync_hosts = [h.strip() + for h in conf.get('allowed_sync_hosts', '127.0.0.1').split(',') + if h.strip()] + self.allow_overrides = \ + conf.get('allow_overrides', 't').lower() in TRUE_VALUES + self.ext_authentication_url = conf.get('ext_authentication_url') + if not self.ext_authentication_url: + raise RuntimeError("Missing filter parameter ext_authentication_url in /etc/swift/proxy-server.conf") + + def __call__(self, env, start_response): + """ + Accepts a standard WSGI application call, authenticating the request + and installing callback hooks for authorization and ACL header + validation. For an authenticated request, REMOTE_USER will be set to a + comma separated list of the user's groups. + + If the request matches the self.auth_prefix, the request will be + routed through the internal auth request handler (self.handle). + This is to handle granting tokens, etc. + """ + if self.allow_overrides and env.get('swift.authorize_override', False): + return self.app(env, start_response) + if env.get('PATH_INFO', '').startswith(self.auth_prefix): + return self.handle(env, start_response) + token = env.get('HTTP_X_AUTH_TOKEN', env.get('HTTP_X_STORAGE_TOKEN')) + if token and token.startswith(self.reseller_prefix): + groups = self.get_groups(env, token) + if groups: + env['REMOTE_USER'] = groups + user = groups and groups.split(',', 1)[0] or '' + # We know the proxy logs the token, so we augment it just a bit + # to also log the authenticated user. + env['HTTP_X_AUTH_TOKEN'] = '%s,%s' % (user, token) + env['swift.authorize'] = self.authorize + env['swift.clean_acl'] = clean_acl + else: + # Invalid token (may be expired) + return HTTPSeeOther(location=self.ext_authentication_url)(env, start_response) + else: + # With a non-empty reseller_prefix, I would like to be called + # back for anonymous access to accounts I know I'm the + # definitive auth for. + try: + version, rest = split_path(env.get('PATH_INFO', ''), + 1, 2, True) + except ValueError: + return HTTPNotFound()(env, start_response) + # Not my token, not my account, I can't authorize this request, + # deny all is a good idea if not already set... + if 'swift.authorize' not in env: + env['swift.authorize'] = self.denied_response + + return self.app(env, start_response) + + def get_groups(self, env, token): + """ + Get groups for the given token. + + :param env: The current WSGI environment dictionary. + :param token: Token to validate and return a group string for. + + :returns: None if the token is invalid or a string containing a comma + separated list of groups the authenticated user is a member + of. The first group in the list is also considered a unique + identifier for that user. + """ + groups = None + memcache_client = cache_from_env(env) + if not memcache_client: + raise Exception('Memcache required') + memcache_token_key = '%s/token/%s' % (self.reseller_prefix, token) + cached_auth_data = memcache_client.get(memcache_token_key) + if cached_auth_data: + expires, groups = cached_auth_data + if expires < time(): + groups = None + + return groups + + def authorize(self, req): + """ + Returns None if the request is authorized to continue or a standard + WSGI response callable if not. + + Assumes that user groups are all lower case, which is true when Red Hat + Enterprise Linux Identity Management is used. + """ + try: + version, account, container, obj = split_path(req.path, 1, 4, True) + except ValueError: + return HTTPNotFound(request=req) + if not account or not account.startswith(self.reseller_prefix): + return self.denied_response(req) + user_groups = (req.remote_user or '').split(',') + # If the user is in the reseller_admin group for our prefix, he gets + # full access to all accounts we manage. For the default reseller + # prefix, the group name is auth_reseller_admin. + admin_group = ("%sreseller_admin" % self.reseller_prefix).lower() + if admin_group in user_groups and \ + account != self.reseller_prefix and \ + account[len(self.reseller_prefix)] != '.': + req.environ['swift_owner'] = True + return None + # The "account" is part of the request URL, and already contains the + # reseller prefix, like in "/v1/AUTH_vol1/pictures/pic1.png". + if account.lower() in user_groups and \ + (req.method not in ('DELETE', 'PUT') or container): + # If the user is admin for the account and is not trying to do an + # account DELETE or PUT... + req.environ['swift_owner'] = True + return None + if (req.environ.get('swift_sync_key') and + req.environ['swift_sync_key'] == + req.headers.get('x-container-sync-key', None) and + 'x-timestamp' in req.headers and + (req.remote_addr in self.allowed_sync_hosts or + get_remote_client(req) in self.allowed_sync_hosts)): + return None + referrers, groups = parse_acl(getattr(req, 'acl', None)) + if referrer_allowed(req.referer, referrers): + if obj or '.rlistings' in groups: + return None + return self.denied_response(req) + if not req.remote_user: + return self.denied_response(req) + for user_group in user_groups: + if user_group in groups: + return None + return self.denied_response(req) + + def denied_response(self, req): + """ + Returns a standard WSGI response callable with the status of 403 or 401 + depending on whether the REMOTE_USER is set or not. + """ + if req.remote_user: + return HTTPForbidden(request=req) + else: + return HTTPSeeOther(location=self.ext_authentication_url) + + def handle(self, env, start_response): + """ + WSGI entry point for auth requests (ones that match the + self.auth_prefix). + Wraps env in webob.Request object and passes it down. + + :param env: WSGI environment dictionary + :param start_response: WSGI callable + """ + try: + req = Request(env) + if self.auth_prefix: + req.path_info_pop() + req.bytes_transferred = '-' + req.client_disconnect = False + if 'x-storage-token' in req.headers and \ + 'x-auth-token' not in req.headers: + req.headers['x-auth-token'] = req.headers['x-storage-token'] + if 'eventlet.posthooks' in env: + env['eventlet.posthooks'].append( + (self.posthooklogger, (req,), {})) + return self.handle_request(req)(env, start_response) + else: + # Lack of posthook support means that we have to log on the + # start of the response, rather than after all the data has + # been sent. This prevents logging client disconnects + # differently than full transmissions. + response = self.handle_request(req)(env, start_response) + self.posthooklogger(env, req) + return response + except (Exception, Timeout): + print "EXCEPTION IN handle: %s: %s" % (format_exc(), env) + start_response('500 Server Error', + [('Content-Type', 'text/plain')]) + return ['Internal server error.\n'] + + def handle_request(self, req): + """ + Entry point for auth requests (ones that match the self.auth_prefix). + Should return a WSGI-style callable (such as webob.Response). + + :param req: webob.Request object + """ + req.start_time = time() + handler = None + try: + version, account, user, _junk = split_path(req.path_info, + minsegs=1, maxsegs=4, rest_with_last=True) + except ValueError: + return HTTPNotFound(request=req) + if version in ('v1', 'v1.0', 'auth'): + if req.method == 'GET': + handler = self.handle_get_token + if not handler: + req.response = HTTPBadRequest(request=req) + else: + req.response = handler(req) + return req.response + + def handle_get_token(self, req): + """ + Handles the various `request for token and service end point(s)` calls. + There are various formats to support the various auth servers in the + past. Examples:: + + GET /v1//auth + GET /auth + GET /v1.0 + + All formats require GSS (Kerberos) authentication. + + On successful authentication, the response will have X-Auth-Token + set to the token to use with Swift. + + :param req: The webob.Request to process. + :returns: webob.Response, 2xx on success with data set as explained + above. + """ + # Validate the request info + try: + pathsegs = split_path(req.path_info, minsegs=1, maxsegs=3, + rest_with_last=True) + except ValueError: + return HTTPNotFound(request=req) + if not ((pathsegs[0] == 'v1' and pathsegs[2] == 'auth') or pathsegs[0] in ('auth', 'v1.0')): + return HTTPBadRequest(request=req) + + return HTTPSeeOther(location=self.ext_authentication_url) + + def posthooklogger(self, env, req): + if not req.path.startswith(self.auth_prefix): + return + response = getattr(req, 'response', None) + if not response: + return + trans_time = '%.4f' % (time() - req.start_time) + the_request = quote(unquote(req.path)) + if req.query_string: + the_request = the_request + '?' + req.query_string + # remote user for zeus + client = req.headers.get('x-cluster-client-ip') + if not client and 'x-forwarded-for' in req.headers: + # remote user for other lbs + client = req.headers['x-forwarded-for'].split(',')[0].strip() + logged_headers = None + if self.log_headers: + logged_headers = '\n'.join('%s: %s' % (k, v) + for k, v in req.headers.items()) + status_int = response.status_int + if getattr(req, 'client_disconnect', False) or \ + getattr(response, 'client_disconnect', False): + status_int = 499 + self.logger.info(' '.join(quote(str(x)) for x in (client or '-', + req.remote_addr or '-', strftime('%d/%b/%Y/%H/%M/%S', gmtime()), + req.method, the_request, req.environ['SERVER_PROTOCOL'], + status_int, req.referer or '-', req.user_agent or '-', + req.headers.get('x-auth-token', + req.headers.get('x-auth-admin-user', '-')), + getattr(req, 'bytes_transferred', 0) or '-', + getattr(response, 'bytes_transferred', 0) or '-', + req.headers.get('etag', '-'), + req.environ.get('swift.trans_id', '-'), logged_headers or '-', + trans_time))) + + +def filter_factory(global_conf, **local_conf): + """Returns a WSGI filter app for use with paste.deploy.""" + conf = global_conf.copy() + conf.update(local_conf) + + def auth_filter(app): + return KerbAuth(app, conf) + return auth_filter -- cgit