Merge "Added authmiddleware"
This commit is contained in:
commit
0b4bd139f5
@ -3,11 +3,14 @@
|
|||||||
|
|
||||||
# Remove authtoken from the pipeline if you don't want to use keystone authentication
|
# Remove authtoken from the pipeline if you don't want to use keystone authentication
|
||||||
[pipeline:main]
|
[pipeline:main]
|
||||||
pipeline = api-server
|
pipeline = surveil-auth api-server
|
||||||
|
|
||||||
[app:api-server]
|
[app:api-server]
|
||||||
paste.app_factory = surveil.api.app:app_factory
|
paste.app_factory = surveil.api.app:app_factory
|
||||||
|
|
||||||
|
[filter:surveil-auth]
|
||||||
|
paste.filter_factory = surveil.api.authmiddleware.auth:filter_factory
|
||||||
|
|
||||||
[filter:authtoken]
|
[filter:authtoken]
|
||||||
paste.filter_factory = keystonemiddleware.auth_token:filter_factory
|
paste.filter_factory = keystonemiddleware.auth_token:filter_factory
|
||||||
|
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"surveil:break":"!",
|
"admin_required": "role:admin or is_admin:1",
|
||||||
"surveil:pass":"@"
|
"surveil:admin": "rule:admin_required",
|
||||||
}
|
"surveil:break": "!",
|
||||||
|
"surveil:pass": "@"
|
||||||
|
}
|
0
surveil/api/authmiddleware/__init__.py
Normal file
0
surveil/api/authmiddleware/__init__.py
Normal file
170
surveil/api/authmiddleware/auth.py
Normal file
170
surveil/api/authmiddleware/auth.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
# Copyright 2010-2012 OpenStack Foundation
|
||||||
|
# Copyright 2015 - Savoir-Faire Linux 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 surveil.api.authmiddleware import utils
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
|
"""
|
||||||
|
Keystone-Compatible Token-based Authentication Middleware.
|
||||||
|
|
||||||
|
This Middleware is based on keystonemiddleware, it creates the same headers but
|
||||||
|
verifies token authenticity against some other service. It was created for
|
||||||
|
Surveil so that we can have more flexibility on the authentication backend.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_HEADER_TEMPLATE = {
|
||||||
|
'X%s-Domain-Id': 'domain_id',
|
||||||
|
'X%s-Domain-Name': 'domain_name',
|
||||||
|
'X%s-Project-Id': 'project_id',
|
||||||
|
'X%s-Project-Name': 'project_name',
|
||||||
|
'X%s-Project-Domain-Id': 'project_domain_id',
|
||||||
|
'X%s-Project-Domain-Name': 'project_domain_name',
|
||||||
|
'X%s-User-Id': 'user_id',
|
||||||
|
'X%s-User-Name': 'username',
|
||||||
|
'X%s-User-Domain-Id': 'user_domain_id',
|
||||||
|
'X%s-User-Domain-Name': 'user_domain_name',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 AuthProtocol(app, conf)
|
||||||
|
return auth_filter
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProtocol(object):
|
||||||
|
"""Middleware that handles authenticating client calls."""
|
||||||
|
|
||||||
|
def __init__(self, app, conf):
|
||||||
|
self._app = app
|
||||||
|
self._init_auth_headers()
|
||||||
|
|
||||||
|
# TODO(aviau): auth_uri should be loaded in config
|
||||||
|
self._auth_uri = 'www.surveil.com'
|
||||||
|
|
||||||
|
def _init_auth_headers(self):
|
||||||
|
"""Initialize auth header list.
|
||||||
|
|
||||||
|
Both user and service token headers are generated.
|
||||||
|
"""
|
||||||
|
auth_headers = ['X-Service-Catalog',
|
||||||
|
'X-Identity-Status',
|
||||||
|
'X-Service-Identity-Status',
|
||||||
|
'X-Roles',
|
||||||
|
'X-Service-Roles']
|
||||||
|
for key in six.iterkeys(_HEADER_TEMPLATE):
|
||||||
|
auth_headers.append(key % '')
|
||||||
|
# Service headers
|
||||||
|
auth_headers.append(key % '-Service')
|
||||||
|
|
||||||
|
self._auth_headers = auth_headers
|
||||||
|
|
||||||
|
def __call__(self, env, start_response):
|
||||||
|
"""Handle incoming request.
|
||||||
|
|
||||||
|
Authenticate send downstream on success. Reject request if
|
||||||
|
we can't authenticate.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self._remove_auth_headers(env)
|
||||||
|
|
||||||
|
# TODO(aviau): Get token and validate it, then build proper headers
|
||||||
|
if False:
|
||||||
|
self._reject_request(env, start_response)
|
||||||
|
|
||||||
|
user_headers = {
|
||||||
|
'X-Identity-Status': 'Confirmed',
|
||||||
|
'X-User-Id': 'surveil',
|
||||||
|
'X-Roles': 'admin',
|
||||||
|
'X-Service-Catalog': 'surveil'
|
||||||
|
}
|
||||||
|
self._add_headers(env, user_headers)
|
||||||
|
|
||||||
|
service_headers = {
|
||||||
|
'X-Service-Identity-Status': 'Confirmed',
|
||||||
|
'X-Service-Roles': 'surveil',
|
||||||
|
}
|
||||||
|
self._add_headers(env, service_headers)
|
||||||
|
|
||||||
|
return self._call_app(env, start_response)
|
||||||
|
|
||||||
|
def _remove_auth_headers(self, env):
|
||||||
|
"""Remove headers so a user can't fake authentication.
|
||||||
|
|
||||||
|
Both user and service token headers are removed.
|
||||||
|
|
||||||
|
:param env: wsgi request environment
|
||||||
|
|
||||||
|
"""
|
||||||
|
self._remove_headers(env, self._auth_headers)
|
||||||
|
|
||||||
|
def _remove_headers(self, env, keys):
|
||||||
|
"""Remove http headers from environment."""
|
||||||
|
for k in keys:
|
||||||
|
env_key = self._header_to_env_var(k)
|
||||||
|
try:
|
||||||
|
del env[env_key]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _add_headers(self, env, headers):
|
||||||
|
"""Add http headers to environment."""
|
||||||
|
for (k, v) in six.iteritems(headers):
|
||||||
|
env_key = self._header_to_env_var(k)
|
||||||
|
env[env_key] = v
|
||||||
|
|
||||||
|
def _header_to_env_var(self, key):
|
||||||
|
"""Convert header to wsgi env variable.
|
||||||
|
|
||||||
|
:param key: http header name (ex. 'X-Auth-Token')
|
||||||
|
:returns: wsgi env variable name (ex. 'HTTP_X_AUTH_TOKEN')
|
||||||
|
|
||||||
|
"""
|
||||||
|
return 'HTTP_%s' % key.replace('-', '_').upper()
|
||||||
|
|
||||||
|
def _call_app(self, env, start_response):
|
||||||
|
# NOTE(jamielennox): We wrap the given start response so that if an
|
||||||
|
# application with a 'delay_auth_decision' setting fails, or otherwise
|
||||||
|
# raises Unauthorized that we include the Authentication URL headers.
|
||||||
|
def _fake_start_response(status, response_headers, exc_info=None):
|
||||||
|
if status.startswith('401'):
|
||||||
|
response_headers.extend(self._reject_auth_headers)
|
||||||
|
return start_response(status, response_headers, exc_info)
|
||||||
|
return self._app(env, _fake_start_response)
|
||||||
|
|
||||||
|
def _reject_request(self, env, start_response):
|
||||||
|
"""Redirect client to auth server.
|
||||||
|
|
||||||
|
:param env: wsgi request environment
|
||||||
|
:param start_response: wsgi response callback
|
||||||
|
:returns: HTTPUnauthorized http response
|
||||||
|
"""
|
||||||
|
resp = utils.MiniResp('Authentication required',
|
||||||
|
env, self._reject_auth_headers)
|
||||||
|
start_response('401 Unauthorized', resp.headers)
|
||||||
|
return resp.body
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _reject_auth_headers(self):
|
||||||
|
header_val = 'Keystone uri=\'%s\'' % self._auth_uri
|
||||||
|
return [('WWW-Authenticate', header_val)]
|
32
surveil/api/authmiddleware/utils.py
Normal file
32
surveil/api/authmiddleware/utils.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# 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 six.moves import urllib
|
||||||
|
|
||||||
|
|
||||||
|
def safe_quote(s):
|
||||||
|
"""URL-encode strings that are not already URL-encoded."""
|
||||||
|
return urllib.parse.quote(s) if s == urllib.parse.unquote(s) else s
|
||||||
|
|
||||||
|
|
||||||
|
class MiniResp(object):
|
||||||
|
|
||||||
|
def __init__(self, error_message, env, headers=[]):
|
||||||
|
# The HEAD method is unique: it must never return a body, even if
|
||||||
|
# it reports an error (RFC-2616 clause 9.4). We relieve callers
|
||||||
|
# from varying the error responses depending on the method.
|
||||||
|
if env['REQUEST_METHOD'] == 'HEAD':
|
||||||
|
self.body = ['']
|
||||||
|
else:
|
||||||
|
self.body = [error_message.encode()]
|
||||||
|
self.headers = list(headers)
|
||||||
|
self.headers.append(('Content-type', 'text/plain'))
|
@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
from pecan import rest
|
from pecan import rest
|
||||||
|
|
||||||
|
from surveil.api.controllers.v2.auth import login
|
||||||
|
|
||||||
|
|
||||||
class AuthController(rest.RestController):
|
class AuthController(rest.RestController):
|
||||||
# login = LoginController()
|
login = login.LoginController()
|
||||||
# logout = LogoutController()
|
|
||||||
pass
|
|
||||||
|
42
surveil/api/controllers/v2/auth/login.py
Normal file
42
surveil/api/controllers/v2/auth/login.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Copyright 2014 - Savoir-Faire Linux 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 json
|
||||||
|
|
||||||
|
import pecan
|
||||||
|
from pecan import rest
|
||||||
|
|
||||||
|
|
||||||
|
class LoginController(rest.RestController):
|
||||||
|
|
||||||
|
@pecan.expose()
|
||||||
|
def post(self):
|
||||||
|
"""Retrieve an auth token."""
|
||||||
|
|
||||||
|
access = {
|
||||||
|
"access": {
|
||||||
|
"token": {
|
||||||
|
"issued_at": "2014-01-30T15:30:58.819584",
|
||||||
|
"expires": "2014-01-31T15:30:58Z",
|
||||||
|
"id": "aaaaa-bbbbb-ccccc-dddd",
|
||||||
|
"tenant": {
|
||||||
|
"description": "Hey!",
|
||||||
|
"enabled": True,
|
||||||
|
"id": "fc394f2ab2df4114bde39905f800dc57",
|
||||||
|
"name": "demo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.dumps(access)
|
@ -26,8 +26,29 @@ class HelloController(rest.RestController):
|
|||||||
"""Says hello."""
|
"""Says hello."""
|
||||||
return "Hello World!"
|
return "Hello World!"
|
||||||
|
|
||||||
|
@pecan.expose()
|
||||||
|
def _lookup(self, *remainder):
|
||||||
|
return HelloSubController(), remainder
|
||||||
|
|
||||||
|
|
||||||
|
class AdminController(rest.RestController):
|
||||||
|
|
||||||
|
@pecan.expose()
|
||||||
|
@util.policy_enforce(['admin'])
|
||||||
|
def get(self):
|
||||||
|
"""Says hello to the admin."""
|
||||||
|
return "Hello, dear admin!"
|
||||||
|
|
||||||
|
|
||||||
|
class DeniedController(rest.RestController):
|
||||||
|
|
||||||
@pecan.expose()
|
@pecan.expose()
|
||||||
@util.policy_enforce(['break'])
|
@util.policy_enforce(['break'])
|
||||||
def post(self):
|
def get(self):
|
||||||
"""What are you trying to post dude?"""
|
"""This should be denied."""
|
||||||
return "Looks like policies are not working."
|
return "Looks like policies are not working."
|
||||||
|
|
||||||
|
|
||||||
|
class HelloSubController(rest.RestController):
|
||||||
|
admin = AdminController()
|
||||||
|
denied = DeniedController()
|
||||||
|
0
surveil/tests/api/controllers/v2/auth/__init__.py
Normal file
0
surveil/tests/api/controllers/v2/auth/__init__.py
Normal file
51
surveil/tests/api/controllers/v2/auth/test_auth.py
Normal file
51
surveil/tests/api/controllers/v2/auth/test_auth.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Copyright 2015 - Savoir-Faire Linux 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 json
|
||||||
|
|
||||||
|
from surveil.tests.api import functionalTest
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthController(functionalTest.FunctionalTest):
|
||||||
|
|
||||||
|
def test_auth_login(self):
|
||||||
|
auth = {
|
||||||
|
"auth": {
|
||||||
|
"tenantName": "demo",
|
||||||
|
"passwordCredentials": {
|
||||||
|
"username": "demo",
|
||||||
|
"password": "secretsecret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.app.post_json('/v2/auth/login', params=auth)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"access": {
|
||||||
|
"token": {
|
||||||
|
"issued_at": "2014-01-30T15:30:58.819584",
|
||||||
|
"expires": "2014-01-31T15:30:58Z",
|
||||||
|
"id": "aaaaa-bbbbb-ccccc-dddd",
|
||||||
|
"tenant": {
|
||||||
|
"enabled": True,
|
||||||
|
"description": "Hey!",
|
||||||
|
"name": "demo",
|
||||||
|
"id": "fc394f2ab2df4114bde39905f800dc57"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(json.loads(response.body.decode()), expected)
|
@ -24,4 +24,4 @@ class TestHelloController(functionalTest.FunctionalTest):
|
|||||||
|
|
||||||
def test_post_policy_forbidden(self):
|
def test_post_policy_forbidden(self):
|
||||||
with self.assertRaisesRegexp(Exception, '403 Forbidden'):
|
with self.assertRaisesRegexp(Exception, '403 Forbidden'):
|
||||||
self.app.post('/v2/hello')
|
self.app.get('/v2/hello/denied')
|
||||||
|
Loading…
x
Reference in New Issue
Block a user