Add KeyCloak OpenID Connect server-side authentication

* Changed AuthHook for Pecan that implements token validation
* Added another config option to disable SSL verification for
  KeyCloak access tokens
* Added unit tests for successful and failed KeyCloak
  authentication that use request_mock library
* Minor style changes

Change-Id: I87f8d54fc58f82952a4c68831547e6dab320230e
This commit is contained in:
Renat Akhmerov 2016-07-04 16:45:45 +07:00
parent d29a6dcfbb
commit 021caf873f
9 changed files with 267 additions and 19 deletions

View File

@ -27,11 +27,15 @@ _ENFORCER = None
def setup(app): def setup(app):
if cfg.CONF.pecan.auth_enable: if cfg.CONF.pecan.auth_enable and cfg.CONF.auth_type == 'keystone':
conf = dict(cfg.CONF.keystone_authtoken) conf = dict(cfg.CONF.keystone_authtoken)
# Change auth decisions of requests to the app itself. # Change auth decisions of requests to the app itself.
conf.update({'delay_auth_decision': True}) conf.update({'delay_auth_decision': True})
# NOTE(rakhmerov): Policy enforcement works only if Keystone
# authentication is enabled. No support for other authentication
# types at this point.
_ensure_enforcer_initialization() _ensure_enforcer_initialization()
return auth_token.AuthProtocol(app, conf) return auth_token.AuthProtocol(app, conf)
@ -58,6 +62,12 @@ def enforce(action, context, target=None, do_raise=True,
:return: returns True if authorized and False if not authorized and :return: returns True if authorized and False if not authorized and
do_raise is False. do_raise is False.
""" """
if cfg.CONF.auth_type != 'keystone':
# Policy enforcement is supported now only with Keystone
# authentication.
return
target_obj = { target_obj = {
'project_id': context.project_id, 'project_id': context.project_id,
'user_id': context.user_id, 'user_id': context.user_id,

View File

@ -196,6 +196,11 @@ keycloak_oidc_opts = [
cfg.StrOpt( cfg.StrOpt(
'auth_url', 'auth_url',
help='Keycloak base url (e.g. https://my.keycloak:8443/auth)' help='Keycloak base url (e.g. https://my.keycloak:8443/auth)'
),
cfg.StrOpt(
'insecure',
default=False,
help='If True, SSL/TLS certificate verification is disabled'
) )
] ]

View File

@ -15,17 +15,23 @@
import eventlet import eventlet
from keystoneclient.v3 import client as keystone_client from keystoneclient.v3 import client as keystone_client
import logging
from oslo_config import cfg from oslo_config import cfg
import oslo_messaging as messaging import oslo_messaging as messaging
from oslo_serialization import jsonutils from oslo_serialization import jsonutils
from osprofiler import profiler from osprofiler import profiler
import pecan import pecan
from pecan import hooks from pecan import hooks
import pprint
import requests
from mistral import exceptions as exc from mistral import exceptions as exc
from mistral import utils from mistral import utils
LOG = logging.getLogger(__name__)
CONF = cfg.CONF CONF = cfg.CONF
_CTX_THREAD_LOCAL_NAME = "MISTRAL_APP_CTX_THREAD_LOCAL" _CTX_THREAD_LOCAL_NAME = "MISTRAL_APP_CTX_THREAD_LOCAL"
@ -209,24 +215,16 @@ class AuthHook(hooks.PecanHook):
if state.request.path in ALLOWED_WITHOUT_AUTH: if state.request.path in ALLOWED_WITHOUT_AUTH:
return return
if CONF.pecan.auth_enable: if not CONF.pecan.auth_enable:
# Note(nmakhotkin): Since we have deferred authentication, return
# need to check for auth manually (check for corresponding
# headers according to keystonemiddleware docs.
identity_status = state.request.headers.get('X-Identity-Status')
service_identity_status = state.request.headers.get(
'X-Service-Identity-Status'
)
if (identity_status == 'Confirmed' try:
or service_identity_status == 'Confirmed'): if CONF.auth_type == 'keystone':
return authenticate_with_keystone(state.request)
elif CONF.auth_type == 'keycloak-oidc':
if state.request.headers.get('X-Auth-Token'): authenticate_with_keycloak(state.request)
msg = ("Auth token is invalid: %s" except Exception as e:
% state.request.headers['X-Auth-Token']) msg = "Failed to validate access token: %s" % str(e)
else:
msg = 'Authentication required'
pecan.abort( pecan.abort(
status_code=401, status_code=401,
@ -235,6 +233,54 @@ class AuthHook(hooks.PecanHook):
) )
def authenticate_with_keystone(req):
# Note(nmakhotkin): Since we have deferred authentication,
# need to check for auth manually (check for corresponding
# headers according to keystonemiddleware docs.
identity_status = req.headers.get('X-Identity-Status')
service_identity_status = req.headers.get('X-Service-Identity-Status')
if (identity_status == 'Confirmed' or
service_identity_status == 'Confirmed'):
return
if req.headers.get('X-Auth-Token'):
msg = 'Auth token is invalid: %s' % req.headers['X-Auth-Token']
else:
msg = 'Authentication required'
raise exc.UnauthorizedException(msg)
def authenticate_with_keycloak(req):
realm_name = req.headers.get('X-Project-Id')
# NOTE(rakhmerov): There's a special endpoint for introspecting
# access tokens described in OpenID Connect specification but it's
# available in KeyCloak starting only with version 1.8.Final so we have
# to use user info endpoint which also takes exactly one parameter
# (access token) and replies with error if token is invalid.
user_info_endpoint = (
"%s/realms/%s/protocol/openid-connect/userinfo" %
(CONF.keycloak_oidc.auth_url, realm_name)
)
access_token = req.headers.get('X-Auth-Token')
resp = requests.get(
user_info_endpoint,
headers={"Authorization": "Bearer %s" % access_token},
verify=not CONF.keycloak_oidc.insecure
)
resp.raise_for_status()
LOG.debug(
"HTTP response from OIDC provider: %s" %
pprint.pformat(resp.json())
)
class ContextHook(hooks.PecanHook): class ContextHook(hooks.PecanHook):
def before(self, state): def before(self, state):
set_ctx(context_from_headers(state.request.headers)) set_ctx(context_from_headers(state.request.headers))

View File

@ -183,3 +183,8 @@ class CoordinationException(MistralException):
class NotAllowedException(MistralException): class NotAllowedException(MistralException):
http_code = 403 http_code = 403
message = "Operation not allowed" message = "Operation not allowed"
class UnauthorizedException(MistralException):
http_code = 401
message = "Unauthorized"

View File

@ -24,7 +24,7 @@ from mistral.services import periodic
from mistral.tests.unit import base from mistral.tests.unit import base
from mistral.tests.unit.mstrlfixtures import policy_fixtures from mistral.tests.unit.mstrlfixtures import policy_fixtures
# Disable authentication for functional tests. # Disable authentication for API tests.
cfg.CONF.set_default('auth_enable', False, group='pecan') cfg.CONF.set_default('auth_enable', False, group='pecan')

View File

@ -22,7 +22,9 @@ class PolicyTestCase(base.BaseTest):
"""Tests whether the configuration of the policy engine is corect.""" """Tests whether the configuration of the policy engine is corect."""
def setUp(self): def setUp(self):
super(PolicyTestCase, self).setUp() super(PolicyTestCase, self).setUp()
self.policy = self.useFixture(policy_fixtures.PolicyFixture()) self.policy = self.useFixture(policy_fixtures.PolicyFixture())
rules = { rules = {
"admin_only": "is_admin:True", "admin_only": "is_admin:True",
"admin_or_owner": "is_admin:True or project_id:%(project_id)s", "admin_or_owner": "is_admin:True or project_id:%(project_id)s",
@ -30,16 +32,19 @@ class PolicyTestCase(base.BaseTest):
"example:admin": "rule:admin_only", "example:admin": "rule:admin_only",
"example:admin_or_owner": "rule:admin_or_owner" "example:admin_or_owner": "rule:admin_or_owner"
} }
self.policy.set_rules(rules) self.policy.set_rules(rules)
def test_admin_api_allowed(self): def test_admin_api_allowed(self):
auth_ctx = base.get_context(default=True, admin=True) auth_ctx = base.get_context(default=True, admin=True)
self.assertTrue( self.assertTrue(
acl.enforce('example:admin', auth_ctx, auth_ctx.to_dict()) acl.enforce('example:admin', auth_ctx, auth_ctx.to_dict())
) )
def test_admin_api_disallowed(self): def test_admin_api_disallowed(self):
auth_ctx = base.get_context(default=True) auth_ctx = base.get_context(default=True)
self.assertRaises( self.assertRaises(
exc.NotAllowedException, exc.NotAllowedException,
acl.enforce, acl.enforce,
@ -50,6 +55,7 @@ class PolicyTestCase(base.BaseTest):
def test_admin_or_owner_api_allowed(self): def test_admin_or_owner_api_allowed(self):
auth_ctx = base.get_context(default=True) auth_ctx = base.get_context(default=True)
self.assertTrue( self.assertTrue(
acl.enforce('example:admin_or_owner', auth_ctx, auth_ctx.to_dict()) acl.enforce('example:admin_or_owner', auth_ctx, auth_ctx.to_dict())
) )
@ -57,6 +63,7 @@ class PolicyTestCase(base.BaseTest):
def test_admin_or_owner_api_disallowed(self): def test_admin_or_owner_api_disallowed(self):
auth_ctx = base.get_context(default=True) auth_ctx = base.get_context(default=True)
target = {'project_id': 'another'} target = {'project_id': 'another'}
self.assertRaises( self.assertRaises(
exc.NotAllowedException, exc.NotAllowedException,
acl.enforce, acl.enforce,

View File

@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
#
# Copyright 2013 - Mirantis, 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 datetime
import mock
from oslo_config import cfg
import pecan
import pecan.testing
import requests_mock
from mistral.db.v2 import api as db_api
from mistral.db.v2.sqlalchemy import models
from mistral.services import periodic
from mistral.tests.unit import base
from mistral.tests.unit.mstrlfixtures import policy_fixtures
WF_DEFINITION = """
---
version: '2.0'
flow:
type: direct
input:
- param1
tasks:
task1:
action: std.echo output="Hi"
"""
WF_DB = models.WorkflowDefinition(
id='123e4567-e89b-12d3-a456-426655440000',
name='flow',
definition=WF_DEFINITION,
created_at=datetime.datetime(1970, 1, 1),
updated_at=datetime.datetime(1970, 1, 1),
spec={'input': ['param1']}
)
WF = {
'id': '123e4567-e89b-12d3-a456-426655440000',
'name': 'flow',
'definition': WF_DEFINITION,
'created_at': '1970-01-01 00:00:00',
'updated_at': '1970-01-01 00:00:00',
'input': 'param1'
}
MOCK_WF = mock.MagicMock(return_value=WF_DB)
# Set up config options.
AUTH_URL = 'https://my.keycloak.com:8443/auth'
REALM_NAME = 'my_realm'
USER_INFO_ENDPOINT = (
"%s/realms/%s/protocol/openid-connect/userinfo" % (AUTH_URL, REALM_NAME)
)
USER_CLAIMS = {
"sub": "248289761001",
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"preferred_username": "j.doe",
"email": "janedoe@example.com",
"picture": "http://example.com/janedoe/me.jpg"
}
class TestKeyCloakOIDCAuth(base.DbTestCase):
def setUp(self):
super(TestKeyCloakOIDCAuth, self).setUp()
cfg.CONF.set_default('auth_enable', True, group='pecan')
cfg.CONF.set_default('auth_type', 'keycloak-oidc')
cfg.CONF.set_default('auth_url', AUTH_URL, group='keycloak_oidc')
pecan_opts = cfg.CONF.pecan
self.app = pecan.testing.load_test_app({
'app': {
'root': pecan_opts.root,
'modules': pecan_opts.modules,
'debug': pecan_opts.debug,
'auth_enable': True,
'disable_cron_trigger_thread': True
}
})
self.addCleanup(pecan.set_config, {}, overwrite=True)
self.addCleanup(
cfg.CONF.set_default,
'auth_enable',
False,
group='pecan'
)
self.addCleanup(cfg.CONF.set_default, 'auth_type', 'keystone')
# Adding cron trigger thread clean up explicitly in case if
# new tests will provide an alternative configuration for pecan
# application.
self.addCleanup(periodic.stop_all_periodic_tasks)
# Make sure the api get the correct context.
self.patch_ctx = mock.patch('mistral.context.context_from_headers')
self.mock_ctx = self.patch_ctx.start()
self.mock_ctx.return_value = self.ctx
self.addCleanup(self.patch_ctx.stop)
self.policy = self.useFixture(policy_fixtures.PolicyFixture())
@requests_mock.Mocker()
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
def test_get_workflow_success_auth(self, req_mock):
# Imitate successful response from KeyCloak with user claims.
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
headers = {
'X-Auth-Token': 'cvbcvbasrtqlwkjasdfasdf',
'X-Project-Id': REALM_NAME
}
resp = self.app.get('/v2/workflows/123', headers=headers)
self.assertEqual(200, resp.status_code)
self.assertDictEqual(WF, resp.json)
@requests_mock.Mocker()
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
def test_get_workflow_failed_auth(self, req_mock):
# Imitate failure response from KeyCloak.
req_mock.get(
USER_INFO_ENDPOINT,
status_code=401,
reason='Access token is invalid'
)
headers = {
'X-Auth-Token': 'cvbcvbasrtqlwkjasdfasdf',
'X-Project-Id': REALM_NAME
}
resp = self.app.get(
'/v2/workflows/123',
headers=headers,
expect_errors=True
)
self.assertEqual(401, resp.status_code)
self.assertEqual('401 Unauthorized', resp.status)
self.assertIn('Failed to validate access token', resp.text)
self.assertIn('Access token is invalid', resp.text)

View File

@ -28,23 +28,30 @@ class PolicyFixture(fixtures.Fixture):
def setUp(self): def setUp(self):
super(PolicyFixture, self).setUp() super(PolicyFixture, self).setUp()
self.policy_dir = self.useFixture(fixtures.TempDir()) self.policy_dir = self.useFixture(fixtures.TempDir())
self.policy_file_name = os.path.join( self.policy_file_name = os.path.join(
self.policy_dir.path, self.policy_dir.path,
'policy.json' 'policy.json'
) )
with open(self.policy_file_name, 'w') as policy_file: with open(self.policy_file_name, 'w') as policy_file:
policy_file.write(fake_policy.policy_data) policy_file.write(fake_policy.policy_data)
policy_opts.set_defaults(cfg.CONF) policy_opts.set_defaults(cfg.CONF)
cfg.CONF.set_override( cfg.CONF.set_override(
'policy_file', 'policy_file',
self.policy_file_name, self.policy_file_name,
'oslo_policy' 'oslo_policy'
) )
acl._ENFORCER = oslo_policy.Enforcer(cfg.CONF) acl._ENFORCER = oslo_policy.Enforcer(cfg.CONF)
acl._ENFORCER.load_rules() acl._ENFORCER.load_rules()
self.addCleanup(acl._ENFORCER.clear) self.addCleanup(acl._ENFORCER.clear)
def set_rules(self, rules): def set_rules(self, rules):
policy = acl._ENFORCER policy = acl._ENFORCER
policy.set_rules(oslo_policy.Rules.from_dict(rules)) policy.set_rules(oslo_policy.Rules.from_dict(rules))

View File

@ -9,6 +9,7 @@ oslosphinx!=3.4.0,>=2.5.0 # Apache-2.0
oslotest>=1.10.0 # Apache-2.0 oslotest>=1.10.0 # Apache-2.0
pyflakes==0.8.1 # MIT pyflakes==0.8.1 # MIT
pylint==1.4.5 # GPLv2 pylint==1.4.5 # GPLv2
requests-mock>=1.0 # Apache-2.0
sphinx!=1.3b1,<1.3,>=1.2.1 # BSD sphinx!=1.3b1,<1.3,>=1.2.1 # BSD
sphinxcontrib-httpdomain # BSD sphinxcontrib-httpdomain # BSD
sphinxcontrib-pecanwsme>=0.8 # Apache-2.0 sphinxcontrib-pecanwsme>=0.8 # Apache-2.0