Fix keycloak authentication

* Implement offline access token validation using Keycloak public key.

Closes-bug: #1857871
Change-Id: I0eecec4b4e64381cac005622b16c6d9e4bed4df6
This commit is contained in:
Renat Akhmerov 2019-12-27 16:20:19 +07:00
parent fef538cce7
commit c888a46ccc
6 changed files with 233 additions and 104 deletions

View File

@ -102,7 +102,7 @@ pycparser==2.18
pyflakes==0.8.1
Pygments==2.2.0
pyinotify==0.9.6
PyJWT==1.0.1
PyJWT==1.5
pyOpenSSL==17.1.0
pyparsing==2.1.0
pyperclip==1.5.27

View File

@ -14,7 +14,11 @@
import os
from cachetools import cached
from cachetools import LRUCache
import json
import jwt
from jwt import algorithms as jwt_algos
from oslo_config import cfg
from oslo_log import log as logging
import pprint
@ -32,31 +36,34 @@ CONF = cfg.CONF
class KeycloakAuthHandler(auth.AuthHandler):
def authenticate(self, req):
certfile = CONF.keycloak_oidc.certfile
keyfile = CONF.keycloak_oidc.keyfile
cafile = CONF.keycloak_oidc.cafile or self.get_system_ca_file()
insecure = CONF.keycloak_oidc.insecure
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)
decoded = jwt.decode(
access_token,
algorithms=['RS256'],
verify=False
)
except Exception as e:
msg = _("Token can't be decoded because of wrong format %s")\
% str(e)
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/')
audience = decoded.get('aud')
# Get roles from parsed token
roles = ','.join(decoded['realm_access']['roles']) \
@ -70,46 +77,31 @@ class KeycloakAuthHandler(auth.AuthHandler):
user_info_endpoint_url = CONF.keycloak_oidc.user_info_endpoint_url
if user_info_endpoint_url.startswith(('http://', 'https://')):
user_info_endpoint = user_info_endpoint_url
else:
user_info_endpoint = (
("%s" + user_info_endpoint_url) %
(CONF.keycloak_oidc.auth_url, realm_name))
verify = None
if urllib.parse.urlparse(user_info_endpoint).scheme == "https":
verify = False if insecure else cafile
cert = (certfile, keyfile) if certfile and keyfile else None
try:
resp = requests.get(
user_info_endpoint,
headers={"Authorization": "Bearer %s" % access_token},
verify=verify,
cert=cert
self.send_request_to_auth_server(
url=user_info_endpoint_url,
access_token=access_token
)
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)
if resp.status_code == 401:
LOG.warning("HTTP response from OIDC provider:"
" [%s] with WWW-Authenticate: [%s]",
pprint.pformat(resp.text),
resp.headers.get("WWW-Authenticate"))
else:
LOG.debug("HTTP response from OIDC provider: %s",
pprint.pformat(resp.text))
public_key = self.get_public_key(realm_name)
resp.raise_for_status()
keycloak_iss = None
LOG.debug(
"HTTP response from OIDC provider: %s",
pprint.pformat(resp.json())
)
try:
if CONF.keycloak_oidc.keycloak_iss:
keycloak_iss = CONF.keycloak_oidc.keycloak_iss % realm_name
jwt.decode(
access_token,
public_key,
audience=audience,
issuer=keycloak_iss,
algorithms=['RS256'],
verify=True
)
except Exception:
LOG.exception('The request access token is invalid.')
raise exc.UnauthorizedException()
req.headers["X-Identity-Status"] = "Confirmed"
req.headers["X-Project-Id"] = realm_name
@ -119,16 +111,95 @@ class KeycloakAuthHandler(auth.AuthHandler):
def get_system_ca_file():
"""Return path to system default CA file."""
# Standard CA file locations for Debian/Ubuntu, RedHat/Fedora,
# Suse, FreeBSD/OpenBSD, MacOSX, and the bundled ca
ca_path = ['/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/ca-bundle.pem',
'/etc/ssl/cert.pem',
'/System/Library/OpenSSL/certs/cacert.pem',
requests.certs.where()]
# Suse, FreeBSD/OpenBSD, MacOSX, and the bundled ca.
ca_path = [
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/ca-bundle.pem',
'/etc/ssl/cert.pem',
'/System/Library/OpenSSL/certs/cacert.pem',
requests.certs.where()
]
for ca in ca_path:
LOG.debug("Looking for ca file %s", ca)
if os.path.exists(ca):
LOG.debug("Using ca file %s", ca)
return ca
LOG.warning("System ca file could not be found.")
@cached(LRUCache(maxsize=32))
def get_public_key(self, realm_name):
keycloak_key_url = (
CONF.keycloak_oidc.auth_url +
CONF.keycloak_oidc.public_cert_url % realm_name
)
response_json = self.send_request_to_auth_server(keycloak_key_url)
keys = response_json.get('keys')
if not keys:
raise exc.MistralException(
'Unexpected response structure from the keycloak server.'
)
public_key = jwt_algos.RSAAlgorithm.from_jwk(
json.dumps(keys[0])
)
return public_key
def send_request_to_auth_server(self, url, access_token=None):
certfile = CONF.keycloak_oidc.certfile
keyfile = CONF.keycloak_oidc.keyfile
cafile = CONF.keycloak_oidc.cafile or self.get_system_ca_file()
insecure = CONF.keycloak_oidc.insecure
verify = None
if urllib.parse.urlparse(url).scheme == "https":
verify = False if insecure else cafile
cert = (certfile, keyfile) if certfile and keyfile else None
headers = {}
if access_token:
headers["Authorization"] = "Bearer %s" % access_token
try:
resp = requests.get(
url,
headers=headers,
verify=verify,
cert=cert
)
except requests.ConnectionError:
msg = _(
"Can't connect to the keycloak server with address '%s'."
) % url
LOG.exception(msg)
raise exc.MistralException(message=msg)
if resp.status_code == 401:
LOG.warning(
"HTTP response from OIDC provider:"
" [%s] with WWW-Authenticate: [%s]",
pprint.pformat(resp.text),
resp.headers.get("WWW-Authenticate")
)
else:
LOG.debug(
"HTTP response from the OIDC provider: %s",
pprint.pformat(resp.json())
)
resp.raise_for_status()
return resp.json()

View File

@ -546,6 +546,16 @@ keycloak_oidc_opts = [
default='/realms/%s/protocol/openid-connect/userinfo',
help='Endpoint against which authorization will be performed'
),
cfg.StrOpt(
'public_cert_url',
default="/realms/%s/protocol/openid-connect/certs",
help="URL to get the public key for a particular realm"
),
cfg.StrOpt(
'keycloak_iss',
help="Keycloak issuer(iss) url. "
"Example: https://ip_add:port/auth/realms/%s"
)
]
openstack_actions_opts = [

View File

@ -19,6 +19,7 @@ from mistral_lib.actions import context as lib_ctx
from mistral_lib import serialization
from oslo_config import cfg
from oslo_context import context as oslo_context
from oslo_log import log as logging
import oslo_messaging as messaging
from oslo_serialization import jsonutils
from osprofiler import profiler
@ -29,7 +30,11 @@ from mistral import auth
from mistral import exceptions as exc
from mistral_lib import utils
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
_CTX_THREAD_LOCAL_NAME = "MISTRAL_APP_CTX_THREAD_LOCAL"
ALLOWED_WITHOUT_AUTH = ['/', '/v2/', '/workflowv2/', '/workflowv2/v2/']
@ -249,6 +254,8 @@ class AuthHook(hooks.PecanHook):
except Exception as e:
msg = "Failed to validate access token: %s" % str(e)
LOG.exception(msg)
pecan.abort(
status_code=401,
detail=msg,

View File

@ -30,6 +30,18 @@ from mistral.services import periodic
from mistral.tests.unit import base
from mistral.tests.unit.mstrlfixtures import policy_fixtures
KEYCLOAK_JSON = {
"keys": [
{
"kid": "FJ86GcF3jTbNLOco4NvZkUCIUmfYCqoqtOQeMfbhNlE",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "q1awrk7QK24Gmcy9Yb4dMbS-ZnO6",
"e": "AQAB"
}
]
}
WF_DEFINITION = """
---
@ -86,7 +98,6 @@ WWW_AUTHENTICATE_HEADER = {'WWW-Authenticate': 'unauthorized reason is ...'}
class TestKeyCloakOIDCAuth(base.BaseTest):
def setUp(self):
super(TestKeyCloakOIDCAuth, self).setUp()
@ -102,8 +113,14 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
return req
@requests_mock.Mocker()
def test_header_parsing(self, req_mock):
@mock.patch("requests.get")
def test_header_parsing(self, mocked_get):
self.override_config(
'user_info_endpoint_url',
'https://127.0.0.1:9080',
'keycloak_oidc'
)
token = {
"iss": "http://localhost:8080/auth/realms/my_realm",
"realm_access": {
@ -111,8 +128,11 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
}
}
# Imitate successful response from KeyCloak with user claims.
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
mocked_resp = mock.Mock()
mocked_resp.status_code = 200
mocked_resp.json.return_value = KEYCLOAK_JSON
mocked_get.return_value = mocked_resp
req = self._build_request(token)
@ -122,7 +142,7 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
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)
self.assertEqual(1, mocked_get.call_count)
def test_no_auth_token(self):
req = webob.Request.blank("/")
@ -133,12 +153,19 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
req
)
@requests_mock.Mocker()
def test_no_realm_roles(self, req_mock):
token = {"iss": "http://localhost:8080/auth/realms/my_realm"}
@mock.patch("requests.get")
def test_no_realm_roles(self, mocked_get):
token = {
"aud": "openstack",
"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)
mocked_resp = mock.Mock()
mocked_resp.status_code = 200
mocked_resp.json.return_value = KEYCLOAK_JSON
mocked_get.return_value = mocked_resp
req = self._build_request(token)
@ -160,13 +187,20 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
@requests_mock.Mocker()
def test_server_unauthorized(self, req_mock):
self.override_config(
'user_info_endpoint_url',
'https://127.0.0.1:9080',
'keycloak_oidc'
)
token = {
"aud": "openstack",
"iss": "http://localhost:8080/auth/realms/my_realm",
}
# Imitate failure response from KeyCloak.
req_mock.get(
USER_INFO_ENDPOINT,
'https://127.0.0.1:9080',
status_code=401,
reason='Access token is invalid',
headers=WWW_AUTHENTICATE_HEADER
@ -186,15 +220,20 @@ class TestKeyCloakOIDCAuth(base.BaseTest):
'unauthorized reason is ...',
e.response.headers.get('WWW-Authenticate')
)
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"}
@mock.patch("requests.get")
def test_connection_error(self, mocked_get):
token = {
"aud": "openstack",
"iss": "http://localhost:8080/auth/realms/my_realm",
"realm_access": {
"roles": ["role1", "role2"]
}
}
req_mock.get(USER_INFO_ENDPOINT, exc=requests.ConnectionError)
mocked_get.side_effect = requests.ConnectionError
req = self._build_request(token)
@ -235,11 +274,15 @@ class TestKeyCloakOIDCAuthScenarios(base.DbTestCase):
self.policy = self.useFixture(policy_fixtures.PolicyFixture())
@requests_mock.Mocker()
@mock.patch("requests.get")
@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)
def test_get_workflow_success_auth(self, mocked_get):
mocked_resp = mock.Mock()
mocked_resp.status_code = 200
mocked_resp.json.return_value = KEYCLOAK_JSON
mocked_get.return_value = mocked_resp
token = {
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
@ -258,19 +301,13 @@ class TestKeyCloakOIDCAuthScenarios(base.DbTestCase):
@mock.patch("requests.get")
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
def test_get_workflow_invalid_token_format(self, req_mock):
# Imitate successful response from KeyCloak with user claims.
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
token = {
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
"realm_access": {
"roles": ["role1", "role2"]
}
}
def test_get_workflow_invalid_token_format(self, mocked_get):
token = 'WRONG_FORMAT_TOKEN'
headers = {'X-Auth-Token': str(token)}
# We don't mock jwt.decode so the test must fail.
resp = self.app.get(
'/v2/workflows/123',
headers=headers,
@ -285,18 +322,18 @@ class TestKeyCloakOIDCAuthScenarios(base.DbTestCase):
resp.text
)
@requests_mock.Mocker()
@mock.patch("requests.get")
@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'
)
def test_get_workflow_failed_auth(self, mocked_get):
mocked_resp = mock.Mock()
mocked_resp.status_code = 200
mocked_resp.json.return_value = KEYCLOAK_JSON
mocked_get.return_value = mocked_resp
# A token without an issuer (iss).
token = {
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
"realm_access": {
"roles": ["role1", "role2"]
}
@ -313,12 +350,11 @@ class TestKeyCloakOIDCAuthScenarios(base.DbTestCase):
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)
self.assertIn("Failed to validate access token: 'iss'", resp.text)
class TestKeyCloakOIDCAuthApp(base.DbTestCase):
"""Test that Keycloak auth params were successfully passed to Context"""
"""Test that Keycloak auth params get passed to the security context."""
def setUp(self):
super(TestKeyCloakOIDCAuthApp, self).setUp()
@ -339,10 +375,15 @@ class TestKeyCloakOIDCAuthApp(base.DbTestCase):
self.policy = self.useFixture(policy_fixtures.PolicyFixture())
@requests_mock.Mocker()
@mock.patch("requests.get")
@mock.patch.object(db_api, 'get_workflow_definition', MOCK_WF)
def test_params_transition(self, req_mock):
req_mock.get(USER_INFO_ENDPOINT, json=USER_CLAIMS)
def test_params_transition(self, mocked_get):
mocked_resp = mock.Mock()
mocked_resp.status_code = 200
mocked_resp.json.return_value = KEYCLOAK_JSON
mocked_get.return_value = mocked_resp
token = {
"iss": "http://localhost:8080/auth/realms/%s" % REALM_NAME,
@ -351,22 +392,22 @@ class TestKeyCloakOIDCAuthApp(base.DbTestCase):
}
}
headers = {
'X-Auth-Token': str(token)
}
headers = {'X-Auth-Token': str(token)}
with mock.patch("jwt.decode", return_value=token):
with mock.patch("mistral.context.set_ctx") as mocked_set_cxt:
self.app.get('/v2/workflows/123', headers=headers)
calls = mocked_set_cxt.call_args_list
self.assertEqual(2, len(calls))
# First positional argument of the first call ('before')
# First positional argument of the first call ('before').
ctx = calls[0][0][0]
self.assertIsInstance(ctx, context.MistralContext)
self.assertEqual('my_realm', ctx.project_id)
self.assertEqual(["role1", "role2"], ctx.roles)
# Second call of set_ctx ('after'), where we reset the context
# Second call of set_ctx ('after'), where we reset the context.
self.assertIsNone(calls[1][0][0])

View File

@ -56,7 +56,7 @@ python-ironic-inspector-client>=1.5.0 # Apache-2.0
python-vitrageclient>=2.0.0 # Apache-2.0
python-zunclient>=3.4.0 # Apache-2.0
python-qinlingclient>=1.0.0 # Apache-2.0
PyJWT>=1.0.1 # MIT
PyJWT>=1.5 # MIT
PyYAML>=3.12 # MIT
requests>=2.14.2 # Apache-2.0
tenacity>=5.0.1 # Apache-2.0