Fix keycloak authentication

* Implement offline access token validation using Keycloak public key.

Change-Id: Ic9992cc6241239a22b6fd78d9c4f45a04d1a763d
This commit is contained in:
Eyal 2020-01-02 15:38:11 +02:00
parent 6304037894
commit 19ebcd43f6
3 changed files with 69 additions and 22 deletions

View File

@ -5,6 +5,7 @@
pbr>=3.1.1 # Apache-2.0 pbr>=3.1.1 # Apache-2.0
alembic>=0.9.8 # MIT alembic>=0.9.8 # MIT
Babel>=2.5.3 # BSD Babel>=2.5.3 # BSD
cachetools>=2.0.1 # MIT License
lxml>=4.1.1 # BSD lxml>=4.1.1 # BSD
PyMySQL>=0.8.0 # MIT License PyMySQL>=0.8.0 # MIT License
aodhclient>=1.0.0 # Apache-2.0 aodhclient>=1.0.0 # Apache-2.0

View File

@ -11,11 +11,14 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import json
import jwt import jwt
import os import os
import requests import requests
from cachetools import cached
from cachetools import LRUCache
from jwt.algorithms import RSAAlgorithm
from oslo_config import cfg from oslo_config import cfg
from oslo_log import log as logging from oslo_log import log as logging
from oslo_middleware import base from oslo_middleware import base
@ -46,6 +49,16 @@ KEYCLOAK_OPTS = [
default='/realms/%s/protocol/openid-connect/userinfo', default='/realms/%s/protocol/openid-connect/userinfo',
help='Endpoint against which authorization will be performed' 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._get_system_ca_file()
self.user_info_endpoint_url = self._conf_get('user_info_endpoint_url', self.user_info_endpoint_url = self._conf_get('user_info_endpoint_url',
KEYCLOAK_GROUP) 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 @property
def reject_auth_headers(self): def reject_auth_headers(self):
@ -93,7 +109,7 @@ class KeycloakAuth(base.ConfigurableMiddleware):
message = 'Auth token must be provided in "X-Auth-Token" header.' message = 'Auth token must be provided in "X-Auth-Token" header.'
self._unauthorized(message) self._unauthorized(message)
self.call_keycloak(token, decoded) self.call_keycloak(token, decoded, decoded.get('aud'))
self._set_req_headers(req, decoded) self._set_req_headers(req, decoded)
@ -104,23 +120,37 @@ class KeycloakAuth(base.ConfigurableMiddleware):
message = "Token can't be decoded because of wrong format." message = "Token can't be decoded because of wrong format."
self._unauthorized(message) 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://')): if self.user_info_endpoint_url.startswith(('http://', 'https://')):
endpoint = self.user_info_endpoint_url endpoint = self.user_info_endpoint_url
self.send_request_to_auth_server(endpoint, token)
else: else:
endpoint = ('%s' + self.user_info_endpoint_url) % \ public_key = self.get_public_key(self.realm_name(decoded))
(self.auth_url, 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} headers = {'Authorization': 'Bearer %s' % token}
verify = None verify = None
if urllib.parse.urlparse(endpoint).scheme == "https": if urllib.parse.urlparse(endpoint).scheme == "https":
verify = False if self.insecure else self.cafile verify = False if self.insecure else self.cafile
cert = (self.certfile, self.keyfile) if self.certfile and self.keyfile \ cert = (self.certfile, self.keyfile) \
else None if self.certfile and self.keyfile else None
resp = requests.get(endpoint, headers=headers, resp = requests.get(endpoint, headers=headers,
verify=verify, cert=cert) verify=verify, cert=cert)
if not resp.ok: if not resp.ok:
abort(resp.status_code, resp.reason) abort(resp.status_code, resp.reason)
return resp.json()
def _set_req_headers(self, req, decoded): def _set_req_headers(self, req, decoded):
req.headers['X-Identity-Status'] = 'Confirmed' req.headers['X-Identity-Status'] = 'Confirmed'
@ -157,5 +187,13 @@ class KeycloakAuth(base.ConfigurableMiddleware):
return ca return ca
LOG.warning("System ca file could not be found.") 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 filter_factory = KeycloakAuth.factory

View File

@ -16,6 +16,7 @@
from datetime import datetime from datetime import datetime
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
import json
import mock import mock
import requests_mock import requests_mock
from vitrage.middleware.keycloak import KeycloakAuth from vitrage.middleware.keycloak import KeycloakAuth
@ -25,6 +26,7 @@ from webtest import TestRequest
TOKEN = { TOKEN = {
"iss": "http://127.0.0.1/auth/realms/my_realm", "iss": "http://127.0.0.1/auth/realms/my_realm",
"aud": "openstack",
"realm_access": { "realm_access": {
"roles": ["role1", "role2"] "roles": ["role1", "role2"]
} }
@ -35,18 +37,24 @@ HEADERS = {
'X-Project-Id': 'my_realm' 'X-Project-Id': 'my_realm'
} }
OPENID_CONNECT_USERINFO = 'http://127.0.0.1:9080/auth/realms/my_realm/' \
'protocol/openid-connect/userinfo'
USER_CLAIMS = { CERT_URL = 'http://127.0.0.1:9080/auth/realms/my_realm/' \
"sub": "248289761001", 'protocol/openid-connect/certs'
"name": "Jane Doe",
"given_name": "Jane", PUBLIC_KEY = json.loads("""
"family_name": "Doe", {
"preferred_username": "j.doe", "keys": [
"email": "janedoe@example.com", {
"picture": "http://example.com/janedoe/me.jpg" "kid": "FJ86GcF3jTbNLOco4NvZkUCIUmfYCqoqtOQeMfbhNlE",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "q1awrk7QK24Gmcy9Yb4dMbS-ZnO6",
"e": "AQAB"
} }
]
}
""")
EVENT_DETAILS = { EVENT_DETAILS = {
'hostname': 'host123', 'hostname': 'host123',
@ -82,7 +90,7 @@ class KeycloakTest(FunctionalTest):
def test_header_parsing(self, _, req_mock): def test_header_parsing(self, _, req_mock):
# Imitate success response from KeyCloak. # Imitate success response from KeyCloak.
req_mock.get(OPENID_CONNECT_USERINFO) req_mock.get(CERT_URL, json=PUBLIC_KEY)
req = self._build_request() req = self._build_request()
auth = KeycloakAuth(mock.Mock(), self.conf) auth = KeycloakAuth(mock.Mock(), self.conf)
@ -122,7 +130,7 @@ class KeycloakTest(FunctionalTest):
def test_in_keycloak_mode_auth_success(self, _, req_mock): def test_in_keycloak_mode_auth_success(self, _, req_mock):
# Imitate success response from KeyCloak. # 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: with mock.patch('pecan.request') as request:
resp = self.post_json('/event/', resp = self.post_json('/event/',