From: Jon Zhang Date: Thu, 30 May 2024 08:44:55 +0000 (+0000) Subject: Merge "OAuth2 support" X-Git-Tag: 2.0.4~5 X-Git-Url: https://gerrit.o-ran-sc.org/r/gitweb?a=commitdiff_plain;h=76a2f103ef57cc47973f0e7914ff5b7036f49800;hp=face7ded891f079361dc37656a0fe3f54585d303;p=pti%2Fo2.git Merge "OAuth2 support" --- 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