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
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

View File

@ -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))
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

View File

@ -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/',