From 4b988ce3c598c8b59bd0ce77ab7854291c66549f Mon Sep 17 00:00:00 2001 From: Chetan Risbud Date: Mon, 23 Dec 2013 15:46:22 +0530 Subject: Initial import of the swiftkerbauth Imported code till commit f64a3354185f32928e2568d9ece4a52fa4746c05 Changed a code bit to import correct definitions. kerbauth unit tests do run along with gluster-swift. Install script does install swiftkerbauth. import swiftkerbauth from http://review.gluster.org/swiftkrbauth.git Change-Id: Ia89f2b77cc68df10dee2f41ce074f3381ac3c408 Signed-off-by: Chetan Risbud Reviewed-on: http://review.gluster.org/6597 Reviewed-by: Prashanth Pai Reviewed-by: Luis Pabon Tested-by: Luis Pabon --- .../common/middleware/swiftkerbauth/__init__.py | 38 +++ .../etc/httpd/conf.d/swift-auth.conf | 12 + .../apachekerbauth/var/www/cgi-bin/swift-auth | 70 +++++ .../common/middleware/swiftkerbauth/kerbauth.py | 328 +++++++++++++++++++++ .../middleware/swiftkerbauth/kerbauth_utils.py | 107 +++++++ 5 files changed, 555 insertions(+) create mode 100644 gluster/swift/common/middleware/swiftkerbauth/__init__.py create mode 100644 gluster/swift/common/middleware/swiftkerbauth/apachekerbauth/etc/httpd/conf.d/swift-auth.conf create mode 100755 gluster/swift/common/middleware/swiftkerbauth/apachekerbauth/var/www/cgi-bin/swift-auth create mode 100644 gluster/swift/common/middleware/swiftkerbauth/kerbauth.py create mode 100644 gluster/swift/common/middleware/swiftkerbauth/kerbauth_utils.py (limited to 'gluster') diff --git a/gluster/swift/common/middleware/swiftkerbauth/__init__.py b/gluster/swift/common/middleware/swiftkerbauth/__init__.py new file mode 100644 index 0000000..c752df7 --- /dev/null +++ b/gluster/swift/common/middleware/swiftkerbauth/__init__.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +# Copyright (c) 2013 Red Hat, Inc. +# +# 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 swift.common.utils import readconf, config_true_value + +config_file = {} +try: + config_file = readconf("/etc/swift/proxy-server.conf", + section_name="filter:cache") +except SystemExit: + pass + +MEMCACHE_SERVERS = config_file.get('memcache_servers', None) + +config_file = {} + +try: + config_file = readconf("/etc/swift/proxy-server.conf", + section_name="filter:kerbauth") +except SystemExit: + pass + +TOKEN_LIFE = int(config_file.get('token_life', 86400)) +RESELLER_PREFIX = config_file.get('reseller_prefix', "AUTH_") +DEBUG_HEADERS = config_true_value(config_file.get('debug_headers', 'yes')) diff --git a/gluster/swift/common/middleware/swiftkerbauth/apachekerbauth/etc/httpd/conf.d/swift-auth.conf b/gluster/swift/common/middleware/swiftkerbauth/apachekerbauth/etc/httpd/conf.d/swift-auth.conf new file mode 100644 index 0000000..68472d8 --- /dev/null +++ b/gluster/swift/common/middleware/swiftkerbauth/apachekerbauth/etc/httpd/conf.d/swift-auth.conf @@ -0,0 +1,12 @@ + + AuthType Kerberos + AuthName "Swift Authentication" + KrbMethodNegotiate On + KrbMethodK5Passwd On + KrbSaveCredentials On + KrbServiceName HTTP/client.example.com + KrbAuthRealms EXAMPLE.COM + Krb5KeyTab /etc/httpd/conf/http.keytab + KrbVerifyKDC Off + Require valid-user + diff --git a/gluster/swift/common/middleware/swiftkerbauth/apachekerbauth/var/www/cgi-bin/swift-auth b/gluster/swift/common/middleware/swiftkerbauth/apachekerbauth/var/www/cgi-bin/swift-auth new file mode 100755 index 0000000..45df45c --- /dev/null +++ b/gluster/swift/common/middleware/swiftkerbauth/apachekerbauth/var/www/cgi-bin/swift-auth @@ -0,0 +1,70 @@ +#!/usr/bin/python + +# Copyright (c) 2013 Red Hat, Inc. +# +# 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. + +# Requires the following command to be run: +# setsebool -P httpd_can_network_connect 1 +# setsebool -P httpd_can_network_memcache 1 + +import os +import cgi +from swift.common.memcached import MemcacheRing +from time import time, ctime +from swiftkerbauth import MEMCACHE_SERVERS, TOKEN_LIFE, DEBUG_HEADERS +from swiftkerbauth.kerbauth_utils import get_remote_user, get_auth_data, \ + generate_token, set_auth_data, get_groups + + +def main(): + try: + username = get_remote_user(os.environ) + except RuntimeError: + print "Status: 401 Unauthorized\n" + print "Malformed REMOTE_USER" + return + + if not MEMCACHE_SERVERS: + print "Status: 500 Internal Server Error\n" + print "Memcache not configured in /etc/swift/proxy-server.conf" + return + + mc_servers = [s.strip() for s in MEMCACHE_SERVERS.split(',') if s.strip()] + mc = MemcacheRing(mc_servers) + + token, expires, groups = get_auth_data(mc, username) + + if not token: + token = generate_token() + expires = time() + TOKEN_LIFE + groups = get_groups(username) + set_auth_data(mc, username, token, expires, groups) + + print "X-Auth-Token: %s" % token + print "X-Storage-Token: %s" % token + + # For debugging. + if DEBUG_HEADERS: + print "X-Debug-Remote-User: %s" % username + print "X-Debug-Groups: %s" % groups + print "X-Debug-Token-Life: %ss" % TOKEN_LIFE + print "X-Debug-Token-Expires: %s" % ctime(expires) + + print "" + +try: + print("Content-Type: text/html") + main() +except: + cgi.print_exception() diff --git a/gluster/swift/common/middleware/swiftkerbauth/kerbauth.py b/gluster/swift/common/middleware/swiftkerbauth/kerbauth.py new file mode 100644 index 0000000..a1ba091 --- /dev/null +++ b/gluster/swift/common/middleware/swiftkerbauth/kerbauth.py @@ -0,0 +1,328 @@ +# Copyright (c) 2013 Red Hat, Inc. +# +# 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 time +from traceback import format_exc +from eventlet import Timeout + +from swift.common.swob import Request +from swift.common.swob 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, \ + split_path, config_true_value + + +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:swiftkerbauth#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 = config_true_value(conf.get('log_headers', 'f')) + self.reseller_prefix = conf.get('reseller_prefix', 'AUTH').strip() + if self.reseller_prefix and self.reseller_prefix[-1] != '_': + self.reseller_prefix += '_' + self.logger.set_statsd_prefix('kerbauth.%s' % ( + self.reseller_prefix if self.reseller_prefix else 'NONE',)) + self.auth_prefix = conf.get('auth_prefix', '/auth/') + if not self.auth_prefix or not self.auth_prefix.strip('/'): + self.logger.warning('Rewriting invalid auth prefix "%s" to ' + '"/auth/" (Non-empty auth prefix path ' + 'is required)' % 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.allow_overrides = config_true_value( + conf.get('allow_overrides', 't')) + self.storage_url_scheme = conf.get('storage_url_scheme', 'default') + 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: + user = groups and groups.split(',', 1)[0] or '' + trans_id = env.get('swift.trans_id') + self.logger.debug('User: %s uses token %s (trans_id %s)' % + (user, token, trans_id)) + env['REMOTE_USER'] = groups + env['swift.authorize'] = self.authorize + env['swift.clean_acl'] = clean_acl + if '.reseller_admin' in groups: + env['reseller_request'] = True + 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: + version, rest = None, None + self.logger.increment('errors') + # 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 = req.split_path(1, 4, True) + except ValueError: + self.logger.increment('errors') + return HTTPNotFound(request=req) + + if not account or not account.startswith(self.reseller_prefix): + self.logger.debug("Account name: %s doesn't start with " + "reseller_prefix: %s." + % (account, self.reseller_prefix)) + return self.denied_response(req) + + user_groups = (req.remote_user or '').split(',') + account_user = user_groups[1] if len(user_groups) > 1 else None + # 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 + self.logger.debug("User %s has admin authorizing." + % account_user) + 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): + self.logger.debug("Allow request with container sync-key: %s." + % req.environ['swift_sync_key']) + return None + + if req.method == 'OPTIONS': + #allow OPTIONS requests to proceed as normal + self.logger.debug("Allow OPTIONS request.") + return None + + referrers, groups = parse_acl(getattr(req, 'acl', None)) + + if referrer_allowed(req.referer, referrers): + if obj or '.rlistings' in groups: + self.logger.debug("Allow authorizing %s via referer ACL." + % req.referer) + return None + + for user_group in user_groups: + if user_group in groups: + self.logger.debug("User %s allowed in ACL: %s authorizing." + % (account_user, user_group)) + 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: + self.logger.increment('forbidden') + 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 swob.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'] + return self.handle_request(req)(env, start_response) + except (Exception, Timeout): + print "EXCEPTION IN handle: %s: %s" % (format_exc(), env) + self.logger.increment('errors') + 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: swob.Request object + """ + req.start_time = time() + handler = None + try: + version, account, user, _junk = req.split_path(1, 4, True) + except ValueError: + self.logger.increment('errors') + return HTTPNotFound(request=req) + if version in ('v1', 'v1.0', 'auth'): + if req.method == 'GET': + handler = self.handle_get_token + if not handler: + self.logger.increment('errors') + 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 swob.Request to process. + :returns: swob.Response, 2xx on success with data set as explained + above. + """ + # Validate the request info + try: + pathsegs = split_path(req.path_info, 1, 3, True) + except ValueError: + self.logger.increment('errors') + 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 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 diff --git a/gluster/swift/common/middleware/swiftkerbauth/kerbauth_utils.py b/gluster/swift/common/middleware/swiftkerbauth/kerbauth_utils.py new file mode 100644 index 0000000..2fecf4b --- /dev/null +++ b/gluster/swift/common/middleware/swiftkerbauth/kerbauth_utils.py @@ -0,0 +1,107 @@ +# Copyright (c) 2013 Red Hat, Inc. +# +# 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. + +import re +import random +import grp +import subprocess +from time import time +from gluster.swift.common.middleware.swiftkerbauth \ + import TOKEN_LIFE, RESELLER_PREFIX + + +def get_remote_user(env): + """Retrieve REMOTE_USER set by Apache from environment.""" + remote_user = env.get('REMOTE_USER', "") + matches = re.match('([^@]+)@.*', remote_user) + if not matches: + raise RuntimeError("Malformed REMOTE_USER \"%s\"" % remote_user) + return matches.group(1) + + +def get_auth_data(mc, username): + """ + Returns the token, expiry time and groups for the user if it already exists + on memcache. Returns None otherwise. + + :param mc: MemcacheRing object + :param username: swift user + """ + token, expires, groups = None, None, None + memcache_user_key = '%s/user/%s' % (RESELLER_PREFIX, username) + 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 + else: + expires, groups = None, None + return (token, expires, groups) + + +def set_auth_data(mc, username, token, expires, groups): + """ + Stores the following key value pairs on Memcache: + (token, expires+groups) + (user, token) + """ + auth_data = (expires, groups) + memcache_token_key = "%s/token/%s" % (RESELLER_PREFIX, token) + mc.set(memcache_token_key, auth_data, time=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, time=TOKEN_LIFE) + + +def generate_token(): + """Generates a random 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))) + return token + + +def get_groups(username): + """Return a set of groups to which the user belongs to.""" + # 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" % username) + (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) + return groups -- cgit