|
| 1 | +# Copyright 2012 OpenStack Foundation |
| 2 | +# Copyright 2010 United States Government as represented by the |
| 3 | +# Administrator of the National Aeronautics and Space Administration. |
| 4 | +# Copyright 2011,2012 Akira YOSHIYAMA <akirayoshiyama@gmail.com> |
| 5 | +# All Rights Reserved. |
| 6 | +# |
| 7 | +# Licensed under the Apache License, Version 2.0 (the "License"); you may |
| 8 | +# not use this file except in compliance with the License. You may obtain |
| 9 | +# a copy of the License at |
| 10 | +# |
| 11 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 12 | +# |
| 13 | +# Unless required by applicable law or agreed to in writing, software |
| 14 | +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 15 | +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 16 | +# License for the specific language governing permissions and limitations |
| 17 | +# under the License. |
| 18 | + |
| 19 | +# This source code is based ./auth_token.py and ./ec2_token.py. |
| 20 | +# See them for their copyright. |
| 21 | + |
| 22 | +""" |
| 23 | +S3 Token Middleware |
| 24 | +
|
| 25 | +This WSGI component: |
| 26 | +
|
| 27 | +* Gets a request from the swift3 middleware with an S3 Authorization |
| 28 | + access key. |
| 29 | +* Validates s3 token in Keystone. |
| 30 | +* Transforms the account name to AUTH_%(tenant_name). |
| 31 | +
|
| 32 | +""" |
| 33 | + |
| 34 | +import json |
| 35 | +import logging |
| 36 | + |
| 37 | +import requests |
| 38 | +import six |
| 39 | + |
| 40 | +from swift.common.swob import Request, Response |
| 41 | +from swift.common.utils import config_true_value, split_path |
| 42 | + |
| 43 | + |
| 44 | +PROTOCOL_NAME = 'S3 Token Authentication' |
| 45 | + |
| 46 | + |
| 47 | +class ServiceError(Exception): |
| 48 | + pass |
| 49 | + |
| 50 | + |
| 51 | +class S3Token(object): |
| 52 | + """Middleware that handles S3 authentication.""" |
| 53 | + |
| 54 | + def __init__(self, app, conf): |
| 55 | + """Common initialization code.""" |
| 56 | + self._app = app |
| 57 | + self._logger = logging.getLogger(conf.get('log_name', __name__)) |
| 58 | + self._logger.debug('Starting the %s component', PROTOCOL_NAME) |
| 59 | + self._reseller_prefix = conf.get('reseller_prefix', 'AUTH_') |
| 60 | + # where to find the auth service (we use this to validate tokens) |
| 61 | + |
| 62 | + self._request_uri = conf.get('auth_uri') |
| 63 | + if not self._request_uri: |
| 64 | + self._logger.warning( |
| 65 | + "Use of the auth_host, auth_port, and auth_protocol " |
| 66 | + "configuration options was deprecated in the Newton release " |
| 67 | + "in favor of auth_uri. These options may be removed in a " |
| 68 | + "future release.") |
| 69 | + auth_host = conf.get('auth_host') |
| 70 | + auth_port = int(conf.get('auth_port', 35357)) |
| 71 | + auth_protocol = conf.get('auth_protocol', 'https') |
| 72 | + |
| 73 | + self._request_uri = '%s://%s:%s' % (auth_protocol, auth_host, |
| 74 | + auth_port) |
| 75 | + self._request_uri = self._request_uri.rstrip('/') |
| 76 | + |
| 77 | + # SSL |
| 78 | + insecure = config_true_value(conf.get('insecure')) |
| 79 | + cert_file = conf.get('certfile') |
| 80 | + key_file = conf.get('keyfile') |
| 81 | + |
| 82 | + if insecure: |
| 83 | + self._verify = False |
| 84 | + elif cert_file and key_file: |
| 85 | + self._verify = (cert_file, key_file) |
| 86 | + elif cert_file: |
| 87 | + self._verify = cert_file |
| 88 | + else: |
| 89 | + self._verify = None |
| 90 | + |
| 91 | + def _deny_request(self, code): |
| 92 | + error_table = { |
| 93 | + 'AccessDenied': (401, 'Access denied'), |
| 94 | + 'InvalidURI': (400, 'Could not parse the specified URI'), |
| 95 | + } |
| 96 | + resp = Response(content_type='text/xml') |
| 97 | + resp.status = error_table[code][0] |
| 98 | + error_msg = ('<?xml version="1.0" encoding="UTF-8"?>\r\n' |
| 99 | + '<Error>\r\n <Code>%s</Code>\r\n ' |
| 100 | + '<Message>%s</Message>\r\n</Error>\r\n' % |
| 101 | + (code, error_table[code][1])) |
| 102 | + if six.PY3: |
| 103 | + error_msg = error_msg.encode() |
| 104 | + resp.body = error_msg |
| 105 | + return resp |
| 106 | + |
| 107 | + def _json_request(self, creds_json): |
| 108 | + headers = {'Content-Type': 'application/json'} |
| 109 | + try: |
| 110 | + response = requests.post('%s/v2.0/s3tokens' % self._request_uri, |
| 111 | + headers=headers, data=creds_json, |
| 112 | + verify=self._verify) |
| 113 | + except requests.exceptions.RequestException as e: |
| 114 | + self._logger.info('HTTP connection exception: %s', e) |
| 115 | + resp = self._deny_request('InvalidURI') |
| 116 | + raise ServiceError(resp) |
| 117 | + |
| 118 | + if response.status_code < 200 or response.status_code >= 300: |
| 119 | + self._logger.debug('Keystone reply error: status=%s reason=%s', |
| 120 | + response.status_code, response.reason) |
| 121 | + resp = self._deny_request('AccessDenied') |
| 122 | + raise ServiceError(resp) |
| 123 | + |
| 124 | + return response |
| 125 | + |
| 126 | + def __call__(self, environ, start_response): |
| 127 | + """Handle incoming request. authenticate and send downstream.""" |
| 128 | + req = Request(environ) |
| 129 | + self._logger.debug('Calling S3Token middleware.') |
| 130 | + |
| 131 | + try: |
| 132 | + parts = split_path(req.path, 1, 4, True) |
| 133 | + version, account, container, obj = parts |
| 134 | + except ValueError: |
| 135 | + msg = 'Not a path query, skipping.' |
| 136 | + self._logger.debug(msg) |
| 137 | + return self._app(environ, start_response) |
| 138 | + |
| 139 | + # Read request signature and access id. |
| 140 | + if 'Authorization' not in req.headers: |
| 141 | + msg = 'No Authorization header. skipping.' |
| 142 | + self._logger.debug(msg) |
| 143 | + return self._app(environ, start_response) |
| 144 | + |
| 145 | + token = req.headers.get('X-Auth-Token', |
| 146 | + req.headers.get('X-Storage-Token')) |
| 147 | + if not token: |
| 148 | + msg = 'You did not specify an auth or a storage token. skipping.' |
| 149 | + self._logger.debug(msg) |
| 150 | + return self._app(environ, start_response) |
| 151 | + |
| 152 | + auth_header = req.headers['Authorization'] |
| 153 | + try: |
| 154 | + access, signature = auth_header.split(' ')[-1].rsplit(':', 1) |
| 155 | + except ValueError: |
| 156 | + msg = 'You have an invalid Authorization header: %s' |
| 157 | + self._logger.debug(msg, auth_header) |
| 158 | + return self._deny_request('InvalidURI')(environ, start_response) |
| 159 | + |
| 160 | + # NOTE(chmou): This is to handle the special case with nova |
| 161 | + # when we have the option s3_affix_tenant. We will force it to |
| 162 | + # connect to another account than the one |
| 163 | + # authenticated. Before people start getting worried about |
| 164 | + # security, I should point that we are connecting with |
| 165 | + # username/token specified by the user but instead of |
| 166 | + # connecting to its own account we will force it to go to an |
| 167 | + # another account. In a normal scenario if that user don't |
| 168 | + # have the reseller right it will just fail but since the |
| 169 | + # reseller account can connect to every account it is allowed |
| 170 | + # by the swift_auth middleware. |
| 171 | + force_tenant = None |
| 172 | + if ':' in access: |
| 173 | + access, force_tenant = access.split(':') |
| 174 | + |
| 175 | + # Authenticate request. |
| 176 | + creds = {'credentials': {'access': access, |
| 177 | + 'token': token, |
| 178 | + 'signature': signature}} |
| 179 | + creds_json = json.dumps(creds) |
| 180 | + self._logger.debug('Connecting to Keystone sending this JSON: %s', |
| 181 | + creds_json) |
| 182 | + # NOTE(vish): We could save a call to keystone by having |
| 183 | + # keystone return token, tenant, user, and roles |
| 184 | + # from this call. |
| 185 | + # |
| 186 | + # NOTE(chmou): We still have the same problem we would need to |
| 187 | + # change token_auth to detect if we already |
| 188 | + # identified and not doing a second query and just |
| 189 | + # pass it through to swiftauth in this case. |
| 190 | + try: |
| 191 | + resp = self._json_request(creds_json) |
| 192 | + except ServiceError as e: |
| 193 | + resp = e.args[0] # NB: swob.Response, not requests.Response |
| 194 | + msg = 'Received error, exiting middleware with error: %s' |
| 195 | + self._logger.debug(msg, resp.status_int) |
| 196 | + return resp(environ, start_response) |
| 197 | + |
| 198 | + self._logger.debug('Keystone Reply: Status: %d, Output: %s', |
| 199 | + resp.status_code, resp.content) |
| 200 | + |
| 201 | + try: |
| 202 | + identity_info = resp.json() |
| 203 | + token_id = str(identity_info['access']['token']['id']) |
| 204 | + tenant = identity_info['access']['token']['tenant'] |
| 205 | + except (ValueError, KeyError): |
| 206 | + error = 'Error on keystone reply: %d %s' |
| 207 | + self._logger.debug(error, resp.status_code, resp.content) |
| 208 | + return self._deny_request('InvalidURI')(environ, start_response) |
| 209 | + |
| 210 | + req.headers['X-Auth-Token'] = token_id |
| 211 | + tenant_to_connect = force_tenant or tenant['id'] |
| 212 | + if six.PY2 and isinstance(tenant_to_connect, six.text_type): |
| 213 | + tenant_to_connect = tenant_to_connect.encode('utf-8') |
| 214 | + self._logger.debug('Connecting with tenant: %s', tenant_to_connect) |
| 215 | + new_tenant_name = '%s%s' % (self._reseller_prefix, tenant_to_connect) |
| 216 | + environ['PATH_INFO'] = environ['PATH_INFO'].replace(account, |
| 217 | + new_tenant_name) |
| 218 | + return self._app(environ, start_response) |
| 219 | + |
| 220 | + |
| 221 | +def filter_factory(global_conf, **local_conf): |
| 222 | + """Returns a WSGI filter app for use with paste.deploy.""" |
| 223 | + conf = global_conf.copy() |
| 224 | + conf.update(local_conf) |
| 225 | + |
| 226 | + def auth_filter(app): |
| 227 | + return S3Token(app, conf) |
| 228 | + return auth_filter |
0 commit comments