Skip to content

Commit

Permalink
vdk-control-cli: autodetect if authentication is needed
Browse files Browse the repository at this point in the history
It will prompt for login only if control service require authentication.
This happens if control service returns 401. This makes it easier to use
since this removes the necessity of explicit flag to enable/disable
authentication. And make integration with other library easier.
For example same authentication is used by properties api .

So how it works

1. If users uses vdk login we'd cache their api token (and api token authorization url which may be provided by plugin)
2. if users users vdk login with interactive flow - we cache the necessary data as well
3. If users has not used vdk login but has set VDK_API_TOKEN and VDK_API_TOKEN_AUTHORIZATION_URL we'd use it in api-token folow to login (similar to step 1)

With 1 and 2 having higher priority than environment variables (3)

Error is not thrown until we try to connect to Control Service and it returns 401:

If Control Service require authentication then error would be thrown
like this
```
vdkcli list --all
Usage: vdkcli list [OPTIONS]

Error: ¯\_(ツ)_/¯

what: Control Service Error
why: The request has not been applied because it lacks valid
authentication credentials.
consequences: Operation cannot complete.
countermeasures: Try to login again using VDK CLI login command. Make
sure you have permission to execute the given operation.
```

Otherwise request would succeed

Testing Done: unit tests.
Test using vdkcli list --all -u rest-api-with-auth and saw above error
produced and then vdkcli list --all -u rest-api-without-auth and saw it
succeeds.

Signed-off-by: Antoni Ivanov <[email protected]>
  • Loading branch information
antoniivanov committed Sep 28, 2021
1 parent a79362f commit 1369020
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 65 deletions.
2 changes: 2 additions & 0 deletions projects/vdk-control-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ VDK Control CLI 1.1
Introduced vdk create --local to create job only locally from sample
vdk create without arguments would create job locally and try to detect if it can created in cloud
vdk create --cloud - always created in cloud only and fail if it cannot
* Auto-detect if authentication is necessary
This remove the need of explicit variable to set/unset authentication. Now vdkcli would detect automatically.

* **Bug Fixes**

Expand Down
4 changes: 3 additions & 1 deletion projects/vdk-control-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ use `vdkcli` command instead of `vdk`.

### Environment variables:

* VDK_AUTHENTICATION_DISABLE - disables security (vdk login will not be required). See Security section.
* VDK_BASE_CONFIG_FOLDER - Override local base configuration folder (by default $HOME folder) . Use in case multiple users need to login (e.g in case of automation) on same machine.
* VDK_CONTROL_SERVICE_REST_API_URL - Default Control Service URL to use if not specified as command line argument
* VDK_API_TOKEN - Default API Token to use if another authentication has not been used with vdk login
* VDK_API_TOKEN_AUTHORIZATION_URL - Default API token URL to use if another authentication has not been used with vdk login.

### Security
By default, all operation require authentication: vdk login must have finished successfully.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Copyright 2021 VMware, Inc.
# SPDX-License-Identifier: Apache-2.0
from typing import Optional

from vdk.internal.control.auth.auth import Authentication
from vdk.internal.control.auth.login_types import LoginTypes
from vdk.internal.control.configuration.vdk_config import VDKConfig


class ApiKeyAuthentication:
"""
Class that execute authentication process using API token.
It will use the API token to get temporary access token using api token authorization URL.
See Authentication class as well.
"""

def __init__(
self,
api_token_authorization_url: Optional[str] = None,
api_token: Optional[str] = None,
):
"""
:param api_token_authorization_url: Authorization URL - Same as login --api-token-authorization-server-url.
:param api_token: API Token - Same as login --api-token.
"""
self.__api_token = api_token
self.__api_token_authorization_url = api_token_authorization_url
self.__auth = Authentication()

def authentication_process(self) -> None:
"""
Executes the authentication process and caches the generated access token so it can be used during REST calls.
"""
self.__auth.update_api_token_authorization_url(
self.__api_token_authorization_url
)
self.__auth.update_api_token(self.__api_token)
self.__auth.update_auth_type(LoginTypes.API_TOKEN.value)
self.__auth.acquire_and_cache_access_token()
88 changes: 49 additions & 39 deletions projects/vdk-control-cli/src/vdk/internal/control/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import json
import logging
import time
from typing import Optional

from requests import post
from requests.auth import HTTPBasicAuth
from requests_oauthlib import OAuth2Session
from vdk.internal.control.auth.auth_request_values import AuthRequestValues
from vdk.internal.control.auth.login_types import LoginTypes
from vdk.internal.control.configuration.vdk_config import VDKConfig
from vdk.internal.control.configuration.vdk_config import VDKConfigFolder
from vdk.internal.control.exception.vdk_exception import VDKException

Expand Down Expand Up @@ -83,25 +85,26 @@ class Authentication:
REFRESH_TOKEN_GRANT_TYPE = "refresh_token" # nosec

def __init__(self, conf=VDKConfigFolder()):
self._conf = conf
self._cache = self.__load_cache()
self.__conf = conf
self.__cache = self.__load_cache()
self.__vdk_config = VDKConfig()

def __load_cache(self):
content = self._conf.read_credentials()
content = self.__conf.read_credentials()
return AuthenticationCacheSerDe.deserialize(content)

def __update_cache(self):
self._conf.save_credentials(AuthenticationCacheSerDe.serialize(self._cache))
self.__conf.save_credentials(AuthenticationCacheSerDe.serialize(self.__cache))

def __exchange_api_for_access_token(self):
try:
log.debug(
f"Refresh API token against {self._cache.api_token_authorization_url} "
f"Refresh API token against {self.__cache.api_token_authorization_url} "
)
client = OAuth2Session()
token_response = client.refresh_token(
self._cache.api_token_authorization_url,
refresh_token=self._cache.api_token,
self.__cache.api_token_authorization_url,
refresh_token=self.__cache.api_token,
)
log.debug(
f"Token response received: "
Expand All @@ -112,7 +115,7 @@ def __exchange_api_for_access_token(self):
except Exception as e:
raise VDKException(
what="Failed to login",
why=f"Authorization server at {self._cache.api_token_authorization_url} returned error: {str(e)}",
why=f"Authorization server at {self.__cache.api_token_authorization_url} returned error: {str(e)}",
consequence="Your credentials are not refreshed and VDK CLI operations that require authentication "
"will not work.",
countermeasure="Check error message and follow instructions in it. Check your network connectivity."
Expand All @@ -129,58 +132,65 @@ def __exchange_api_for_access_token(self):
+ int(token_response.get(AuthRequestValues.EXPIRATION_TIME_KEY.value, "0"))
)

def read_access_token(self):
def read_access_token(self) -> Optional[str]:
"""
Read access token from _cache or fetch it from Authorization server.
If not available in _cache it will get it using provided configuration during VDK CLI login to fetch it.
If it detects that token is about to expire it will try to refresh it.
:return: the access token
:return: the access token or None if it cannot detect any credentials.
"""
if (
not self._cache.access_token
or self._cache.access_token_expiration_time < time.time() + 60
not self.__cache.access_token
or self.__cache.access_token_expiration_time < time.time() + 60
):
log.debug("Acquire access token (it's either expired or missing) ...")
self.acquire_and_cache_access_token()
return self._cache.access_token
return self.__cache.access_token

def acquire_and_cache_access_token(self):
"""
Acquires and caches access token
"""
log.debug(f"Using auth type {self._cache.auth_type} to acquire access token")
log.debug(f"Using auth type {self.__cache.auth_type} to acquire access token")
if (
self._cache.auth_type == LoginTypes.API_TOKEN.value
self.__cache.auth_type == LoginTypes.API_TOKEN.value
and self._configured_api_token()
):
self.__exchange_api_for_access_token()
elif (
self._cache.auth_type == LoginTypes.CREDENTIALS.value
self.__cache.auth_type == LoginTypes.CREDENTIALS.value
and self._configured_refresh_token()
):
self.__exchange_refresh_for_access_token()
elif (
self.__vdk_config.api_token
and self.__vdk_config.api_token_authorization_url
):
self.update_api_token(self.__vdk_config.api_token)
self.update_api_token_authorization_url(
self.__vdk_config.api_token_authorization_url
)
self.__exchange_api_for_access_token()
else:
raise VDKException(
what="Not authenticated.",
why="Most likely VDK CLI login need to be completed first.",
consequence="Operation that require authorization will not work.",
countermeasure="Please execute login using VDK CLI login command",
log.debug(
"No authentication mechanism found. Will not cache access token."
"If Control Service authentication is enabled, API calls will fail."
)

def __exchange_refresh_for_access_token(self):
basic_auth = HTTPBasicAuth(self._cache.client_id, self._cache.client_secret)
basic_auth = HTTPBasicAuth(self.__cache.client_id, self.__cache.client_secret)
headers = {
AuthRequestValues.CONTENT_TYPE_HEADER.value: AuthRequestValues.CONTENT_TYPE_URLENCODED.value,
}
data = (
f"grant_type={self.REFRESH_TOKEN_GRANT_TYPE}&"
+ f"refresh_token={self._cache.refresh_token}"
+ f"refresh_token={self.__cache.refresh_token}"
)
log.debug(
f"Refresh access token against {self._cache.authorization_url} grant_type={self.REFRESH_TOKEN_GRANT_TYPE}"
f"Refresh access token against {self.__cache.authorization_url} grant_type={self.REFRESH_TOKEN_GRANT_TYPE}"
)
response = post(
self._cache.authorization_url, data=data, headers=headers, auth=basic_auth
self.__cache.authorization_url, data=data, headers=headers, auth=basic_auth
)
json_data = json.loads(response.text)
log.debug(
Expand All @@ -195,66 +205,66 @@ def update_api_token_authorization_url(self, api_token_authorization_url):
"""
Updates and caches authorization URL
"""
self._cache.api_token_authorization_url = api_token_authorization_url
self.__cache.api_token_authorization_url = api_token_authorization_url
self.__update_cache()

def update_api_token(self, api_token):
"""
Updates and caches refresh token
"""
self._cache.api_token = api_token
self.__cache.api_token = api_token
self.__update_cache()

def update_refresh_token(self, refresh_token):
"""
Updates and caches refresh token
"""
self._cache.refresh_token = refresh_token
self.__cache.refresh_token = refresh_token
self.__update_cache()

def update_client_id(self, client_id):
"""
Updates and caches refresh token
"""
self._cache.client_id = client_id
self.__cache.client_id = client_id
self.__update_cache()

def update_client_secret(self, client_secret):
"""
Updates and caches refresh token
"""
self._cache.client_secret = client_secret
self.__cache.client_secret = client_secret
self.__update_cache()

def update_access_token(self, access_token):
"""
Updates and caches refresh token
"""
self._cache.access_token = access_token
self.__cache.access_token = access_token
self.__update_cache()

def update_access_token_expiration_time(self, access_token_expiration_time):
"""
Updates and caches refresh token
"""
self._cache.access_token_expiration_time = access_token_expiration_time
self.__cache.access_token_expiration_time = access_token_expiration_time
self.__update_cache()

def update_oauth2_authorization_url(self, authorization_url):
self._cache.authorization_url = authorization_url
self.__cache.authorization_url = authorization_url
self.__update_cache()

def update_auth_type(self, auth_type):
self._cache.auth_type = auth_type
self.__cache.auth_type = auth_type
self.__update_cache()

def _configured_api_token(self):
return self._cache.api_token and self._cache.api_token_authorization_url
return self.__cache.api_token and self.__cache.api_token_authorization_url

def _configured_refresh_token(self):
return (
self._cache.refresh_token
and self._cache.authorization_url
and self._cache.client_id
and self._cache.client_secret
self.__cache.refresh_token
and self.__cache.authorization_url
and self.__cache.client_id
and self.__cache.client_secret
)
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Copyright 2021 VMware, Inc.
# SPDX-License-Identifier: Apache-2.0
import click
from vdk.internal.control.auth.apikey_auth import ApiKeyAuthentication
from vdk.internal.control.auth.auth import Authentication
from vdk.internal.control.auth.login_types import LoginTypes
from vdk.internal.control.auth.redirect_auth import RedirectAuthentication
from vdk.internal.control.configuration.vdk_config import VDKConfig
from vdk.internal.control.exception.vdk_exception import VDKException
from vdk.internal.control.utils import cli_utils
from vdk.internal.control.utils.cli_utils import extended_option
Expand Down Expand Up @@ -131,11 +133,8 @@ def login(
countermeasure="Please login providing correct API Token. ",
)
else:
auth = Authentication()
auth.update_api_token_authorization_url(api_token_authorization_url)
auth.update_api_token(api_token)
auth.update_auth_type(auth_type)
auth.acquire_and_cache_access_token()
apikey_auth = ApiKeyAuthentication(api_token_authorization_url, api_token)
apikey_auth.authentication_process()
click.echo("Login Successful")
else:
click.echo(f"Login type: {auth_type} not supported")
Original file line number Diff line number Diff line change
Expand Up @@ -55,25 +55,17 @@ def local_config_folder(self) -> str:
"""
return os.getenv("VDK_BASE_CONFIG_FOLDER", str(Path.home()))

@property
def authentication_disabled(self) -> bool:
return os.getenv("VDK_AUTHENTICATION_DISABLE", "False").lower() in (
"true",
"1",
"t",
)

@property
def control_service_rest_api_url(self) -> str:
return os.getenv("VDK_CONTROL_SERVICE_REST_API_URL", None)

@property
def api_token_authorization_server_url(self) -> str:
def api_token_authorization_url(self) -> str:
"""
Location of the API Token OAuth2 provider. Same as login --api-token-authorization-server-url
This is used as default.
"""
return os.getenv("VDK_API_TOKEN_AUTHORIZATION_SERVER_URL", None)
return os.getenv("VDK_API_TOKEN_AUTHORIZATION_URL", None)

@property
def api_token(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,11 @@ def __init__(self, rest_api_url):
)
self.config.client_side_validation = False
self.config.verify_ssl = self.vdk_config.http_verify_ssl
if (
self.vdk_config.authentication_disabled
or load_default_authentication_disable()
):
log.info("Authentication is disabled.")
else:
auth = Authentication()
# For now there's no need to add auto-update since this is called usually in a shell script
# and each command will have short execution life even when multiple requests to API are made.
self.config.access_token = auth.read_access_token()

auth = Authentication()
# For now there's no need to add auto-update since this is called usually in a shell script
# and each command will have short execution life even when multiple requests to API are made.
self.config.access_token = auth.read_access_token()

def _new_api_client(self):
api_client = ApiClient(self.config)
Expand Down

0 comments on commit 1369020

Please sign in to comment.