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
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -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/',
|
||||
|
Loading…
Reference in New Issue
Block a user