Merge "Improve keycloak auth module"

This commit is contained in:
Jenkins 2017-07-25 10:00:54 +00:00 committed by Gerrit Code Review
commit 31fcb50f9d
3 changed files with 209 additions and 26 deletions

View File

@ -12,12 +12,15 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import jwt
from oslo_config import cfg from oslo_config import cfg
from oslo_log import log as logging from oslo_log import log as logging
import pprint import pprint
import requests import requests
from mistral._i18n import _
from mistral import auth from mistral import auth
from mistral import exceptions as exc
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -28,7 +31,28 @@ CONF = cfg.CONF
class KeycloakAuthHandler(auth.AuthHandler): class KeycloakAuthHandler(auth.AuthHandler):
def authenticate(self, req): def authenticate(self, req):
realm_name = req.headers.get('X-Project-Id')
if 'X-Auth-Token' not in req.headers:
msg = _("Auth token must be provided in 'X-Auth-Token' header.")
LOG.error(msg)
raise exc.UnauthorizedException(message=msg)
access_token = req.headers.get('X-Auth-Token')
try:
decoded = jwt.decode(access_token, algorithms=['RS256'],
verify=False)
except Exception:
msg = _("Token can't be decoded because of wrong format.")
LOG.error(msg)
raise exc.UnauthorizedException(message=msg)
# Get user realm from parsed token
# Format is "iss": "http://<host>:<port>/auth/realms/<realm_name>",
__, __, realm_name = decoded['iss'].strip().rpartition('/realms/')
# Get roles from from parsed token
roles = ','.join(decoded['realm_access']['roles']) \
if 'realm_access' in decoded else ''
# NOTE(rakhmerov): There's a special endpoint for introspecting # NOTE(rakhmerov): There's a special endpoint for introspecting
# access tokens described in OpenID Connect specification but it's # access tokens described in OpenID Connect specification but it's
@ -40,13 +64,17 @@ class KeycloakAuthHandler(auth.AuthHandler):
(CONF.keycloak_oidc.auth_url, realm_name) (CONF.keycloak_oidc.auth_url, realm_name)
) )
access_token = req.headers.get('X-Auth-Token') try:
resp = requests.get(
resp = requests.get( user_info_endpoint,
user_info_endpoint, headers={"Authorization": "Bearer %s" % access_token},
headers={"Authorization": "Bearer %s" % access_token}, verify=not CONF.keycloak_oidc.insecure
verify=not CONF.keycloak_oidc.insecure )
) except requests.ConnectionError:
msg = _("Can't connect to keycloak server with address '%s'."
) % CONF.keycloak_oidc.auth_url
LOG.error(msg)
raise exc.MistralException(message=msg)
resp.raise_for_status() resp.raise_for_status()
@ -54,3 +82,7 @@ class KeycloakAuthHandler(auth.AuthHandler):
"HTTP response from OIDC provider: %s" % "HTTP response from OIDC provider: %s" %
pprint.pformat(resp.json()) pprint.pformat(resp.json())
) )
req.headers["X-Identity-Status"] = "Confirmed"
req.headers["X-Project-Id"] = realm_name
req.headers["X-Roles"] = roles

View File

@ -1,4 +1,4 @@
# Copyright 2013 - Mirantis, Inc. # Copyright 2017 - Nokia Networks
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -17,10 +17,14 @@ import mock
from oslo_config import cfg from oslo_config import cfg
import pecan import pecan
import pecan.testing import pecan.testing
import requests
import requests_mock import requests_mock
import webob
from mistral.auth import keycloak
from mistral.db.v2 import api as db_api from mistral.db.v2 import api as db_api
from mistral.db.v2.sqlalchemy import models from mistral.db.v2.sqlalchemy import models
from mistral import exceptions as exc
from mistral.services import periodic 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
@ -29,12 +33,10 @@ from mistral.tests.unit.mstrlfixtures import policy_fixtures
WF_DEFINITION = """ WF_DEFINITION = """
--- ---
version: '2.0' version: '2.0'
flow: flow:
type: direct type: direct
input: input:
- param1 - param1
tasks: tasks:
task1: task1:
action: std.echo output="Hi" action: std.echo output="Hi"
@ -80,9 +82,112 @@ USER_CLAIMS = {
} }
class TestKeyCloakOIDCAuth(base.DbTestCase): class TestKeyCloakOIDCAuth(base.BaseTest):
def setUp(self): def setUp(self):
super(TestKeyCloakOIDCAuth, self).setUp() super(TestKeyCloakOIDCAuth, self).setUp()
cfg.CONF.set_default('auth_url', AUTH_URL, group='keycloak_oidc')
self.auth_handler = keycloak.KeycloakAuthHandler()
def _build_request(self, token):
req = webob.Request.blank("/")
req.headers["x-auth-token"] = token
req.get_response = lambda app: None
return req
@requests_mock.Mocker()
def test_header_parsing(self, req_mock):
token = {
"iss": "http://localhost:8080/auth/realms/my_realm",
"realm_access": {
"roles": ["role1", "role2"]
}
}
# Imitate successful response from KeyCloak with user claims.
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
req = self._build_request(token)
with mock.patch("jwt.decode", return_value=token):
self.auth_handler.authenticate(req)
self.assertEqual("Confirmed", req.headers["X-Identity-Status"])
self.assertEqual("my_realm", req.headers["X-Project-Id"])
self.assertEqual("role1,role2", req.headers["X-Roles"])
self.assertEqual(1, req_mock.call_count)
def test_no_auth_token(self):
req = webob.Request.blank("/")
self.assertRaises(
exc.UnauthorizedException,
self.auth_handler.authenticate,
req
)
@requests_mock.Mocker()
def test_no_realm_roles(self, req_mock):
token = {
"iss": "http://localhost:8080/auth/realms/my_realm",
}
# Imitate successful response from KeyCloak with user claims.
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
req = self._build_request(token)
with mock.patch("jwt.decode", return_value=token):
self.auth_handler.authenticate(req)
self.assertEqual("Confirmed", req.headers["X-Identity-Status"])
self.assertEqual("my_realm", req.headers["X-Project-Id"])
self.assertEqual("", req.headers["X-Roles"])
def test_wrong_token_format(self):
req = self._build_request(token="WRONG_FORMAT_TOKEN")
self.assertRaises(
exc.UnauthorizedException,
self.auth_handler.authenticate,
req
)
@requests_mock.Mocker()
def test_server_unauthorized(self, req_mock):
token = {
"iss": "http://localhost:8080/auth/realms/my_realm",
}
# Imitate failure response from KeyCloak.
req_mock.get(
USER_INFO_ENDPOINT,
status_code=401,
reason='Access token is invalid'
)
req = self._build_request(token)
with mock.patch("jwt.decode", return_value=token):
try:
self.auth_handler.authenticate(req)
except requests.exceptions.HTTPError as e:
self.assertIn(
"401 Client Error: Access token is invalid for url",
str(e)
)
else:
raise Exception("Test is broken")
@requests_mock.Mocker()
def test_connection_error(self, req_mock):
token = {
"iss": "http://localhost:8080/auth/realms/my_realm",
}
req_mock.get(USER_INFO_ENDPOINT, exc=requests.ConnectionError)
req = self._build_request(token)
with mock.patch("jwt.decode", return_value=token):
self.assertRaises(
exc.MistralException,
self.auth_handler.authenticate,
req
)
class TestKeyCloakOIDCAuthScenarios(base.DbTestCase):
def setUp(self):
super(TestKeyCloakOIDCAuthScenarios, self).setUp()
cfg.CONF.set_default('auth_enable', True, group='pecan') cfg.CONF.set_default('auth_enable', True, group='pecan')
cfg.CONF.set_default('auth_type', 'keycloak-oidc') cfg.CONF.set_default('auth_type', 'keycloak-oidc')
@ -130,29 +235,38 @@ class TestKeyCloakOIDCAuth(base.DbTestCase):
# Imitate successful response from KeyCloak with user claims. # Imitate successful response from KeyCloak with user claims.
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS) req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
headers = { token = {
'X-Auth-Token': 'cvbcvbasrtqlwkjasdfasdf', "iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
'X-Project-Id': REALM_NAME "realm_access": {
"roles": ["role1", "role2"]
}
} }
resp = self.app.get('/v2/workflows/123', headers=headers) headers = {
'X-Auth-Token': str(token)
}
with mock.patch("jwt.decode", return_value=token):
resp = self.app.get('/v2/workflows/123', headers=headers)
self.assertEqual(200, resp.status_code) self.assertEqual(200, resp.status_code)
self.assertDictEqual(WF, resp.json) self.assertDictEqual(WF, resp.json)
@requests_mock.Mocker() @mock.patch("requests.get")
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF) @mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
def test_get_workflow_failed_auth(self, req_mock): def test_get_workflow_invalid_token_format(self, req_mock):
# Imitate failure response from KeyCloak. # Imitate successful response from KeyCloak with user claims.
req_mock.get( req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
USER_INFO_ENDPOINT,
status_code=401, token = {
reason='Access token is invalid' "iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
) "realm_access": {
"roles": ["role1", "role2"]
}
}
headers = { headers = {
'X-Auth-Token': 'cvbcvbasrtqlwkjasdfasdf', 'X-Auth-Token': str(token)
'X-Project-Id': REALM_NAME
} }
resp = self.app.get( resp = self.app.get(
@ -161,6 +275,42 @@ class TestKeyCloakOIDCAuth(base.DbTestCase):
expect_errors=True 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(
"Token can't be decoded because of wrong format.",
resp.text
)
@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'
)
token = {
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
"realm_access": {
"roles": ["role1", "role2"]
}
}
headers = {
'X-Auth-Token': str(token)
}
with mock.patch("jwt.decode", return_value=token):
resp = self.app.get(
'/v2/workflows/123',
headers=headers,
expect_errors=True
)
self.assertEqual(401, resp.status_code) self.assertEqual(401, resp.status_code)
self.assertEqual('401 Unauthorized', resp.status) self.assertEqual('401 Unauthorized', resp.status)
self.assertIn('Failed to validate access token', resp.text) self.assertIn('Failed to validate access token', resp.text)

View File

@ -49,6 +49,7 @@ python-troveclient>=2.2.0 # Apache-2.0
python-ironicclient>=1.14.0 # Apache-2.0 python-ironicclient>=1.14.0 # Apache-2.0
python-ironic-inspector-client>=1.5.0 # Apache-2.0 python-ironic-inspector-client>=1.5.0 # Apache-2.0
python-zaqarclient>=1.0.0 # Apache-2.0 python-zaqarclient>=1.0.0 # Apache-2.0
PyJWT>=1.0.1 # MIT
PyYAML>=3.10.0 # MIT PyYAML>=3.10.0 # MIT
requests>=2.14.2 # Apache-2.0 requests>=2.14.2 # Apache-2.0
tenacity>=3.2.1 # Apache-2.0 tenacity>=3.2.1 # Apache-2.0