From d7c14ad6506b2f1a85246c9e1d08d0d64e9df7f2 Mon Sep 17 00:00:00 2001 From: ksun1 Date: Tue, 16 Apr 2024 16:06:29 +0800 Subject: [PATCH] OAuth2 support The WG11 Security Requirements v04.00.01 specify that the API should follow OAuth 2.0. However, the O2 IMS API is not compliant with OAuth 2.0. This commit will add OAuth 2.0 support to the O2 IMS API. The OAuth 2.0 server is based on a third-party service, and the O2 IMS will be registered with the OAuth server to enable OAuth functionality. Test Plan: 1. Start O2 with the third-party OAuth server. Verify that requests with tokens assigned by the third-party server are authenticated successfully by O2 IMS. 2. Start O2 with the original K8S authentication and verify that it is successful. Issue-ID: INF-447 Change-Id: I8c3cf9ce297b8404cea60c24a1d50b0fb17107a0 Signed-off-by: Kaige Sun Signed-off-by: Zhang Rong(Jon) --- configs/o2app.conf | 29 +++++++++-- o2app/entrypoints/flask_application.py | 11 ----- o2common/authmw/authmiddleware.py | 28 +++-------- o2common/authmw/authprov.py | 88 +++++++++++++++++++++++++++++----- o2common/authmw/exceptions.py | 32 +++++++++++++ o2common/config/config.py | 2 +- requirements.txt | 3 ++ 7 files changed, 143 insertions(+), 50 deletions(-) create mode 100644 o2common/authmw/exceptions.py diff --git a/configs/o2app.conf b/configs/o2app.conf index cf6bdfe..ad224bb 100644 --- a/configs/o2app.conf +++ b/configs/o2app.conf @@ -5,16 +5,35 @@ ocloud_global_id = 4e24b97c-8c49-4c4f-b53e-3de5235a4e37 smo_register_url = http://127.0.0.1:8090/register smo_token_data = smo_token_payload +auth_provider = oauth2 + +[OAUTH2] +# support OAuth2.0 + +# oauth2 token verify type: jwt or introspection +oauth2_verify_type = +# oauth2 public key +oauth2_public_key = +# oauth2 encryption asymmetric algorithm +oauth2_algorithm = + +# oauth2 jwt token introspection endpoint, required if oauth2_verify_type = introspection +oauth2_introspection_endpoint = +# required if oauth2_verify_type = introspection +oauth2_client_id = +# required if oauth2_verify_type = introspection +oauth2_client_secret = + [OCLOUD] -OS_AUTH_URL = -OS_USERNAME = -OS_PASSWORD = -API_HOST_EXTERNAL_FLOATING = +OS_AUTH_URL = +OS_USERNAME = +OS_PASSWORD = +API_HOST_EXTERNAL_FLOATING = [API] # support native_k8sapi,sol018,sol018_helmcli # if the value is black, then native_k8sapi will set by default -DMS_SUPPORT_PROFILES = +DMS_SUPPORT_PROFILES = [WATCHER] diff --git a/o2app/entrypoints/flask_application.py b/o2app/entrypoints/flask_application.py index d55b50a..fb4b6a2 100644 --- a/o2app/entrypoints/flask_application.py +++ b/o2app/entrypoints/flask_application.py @@ -22,7 +22,6 @@ from o2common.views.route_exception import configure_exception from o2common.authmw import authmiddleware from o2common.authmw import authprov -from o2common.config.config import get_review_url from o2common.helper import o2logging AUTH_ENABLED = True @@ -32,18 +31,8 @@ FLASK_API_VERSION = '1.0.0' app = Flask(__name__) logger = o2logging.get_logger(__name__) - -def _get_k8s_url(): - try: - token_review_url = get_review_url() - return token_review_url - except Exception: - raise Exception('Get k8s token review url failed') - - if AUTH_ENABLED: # perform service account identity&privilege check. - _get_k8s_url() ad = authprov.auth_definer('ad') ad.sanity_check() app.wsgi_app = authmiddleware.authmiddleware(app.wsgi_app) diff --git a/o2common/authmw/authmiddleware.py b/o2common/authmw/authmiddleware.py index 3141263..a5193fc 100644 --- a/o2common/authmw/authmiddleware.py +++ b/o2common/authmw/authmiddleware.py @@ -12,24 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json +from flask_restx._http import HTTPStatus from werkzeug.wrappers import Request, Response -from o2common.helper import o2logging + from o2common.authmw.authprov import auth_definer -from flask_restx._http import HTTPStatus -import json +from o2common.authmw.exceptions import AuthRequiredExp +from o2common.authmw.exceptions import AuthFailureExp +from o2common.helper import o2logging logger = o2logging.get_logger(__name__) -class AuthRequiredExp(Exception): - def __init__(self, value): - self.value = value - - def dictize(self): - return { - 'WWW-Authenticate': '{}'.format(self.value)} - - class AuthProblemDetails(): def __init__(self, code: int, detail: str, path: str, title=None, instance=None @@ -54,15 +48,6 @@ class AuthProblemDetails(): return json.dumps(details, indent=True) -class AuthFailureExp(Exception): - def __init__(self, value): - self.value = value - - def dictize(self): - return { - 'WWW-Authenticate': '{}'.format(self.value)} - - def _response_wrapper(environ, start_response, header, detail): res = Response(headers=header, mimetype='application/json', status=401, response=detail) @@ -75,7 +60,6 @@ def _internal_err_response_wrapper(environ, start_response, detail): class authmiddleware(): - ''' Auth WSGI middleware ''' diff --git a/o2common/authmw/authprov.py b/o2common/authmw/authprov.py index 11243df..87bbc4e 100644 --- a/o2common/authmw/authprov.py +++ b/o2common/authmw/authprov.py @@ -13,18 +13,32 @@ # limitations under the License. import ssl -from o2common.helper import o2logging import urllib.request import urllib.parse import json - +from http import HTTPStatus +from requests import post as requests_post +from requests.auth import HTTPBasicAuth +from requests.exceptions import HTTPError +from jwt import decode as jwt_decode +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError + +from o2common.authmw.exceptions import AuthRequiredExp +from o2common.authmw.exceptions import AuthFailureExp from o2common.config.config import get_auth_provider, get_review_url from o2common.config.config import get_reviewer_token +from o2common.config import conf +from o2common.helper import o2logging ssl._create_default_https_context = ssl._create_unverified_context logger = o2logging.get_logger(__name__) +class OAuthAuthenticationException(Exception): + def __init__(self, value): + self.value = value + + class K8SAuthenticaException(Exception): def __init__(self, value): self.value = value @@ -45,7 +59,7 @@ class auth_definer(): if auth_prv_conf == 'k8s': self.obj = k8s_auth_provider('k8s') else: - self.obj = keystone_auth_provider('keystone') + self.obj = oauth2_auth_provider('oauth2') def tokenissue(self): return self.obj.tokenissue() @@ -53,7 +67,6 @@ class auth_definer(): def sanity_check(self): return self.obj.sanity_check() - # call k8s api def authenticate(self, token): return self.obj.authenticate(token) @@ -82,6 +95,7 @@ class k8s_auth_provider(auth_definer): raise Exception(str(ex)) def authenticate(self, token): + ''' Call Kubenetes API to authenticate ''' reviewer_token = get_reviewer_token() tokenreview = { "kind": "TokenReview", @@ -125,18 +139,70 @@ class k8s_auth_provider(auth_definer): return True -class keystone_auth_provider(auth_definer): +class oauth2_auth_provider(auth_definer): def __init__(self, name): self.name = name - def tokenissue(self, *args1, **args2): - pass + def _format_public_key(self): + public_key_string = """-----BEGIN PUBLIC KEY----- \ + %s \ + -----END PUBLIC KEY-----""" % conf.OAUTH2.oauth2_public_key + return public_key_string - def authenticate(self, *args1, **args2): + def _verify_jwt_token_introspect(self, token): + introspect_endpoint = conf.OAUTH2.oauth2_introspection_endpoint + client_id = conf.OAUTH2.oauth2_client_id + client_secret = conf.OAUTH2.oauth2_client_secret + try: + response = requests_post( + introspect_endpoint, + data={'token': token, 'client_id': client_id}, + auth=HTTPBasicAuth(client_id, client_secret) + ) + except HTTPError as e: + logger.error('OAuth2 jwt token introspect verify failed.') + raise Exception(str(e)) + if response.status_code == HTTPStatus.OK: + introspection_data = response.json() + if introspection_data.get('active'): + logger.info('OAuth2 jwt token introspect result active.') + return True + logger.info('OAuth2 jwt token introspect verify failed.') return False - def sanity_check(self): - pass + def _verify_jwt_token(self, token): + algorithm = conf.OAUTH2.oauth2_algorithm + public_key_string = self._format_public_key() + try: + options = {"verify_signature": True, "verify_aud": False, + "exp": True} + decoded_token = jwt_decode(token, public_key_string, + algorithms=[algorithm], options=options) + logger.info( + 'Verified Token from client: %s' % + decoded_token.get("clientHost")) + return True + except (ExpiredSignatureError, + InvalidTokenError) as e: + logger.error(f'OAuth2 jwt token validation failed: {e}') + raise AuthFailureExp( + 'OAuth2 JWT Token Authentication failure.') + except Exception as e: + raise AuthRequiredExp(str(e)) - def tokenrevoke(self, *args1, **args2): + def authenticate(self, token): + ''' Call the JWT to authenticate + + If the verify type is introspection, call introspection endpoint to + verify the token. + If the verify type is jwt, call JWT SDK to verify the token. + ''' + oauth2_verify_type = conf.OAUTH2.oauth2_verify_type + if oauth2_verify_type == 'introspection': + return self._verify_jwt_token_introspect(token) + elif oauth2_verify_type == 'jwt': + return self._verify_jwt_token(token) return False + + def sanity_check(self): + pass diff --git a/o2common/authmw/exceptions.py b/o2common/authmw/exceptions.py new file mode 100644 index 0000000..5d1b1a6 --- /dev/null +++ b/o2common/authmw/exceptions.py @@ -0,0 +1,32 @@ +# Copyright (C) 2024 Wind River Systems, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=too-few-public-methods + + +class AuthGeneralException(Exception): + def __init__(self, value): + self.value = value + + def dictize(self): + return { + 'WWW-Authenticate': '{}'.format(self.value)} + + +class AuthRequiredExp(AuthGeneralException): + pass + + +class AuthFailureExp(AuthGeneralException): + pass diff --git a/o2common/config/config.py b/o2common/config/config.py index 06c3b56..f8eee94 100644 --- a/o2common/config/config.py +++ b/o2common/config/config.py @@ -386,7 +386,7 @@ def get_reviewer_token(): def get_auth_provider(): - return 'k8s' + return config.conf.auth_provider def get_dms_support_profiles(): diff --git a/requirements.txt b/requirements.txt index 3462bae..5d34a0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,3 +24,6 @@ ruamel.yaml==0.17.17 pyOpenSSL gunicorn + +# Import JWT to support OAuth2 +pyjwt==2.6.0 -- 2.16.6