From 19ebcd43f6bd3e3caa4b13b652dd3212bed613dc Mon Sep 17 00:00:00 2001 From: Eyal Date: Thu, 2 Jan 2020 15:38:11 +0200 Subject: [PATCH] Fix keycloak authentication * Implement offline access token validation using Keycloak public key. Change-Id: Ic9992cc6241239a22b6fd78d9c4f45a04d1a763d --- requirements.txt | 1 + vitrage/middleware/keycloak.py | 56 ++++++++++++++++--- .../tests/functional/api/v1/test_keycloak.py | 34 ++++++----- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/requirements.txt b/requirements.txt index bb86c5242..6936f5e7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ pbr>=3.1.1 # Apache-2.0 alembic>=0.9.8 # MIT Babel>=2.5.3 # BSD +cachetools>=2.0.1 # MIT License lxml>=4.1.1 # BSD PyMySQL>=0.8.0 # MIT License aodhclient>=1.0.0 # Apache-2.0 diff --git a/vitrage/middleware/keycloak.py b/vitrage/middleware/keycloak.py index d0a3dad74..1e942935e 100644 --- a/vitrage/middleware/keycloak.py +++ b/vitrage/middleware/keycloak.py @@ -11,11 +11,14 @@ # 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 jwt import os import requests +from cachetools import cached +from cachetools import LRUCache +from jwt.algorithms import RSAAlgorithm from oslo_config import cfg from oslo_log import log as logging from oslo_middleware import base @@ -46,6 +49,16 @@ KEYCLOAK_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 particular realm" + ), + cfg.StrOpt( + 'keycloak_iss', + help="keycloak issuer(iss) url " + "Example: https://ip_add:port/auth/realms/%s" + ) ] @@ -63,6 +76,9 @@ class KeycloakAuth(base.ConfigurableMiddleware): self._get_system_ca_file() self.user_info_endpoint_url = self._conf_get('user_info_endpoint_url', KEYCLOAK_GROUP) + self.public_cert_url = self._conf_get('public_cert_url', + KEYCLOAK_GROUP) + self.keycloak_iss = self._conf_get('keycloak_iss', KEYCLOAK_GROUP) @property def reject_auth_headers(self): @@ -93,7 +109,7 @@ class KeycloakAuth(base.ConfigurableMiddleware): message = 'Auth token must be provided in "X-Auth-Token" header.' self._unauthorized(message) - self.call_keycloak(token, decoded) + self.call_keycloak(token, decoded, decoded.get('aud')) self._set_req_headers(req, decoded) @@ -104,23 +120,37 @@ class KeycloakAuth(base.ConfigurableMiddleware): message = "Token can't be decoded because of wrong format." self._unauthorized(message) - def call_keycloak(self, token, decoded): + def call_keycloak(self, token, decoded, audience): if self.user_info_endpoint_url.startswith(('http://', 'https://')): endpoint = self.user_info_endpoint_url + self.send_request_to_auth_server(endpoint, token) else: - endpoint = ('%s' + self.user_info_endpoint_url) % \ - (self.auth_url, self.realm_name(decoded)) - headers = {'Authorization': 'Bearer %s' % token} + public_key = self.get_public_key(self.realm_name(decoded)) + try: + if self.keycloak_iss: + self.keycloak_iss = self.keycloak_iss % \ + self.realm_name(decoded) + jwt.decode(token, public_key, audience=audience, + issuer=self.keycloak_iss, algorithms=['RS256'], + verify=True) + except Exception: + message = 'Token validation failure' + self._unauthorized(message) + + def send_request_to_auth_server(self, endpoint, token=None): + headers = {} + if token: + headers = {'Authorization': 'Bearer %s' % token} verify = None if urllib.parse.urlparse(endpoint).scheme == "https": verify = False if self.insecure else self.cafile - cert = (self.certfile, self.keyfile) if self.certfile and self.keyfile \ - else None + cert = (self.certfile, self.keyfile) \ + if self.certfile and self.keyfile else None resp = requests.get(endpoint, headers=headers, verify=verify, cert=cert) - if not resp.ok: abort(resp.status_code, resp.reason) + return resp.json() def _set_req_headers(self, req, decoded): req.headers['X-Identity-Status'] = 'Confirmed' @@ -157,5 +187,13 @@ class KeycloakAuth(base.ConfigurableMiddleware): 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 = self.auth_url + self.public_cert_url % realm_name + response_json = self.send_request_to_auth_server(keycloak_key_url) + public_key = RSAAlgorithm.from_jwk( + json.dumps(response_json["keys"][0])) + return public_key + filter_factory = KeycloakAuth.factory diff --git a/vitrage/tests/functional/api/v1/test_keycloak.py b/vitrage/tests/functional/api/v1/test_keycloak.py index 595611120..ba7245f5e 100644 --- a/vitrage/tests/functional/api/v1/test_keycloak.py +++ b/vitrage/tests/functional/api/v1/test_keycloak.py @@ -16,6 +16,7 @@ from datetime import datetime # noinspection PyPackageRequirements +import json import mock import requests_mock from vitrage.middleware.keycloak import KeycloakAuth @@ -25,6 +26,7 @@ from webtest import TestRequest TOKEN = { "iss": "http://127.0.0.1/auth/realms/my_realm", + "aud": "openstack", "realm_access": { "roles": ["role1", "role2"] } @@ -35,18 +37,24 @@ HEADERS = { 'X-Project-Id': 'my_realm' } -OPENID_CONNECT_USERINFO = 'http://127.0.0.1:9080/auth/realms/my_realm/' \ - 'protocol/openid-connect/userinfo' -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" -} +CERT_URL = 'http://127.0.0.1:9080/auth/realms/my_realm/' \ + 'protocol/openid-connect/certs' + +PUBLIC_KEY = json.loads(""" + { + "keys": [ + { + "kid": "FJ86GcF3jTbNLOco4NvZkUCIUmfYCqoqtOQeMfbhNlE", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "q1awrk7QK24Gmcy9Yb4dMbS-ZnO6", + "e": "AQAB" + } + ] + } + """) EVENT_DETAILS = { 'hostname': 'host123', @@ -82,7 +90,7 @@ class KeycloakTest(FunctionalTest): def test_header_parsing(self, _, req_mock): # Imitate success response from KeyCloak. - req_mock.get(OPENID_CONNECT_USERINFO) + req_mock.get(CERT_URL, json=PUBLIC_KEY) req = self._build_request() auth = KeycloakAuth(mock.Mock(), self.conf) @@ -122,7 +130,7 @@ class KeycloakTest(FunctionalTest): def test_in_keycloak_mode_auth_success(self, _, req_mock): # Imitate success response from KeyCloak. - req_mock.get(OPENID_CONNECT_USERINFO, json=USER_CLAIMS) + req_mock.get(CERT_URL, json=PUBLIC_KEY) with mock.patch('pecan.request') as request: resp = self.post_json('/event/',