Fix keycloak authentication
* Implement offline access token validation using Keycloak public key. Change-Id: Ic9992cc6241239a22b6fd78d9c4f45a04d1a763d
This commit is contained in:
parent
6304037894
commit
19ebcd43f6
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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/',
|
||||||
|
Loading…
Reference in New Issue
Block a user