OAuth2 support 99/12899/9
authorksun1 <kaige.sun@windriver.com>
Tue, 16 Apr 2024 08:06:29 +0000 (16:06 +0800)
committerZhang Rong(Jon) <rong.zhang@windriver.com>
Mon, 27 May 2024 13:58:22 +0000 (21:58 +0800)
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 <kaige.sun@windriver.com>
Signed-off-by: Zhang Rong(Jon) <rong.zhang@windriver.com>
configs/o2app.conf
o2app/entrypoints/flask_application.py
o2common/authmw/authmiddleware.py
o2common/authmw/authprov.py
o2common/authmw/exceptions.py [new file with mode: 0644]
o2common/config/config.py
requirements.txt

index cf6bdfe..ad224bb 100644 (file)
@@ -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]
 
index d55b50a..fb4b6a2 100644 (file)
@@ -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)
index 3141263..a5193fc 100644 (file)
 #  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
     '''
index 11243df..87bbc4e 100644 (file)
 #  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 (file)
index 0000000..5d1b1a6
--- /dev/null
@@ -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
index 06c3b56..f8eee94 100644 (file)
@@ -386,7 +386,7 @@ def get_reviewer_token():
 
 
 def get_auth_provider():
-    return 'k8s'
+    return config.conf.auth_provider
 
 
 def get_dms_support_profiles():
index 3462bae..5d34a0c 100644 (file)
@@ -24,3 +24,6 @@ ruamel.yaml==0.17.17
 pyOpenSSL
 
 gunicorn
+
+# Import JWT to support OAuth2
+pyjwt==2.6.0