updated Keycloak_auth to use public key for validation
Previously there were 2 ways to validate the access_token 1. define auth_url, which will send the access_token to keycloak for validation 2. define absolute user_info_endpoint_url to validate the custom url In this patch we have removed the 1st option to validate everytime with keycloak instead we are taking keyclaok certs and constructing public key with it and then validating using access_token using public key and iss(optional). We are keeping the public key in cache to not to request keycloak repeatedly. Change-Id: Ie0551c2f9f8a37debd50e7aebcf35f7143db44f9
This commit is contained in:
parent
5815d94781
commit
47fd843000
@ -12,6 +12,9 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
from cachetools import cached
|
||||||
|
from cachetools import LRUCache
|
||||||
|
import json
|
||||||
import jwt
|
import jwt
|
||||||
import memcache
|
import memcache
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
@ -25,6 +28,7 @@ import webob.dec
|
|||||||
from glare.common import exception
|
from glare.common import exception
|
||||||
from glare.common import utils
|
from glare.common import utils
|
||||||
from glare.i18n import _
|
from glare.i18n import _
|
||||||
|
from jwt.algorithms import RSAAlgorithm
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -70,6 +74,15 @@ keycloak_oidc_opts = [
|
|||||||
'tokens, the middleware caches previously-seen tokens '
|
'tokens, the middleware caches previously-seen tokens '
|
||||||
'for a configurable duration (in seconds).'
|
'for a configurable duration (in seconds).'
|
||||||
),
|
),
|
||||||
|
cfg.StrOpt(
|
||||||
|
'public_cert_url',
|
||||||
|
default="/realms/%s/protocol/openid-connect/certs",
|
||||||
|
help="URL to get the public key for perticualar realm"
|
||||||
|
),
|
||||||
|
cfg.StrOpt(
|
||||||
|
'keycloak_iss',
|
||||||
|
help="keycloak issuer(iss) url Ex: https://ip_add:port/auth/realms/%s"
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
@ -81,67 +94,34 @@ class KeycloakAuthMiddleware(base_middleware.Middleware):
|
|||||||
super(KeycloakAuthMiddleware, self).__init__(application=app)
|
super(KeycloakAuthMiddleware, self).__init__(application=app)
|
||||||
mcserv_url = CONF.keycloak_oidc.memcached_server
|
mcserv_url = CONF.keycloak_oidc.memcached_server
|
||||||
self.mcclient = memcache.Client(mcserv_url) if mcserv_url else None
|
self.mcclient = memcache.Client(mcserv_url) if mcserv_url else None
|
||||||
|
|
||||||
self.certfile = CONF.keycloak_oidc.certfile
|
self.certfile = CONF.keycloak_oidc.certfile
|
||||||
self.keyfile = CONF.keycloak_oidc.keyfile
|
self.keyfile = CONF.keycloak_oidc.keyfile
|
||||||
self.cafile = CONF.keycloak_oidc.cafile or utils.get_system_ca_file()
|
self.cafile = CONF.keycloak_oidc.cafile or utils.get_system_ca_file()
|
||||||
self.insecure = CONF.keycloak_oidc.insecure
|
self.insecure = CONF.keycloak_oidc.insecure
|
||||||
|
|
||||||
def authenticate(self, access_token, realm_name):
|
def authenticate(self, access_token, realm_name, audience):
|
||||||
info = None
|
info = None
|
||||||
if self.mcclient:
|
if self.mcclient:
|
||||||
info = self.mcclient.get(access_token)
|
info = self.mcclient.get(access_token)
|
||||||
|
|
||||||
user_info_endpoint_url = CONF.keycloak_oidc.user_info_endpoint_url
|
user_info_endpoint_url = CONF.keycloak_oidc.user_info_endpoint_url
|
||||||
if info is None and user_info_endpoint_url:
|
if info is None:
|
||||||
if user_info_endpoint_url.startswith(('http://', 'https://')):
|
if user_info_endpoint_url.startswith(('http://', 'https://')):
|
||||||
url = user_info_endpoint_url
|
info = self.send_request_to_auth_server(
|
||||||
|
url=user_info_endpoint_url, access_token=access_token)
|
||||||
else:
|
else:
|
||||||
url_template = CONF.keycloak_oidc.auth_url + \
|
public_key = self.get_public_key(realm_name)
|
||||||
CONF.keycloak_oidc.user_info_endpoint_url
|
keycloak_iss = None
|
||||||
url = url_template % realm_name
|
try:
|
||||||
|
if CONF.keycloak_oidc.keycloak_iss:
|
||||||
verify = None
|
keycloak_iss = \
|
||||||
if urllib.parse.urlparse(url).scheme == "https":
|
CONF.keycloak_oidc.keycloak_iss % realm_name
|
||||||
verify = False if self.insecure else self.cafile
|
jwt.decode(access_token, public_key, audience=audience,
|
||||||
|
issuer=keycloak_iss, algorithms=['RS256'],
|
||||||
cert = (self.certfile, self.keyfile) \
|
verify=True)
|
||||||
if self.certfile and self.keyfile else None
|
except Exception as e:
|
||||||
|
LOG.error("Exception in access_token validation %s", e)
|
||||||
try:
|
raise exception.Unauthorized()
|
||||||
resp = requests.get(
|
|
||||||
url,
|
|
||||||
headers={"Authorization": "Bearer %s" % access_token},
|
|
||||||
verify=verify,
|
|
||||||
cert=cert
|
|
||||||
)
|
|
||||||
except requests.ConnectionError:
|
|
||||||
msg = _("Can't connect to keycloak server with address '%s'."
|
|
||||||
) % CONF.keycloak_oidc.auth_url
|
|
||||||
LOG.error(msg)
|
|
||||||
raise exception.GlareException(message=msg)
|
|
||||||
|
|
||||||
if resp.status_code == 400:
|
|
||||||
raise exception.BadRequest(message=resp.text)
|
|
||||||
if resp.status_code == 401:
|
|
||||||
LOG.warning("HTTP response from OIDC provider:"
|
|
||||||
" [%s] with WWW-Authenticate: [%s]",
|
|
||||||
pprint.pformat(resp.text),
|
|
||||||
resp.headers.get("WWW-Authenticate"))
|
|
||||||
raise exception.Unauthorized(message=resp.text)
|
|
||||||
if resp.status_code == 403:
|
|
||||||
raise exception.Forbidden(message=resp.text)
|
|
||||||
elif resp.status_code > 400:
|
|
||||||
raise exception.GlareException(message=resp.text)
|
|
||||||
|
|
||||||
if self.mcclient:
|
|
||||||
self.mcclient.set(access_token, resp.json(),
|
|
||||||
time=CONF.keycloak_oidc.token_cache_time)
|
|
||||||
info = resp.json()
|
|
||||||
|
|
||||||
LOG.debug("HTTP response from OIDC provider: %s",
|
|
||||||
pprint.pformat(info))
|
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@webob.dec.wsgify
|
@webob.dec.wsgify
|
||||||
@ -163,14 +143,62 @@ class KeycloakAuthMiddleware(base_middleware.Middleware):
|
|||||||
# Get user realm from parsed token
|
# Get user realm from parsed token
|
||||||
# Format is "iss": "http://<host>:<port>/auth/realms/<realm_name>",
|
# Format is "iss": "http://<host>:<port>/auth/realms/<realm_name>",
|
||||||
__, __, realm_name = decoded['iss'].strip().rpartition('/realms/')
|
__, __, realm_name = decoded['iss'].strip().rpartition('/realms/')
|
||||||
|
audience = decoded['aud']
|
||||||
|
|
||||||
# Get roles from from parsed token
|
# Get roles from from parsed token
|
||||||
roles = ','.join(decoded['realm_access']['roles']) \
|
roles = ','.join(decoded['realm_access']['roles']) \
|
||||||
if 'realm_access' in decoded else ''
|
if 'realm_access' in decoded else ''
|
||||||
|
|
||||||
self.authenticate(access_token, realm_name)
|
self.authenticate(access_token, realm_name, audience)
|
||||||
|
|
||||||
request.headers["X-Identity-Status"] = "Confirmed"
|
request.headers["X-Identity-Status"] = "Confirmed"
|
||||||
request.headers["X-Project-Id"] = realm_name
|
request.headers["X-Project-Id"] = realm_name
|
||||||
request.headers["X-Roles"] = roles
|
request.headers["X-Roles"] = roles
|
||||||
return request.get_response(self.application)
|
return request.get_response(self.application)
|
||||||
|
|
||||||
|
@cached(LRUCache(maxsize=32))
|
||||||
|
def get_public_key(self, realm_name):
|
||||||
|
keycloak_key_url = CONF.keycloak_oidc.auth_url + \
|
||||||
|
CONF.keycloak_oidc.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.get("keys")[0]))
|
||||||
|
return public_key
|
||||||
|
|
||||||
|
def send_request_to_auth_server(self, url, access_token=None):
|
||||||
|
verify = None
|
||||||
|
if urllib.parse.urlparse(url).scheme == "https":
|
||||||
|
verify = False if self.insecure else self.cafile
|
||||||
|
|
||||||
|
cert = (self.certfile, self.keyfile) \
|
||||||
|
if self.certfile and self.keyfile else None
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if access_token:
|
||||||
|
headers["Authorization"] = "Bearer %s" % access_token
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
verify=verify,
|
||||||
|
cert=cert
|
||||||
|
)
|
||||||
|
except requests.ConnectionError as e:
|
||||||
|
msg = _("Can't connect to keycloak server with address '%s'."
|
||||||
|
) % url
|
||||||
|
LOG.error(msg, e)
|
||||||
|
raise exception.GlareException(message=msg)
|
||||||
|
|
||||||
|
if resp.status_code == 400:
|
||||||
|
raise exception.BadRequest(message=resp.text)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
LOG.warning("HTTP response from OIDC provider:"
|
||||||
|
" [%s] with WWW-Authenticate: [%s]",
|
||||||
|
pprint.pformat(resp.text),
|
||||||
|
resp.headers.get("WWW-Authenticate"))
|
||||||
|
raise exception.Unauthorized(message=resp.text)
|
||||||
|
if resp.status_code == 403:
|
||||||
|
raise exception.Forbidden(message=resp.text)
|
||||||
|
elif resp.status_code > 400:
|
||||||
|
raise exception.GlareException(message=resp.text)
|
||||||
|
return resp.json()
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import json
|
||||||
import mock
|
import mock
|
||||||
import requests
|
import requests
|
||||||
import webob
|
import webob
|
||||||
@ -35,13 +36,27 @@ class TestKeycloakAuthMiddleware(base.BaseTestCase):
|
|||||||
def test_header_parsing(self, mocked_get):
|
def test_header_parsing(self, mocked_get):
|
||||||
token = {
|
token = {
|
||||||
"iss": "http://localhost:8080/auth/realms/my_realm",
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
|
"aud": "openstack",
|
||||||
"realm_access": {
|
"realm_access": {
|
||||||
"roles": ["role1", "role2"]
|
"roles": ["role1", "role2"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mocked_resp = mock.Mock()
|
mocked_resp = mock.Mock()
|
||||||
mocked_resp.status_code = 200
|
mocked_resp.status_code = 200
|
||||||
mocked_resp.json.return_value = '{"user": "mike"}'
|
mocked_resp.json.return_value = json.loads("""
|
||||||
|
{
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kid": "FJ86GcF3jTbNLOco4NvZkUCIUmfYCqoqtOQeMfbhNlE",
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"use": "sig",
|
||||||
|
"n": "q1awrk7QK24Gmcy9Yb4dMbS-ZnO6",
|
||||||
|
"e": "AQAB"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""")
|
||||||
mocked_get.return_value = mocked_resp
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
req = self._build_request(token)
|
req = self._build_request(token)
|
||||||
@ -58,7 +73,10 @@ class TestKeycloakAuthMiddleware(base.BaseTestCase):
|
|||||||
|
|
||||||
@mock.patch("requests.get")
|
@mock.patch("requests.get")
|
||||||
def test_no_realm_access(self, mocked_get):
|
def test_no_realm_access(self, mocked_get):
|
||||||
|
self.config(user_info_endpoint_url='https://127.0.0.1:9080',
|
||||||
|
group='keycloak_oidc')
|
||||||
token = {
|
token = {
|
||||||
|
"aud": "openstack",
|
||||||
"iss": "http://localhost:8080/auth/realms/my_realm",
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
}
|
}
|
||||||
mocked_resp = mock.Mock()
|
mocked_resp = mock.Mock()
|
||||||
@ -79,12 +97,14 @@ class TestKeycloakAuthMiddleware(base.BaseTestCase):
|
|||||||
|
|
||||||
@mock.patch("requests.get")
|
@mock.patch("requests.get")
|
||||||
def test_server_unauthorized(self, mocked_get):
|
def test_server_unauthorized(self, mocked_get):
|
||||||
|
self.config(user_info_endpoint_url='https://127.0.0.1:9080',
|
||||||
|
group='keycloak_oidc')
|
||||||
token = {
|
token = {
|
||||||
|
"aud": "openstack",
|
||||||
"iss": "http://localhost:8080/auth/realms/my_realm",
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
}
|
}
|
||||||
mocked_resp = mock.Mock()
|
mocked_resp = mock.Mock()
|
||||||
mocked_resp.status_code = 401
|
mocked_resp.status_code = 401
|
||||||
mocked_resp.json.return_value = '{"user": "mike"}'
|
|
||||||
mocked_get.return_value = mocked_resp
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
req = self._build_request(token)
|
req = self._build_request(token)
|
||||||
@ -93,12 +113,14 @@ class TestKeycloakAuthMiddleware(base.BaseTestCase):
|
|||||||
|
|
||||||
@mock.patch("requests.get")
|
@mock.patch("requests.get")
|
||||||
def test_server_forbidden(self, mocked_get):
|
def test_server_forbidden(self, mocked_get):
|
||||||
|
self.config(user_info_endpoint_url='https://127.0.0.1:9080',
|
||||||
|
group='keycloak_oidc')
|
||||||
token = {
|
token = {
|
||||||
|
"aud": "openstack",
|
||||||
"iss": "http://localhost:8080/auth/realms/my_realm",
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
}
|
}
|
||||||
mocked_resp = mock.Mock()
|
mocked_resp = mock.Mock()
|
||||||
mocked_resp.status_code = 403
|
mocked_resp.status_code = 403
|
||||||
mocked_resp.json.return_value = '{"user": "mike"}'
|
|
||||||
mocked_get.return_value = mocked_resp
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
req = self._build_request(token)
|
req = self._build_request(token)
|
||||||
@ -108,11 +130,12 @@ class TestKeycloakAuthMiddleware(base.BaseTestCase):
|
|||||||
@mock.patch("requests.get")
|
@mock.patch("requests.get")
|
||||||
def test_server_exception(self, mocked_get):
|
def test_server_exception(self, mocked_get):
|
||||||
token = {
|
token = {
|
||||||
"iss": "http://localhost:8080/auth/realms/my_realm",
|
"aud": "openstack",
|
||||||
|
"iss": "http://localhost:8080/auth/realms/my_realm"
|
||||||
}
|
}
|
||||||
mocked_resp = mock.Mock()
|
mocked_resp = mock.Mock()
|
||||||
mocked_resp.status_code = 500
|
mocked_resp.status_code = 500
|
||||||
mocked_resp.json.return_value = '{"user": "mike"}'
|
mocked_resp.json.return_value = "Internal Server Error"
|
||||||
mocked_get.return_value = mocked_resp
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
req = self._build_request(token)
|
req = self._build_request(token)
|
||||||
@ -123,6 +146,7 @@ class TestKeycloakAuthMiddleware(base.BaseTestCase):
|
|||||||
@mock.patch("requests.get")
|
@mock.patch("requests.get")
|
||||||
def test_connection_error(self, mocked_get):
|
def test_connection_error(self, mocked_get):
|
||||||
token = {
|
token = {
|
||||||
|
"aud": "openstack",
|
||||||
"iss": "http://localhost:8080/auth/realms/my_realm",
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
}
|
}
|
||||||
mocked_get.side_effect = requests.ConnectionError
|
mocked_get.side_effect = requests.ConnectionError
|
||||||
@ -137,16 +161,35 @@ class TestKeycloakAuthMiddleware(base.BaseTestCase):
|
|||||||
self.config(user_info_endpoint_url='',
|
self.config(user_info_endpoint_url='',
|
||||||
group='keycloak_oidc')
|
group='keycloak_oidc')
|
||||||
token = {
|
token = {
|
||||||
|
"aud": "openstack",
|
||||||
"iss": "http://localhost:8080/auth/realms/my_realm",
|
"iss": "http://localhost:8080/auth/realms/my_realm",
|
||||||
"realm_access": {
|
"realm_access": {
|
||||||
"roles": ["role1", "role2"]
|
"roles": ["role1", "role2"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mocked_resp = mock.Mock()
|
||||||
|
mocked_resp.status_code = 200
|
||||||
|
mocked_resp.json.return_value = json.loads("""
|
||||||
|
{
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kid": "FJ86GcF3jTbNLOco4NvZkUCIUmfYCqoqtOQeMfbhNlE",
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"use": "sig",
|
||||||
|
"n": "q1awrk7QK24Gmcy9Yb4dMbS-ZnO6",
|
||||||
|
"e": "AQAB"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
mocked_get.return_value = mocked_resp
|
||||||
|
|
||||||
req = self._build_request(token)
|
req = self._build_request(token)
|
||||||
with mock.patch("jwt.decode", return_value=token):
|
with mock.patch("jwt.decode", return_value=token):
|
||||||
self._build_middleware()(req)
|
self._build_middleware()(req)
|
||||||
self.assertEqual("Confirmed", req.headers["X-Identity-Status"])
|
self.assertEqual("Confirmed", req.headers["X-Identity-Status"])
|
||||||
self.assertEqual("my_realm", req.headers["X-Project-Id"])
|
self.assertEqual("my_realm", req.headers["X-Project-Id"])
|
||||||
self.assertEqual("role1,role2", req.headers["X-Roles"])
|
self.assertEqual("role1,role2", req.headers["X-Roles"])
|
||||||
self.assertEqual(0, mocked_get.call_count)
|
self.assertEqual(1, mocked_get.call_count)
|
||||||
|
Loading…
Reference in New Issue
Block a user