Skip to content

Commit 4a4410a

Browse files
authored
CM-13018 - implement auth command (#16)
* WIP * minor refactor to scan client * add schemas * add auth client * WIP * WIP * almost done * fix bugbugon
1 parent 75d9c10 commit 4a4410a

12 files changed

Lines changed: 413 additions & 188 deletions

cli/auth/auth_command.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import click
2+
import traceback
3+
from cli.auth.auth_manager import AuthManager
4+
from cli.exceptions.custom_exceptions import AuthProcessError, CycodeError
5+
from cyclient import logger
6+
7+
8+
@click.command()
9+
@click.pass_context
10+
def authenticate(context: click.Context):
11+
""" Initial command to authenticate your CLI - TODO better text """
12+
try:
13+
logger.debug("starting authentication process")
14+
auth_manager = AuthManager()
15+
auth_manager.authenticate()
16+
click.echo("success TODO TEXT")
17+
except Exception as e:
18+
_handle_exception(context, e)
19+
20+
21+
def _handle_exception(context: click.Context, e: Exception):
22+
verbose = context.obj["verbose"]
23+
if verbose:
24+
click.secho(f'Error: {traceback.format_exc()}', fg='red', nl=False)
25+
if isinstance(e, AuthProcessError):
26+
click.secho('Authentication process has failed. Please try again by executing the `cycode auth` command',
27+
fg='red', nl=False)
28+
elif isinstance(e, CycodeError):
29+
click.secho('TBD message. Please try again by executing the `cycode auth` command',
30+
fg='red', nl=False)
31+
elif isinstance(e, click.ClickException):
32+
raise e
33+
else:
34+
raise click.ClickException(str(e))

cli/auth/auth_manager.py

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,101 @@
1+
import time
2+
import webbrowser
3+
from requests import Request
4+
from typing import Optional
5+
from cli.exceptions.custom_exceptions import AuthProcessError
16
from cli.utils.string_utils import generate_random_string, hash_string_to_sha256
27
from cli.user_settings.configuration_manager import ConfigurationManager
8+
from cli.user_settings.credentials_manager import CredentialsManager
9+
from cyclient.auth_client import AuthClient
10+
from cyclient.models import ApiToken, ApiTokenGenerationPollingResponse
11+
from cyclient import logger
312

413

514
class AuthManager:
6-
715
CODE_VERIFIER_LENGTH = 101
16+
POLLING_WAIT_INTERVAL_IN_SECONDS = 3
17+
POLLING_TIMEOUT_IN_SECONDS = 180
18+
FAILED_POLLING_STATUS = "Error"
19+
COMPLETED_POLLING_STATUS = "Completed"
820

921
configuration_manager: ConfigurationManager
22+
credentials_manager: CredentialsManager
23+
auth_client: AuthClient
1024

1125
def __init__(self):
1226
self.configuration_manager = ConfigurationManager()
27+
self.credentials_manager = CredentialsManager()
28+
self.auth_client = AuthClient()
29+
30+
def authenticate(self):
31+
logger.debug('generating pkce code pair')
32+
code_challenge, code_verifier = self._generate_pkce_code_pair()
33+
34+
logger.debug('starting authentication session')
35+
session_id = self.start_session(code_challenge)
36+
logger.debug('authentication session created, %s', {'session_id': session_id})
37+
38+
logger.debug('opening browser and redirecting to cycode login page')
39+
self.redirect_to_login_page(code_challenge, session_id)
40+
41+
logger.debug('starting get api token process')
42+
api_token = self.get_api_token(session_id, code_verifier)
43+
44+
logger.debug('saving get api token')
45+
self.save_api_token(api_token)
46+
47+
def start_session(self, code_challenge: str):
48+
auth_session = self.auth_client.start_session(code_challenge)
49+
return auth_session.session_id
50+
51+
def redirect_to_login_page(self, code_challenge: str, session_id: str):
52+
login_url = self._build_login_url(code_challenge, session_id)
53+
webbrowser.open(login_url)
1354

14-
def generate_pkce_code_pair(self) -> (str, str):
55+
def get_api_token(self, session_id: str, code_verifier: str) -> Optional[ApiToken]:
56+
api_token = self.get_api_token_polling(session_id, code_verifier)
57+
if api_token is None:
58+
raise AuthProcessError("getting api token is completed, but the token is missing")
59+
return api_token
60+
61+
def get_api_token_polling(self, session_id: str, code_verifier: str) -> Optional[ApiToken]:
62+
end_polling_time = time.time() + self.POLLING_TIMEOUT_IN_SECONDS
63+
while time.time() < end_polling_time:
64+
logger.debug('trying to get api token...')
65+
api_token_polling_response = self.auth_client.get_api_token(session_id, code_verifier)
66+
if self._is_api_token_process_completed(api_token_polling_response):
67+
logger.debug('get api token process completed')
68+
return api_token_polling_response.api_token
69+
if self._is_api_token_process_failed(api_token_polling_response):
70+
logger.debug('get api token process failed')
71+
raise AuthProcessError('error during getting api token')
72+
time.sleep(self.POLLING_WAIT_INTERVAL_IN_SECONDS)
73+
74+
raise AuthProcessError('session expired')
75+
76+
def save_api_token(self, api_token: ApiToken):
77+
self.credentials_manager.update_credentials_file(api_token.client_id, api_token.secret)
78+
79+
def _build_login_url(self, code_challenge: str, session_id: str):
80+
app_url = self.configuration_manager.get_cycode_app_url()
81+
login_url = f'{app_url}/account/login'
82+
query_params = {
83+
'source': 'cycode_cli',
84+
'code_challenge': code_challenge,
85+
'session_id': session_id
86+
}
87+
request = Request(url=login_url, params=query_params)
88+
return request.prepare().url
89+
90+
def _generate_pkce_code_pair(self) -> (str, str):
1591
code_verifier = generate_random_string(self.CODE_VERIFIER_LENGTH)
1692
code_challenge = hash_string_to_sha256(code_verifier)
17-
return code_challenge, code_verifier
93+
return code_challenge, code_verifier
94+
95+
def _is_api_token_process_completed(self, api_token_polling_response: ApiTokenGenerationPollingResponse) -> bool:
96+
return api_token_polling_response is not None \
97+
and api_token_polling_response.status == self.COMPLETED_POLLING_STATUS
98+
99+
def _is_api_token_process_failed(self, api_token_polling_response: ApiTokenGenerationPollingResponse) -> bool:
100+
return api_token_polling_response is not None \
101+
and api_token_polling_response.status == self.FAILED_POLLING_STATUS

cli/cycode.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import sys
44
from cli.config import config
55
from cli import code_scanner, __version__
6-
from cyclient import ScanClient, K8SUpdaterClient, logger
6+
from cyclient import ScanClient, logger
77
from cli.user_settings.credentials_manager import CredentialsManager
88
from cli.user_settings.user_settings_commands import set_credentials, add_exclusions
9+
from cli.auth.auth_command import authenticate
910
from cli.user_settings.configuration_manager import ConfigurationManager
11+
from cli.auth.auth_manager import AuthManager
1012

1113
CONTEXT = dict()
1214
ISSUE_DETECTED_STATUS_CODE = 1
@@ -72,7 +74,7 @@ def code_scan(context: click.Context, scan_type, client_id, secret, show_secret,
7274

7375
context.obj["scan_type"] = scan_type
7476
context.obj["output"] = output
75-
context.obj["client"] = get_cycode_client(client_id, secret, "code_scan")
77+
context.obj["client"] = get_cycode_client(client_id, secret)
7678

7779
return 1
7880

@@ -90,7 +92,8 @@ def finalize(context: click.Context, *args, **kwargs):
9092
commands={
9193
"scan": code_scan,
9294
"configure": set_credentials,
93-
"ignore": add_exclusions
95+
"ignore": add_exclusions,
96+
"auth": authenticate
9497
},
9598
context_settings=CONTEXT
9699
)
@@ -108,18 +111,15 @@ def main_cli(context: click.Context, verbose: bool):
108111
logger.setLevel(log_level)
109112

110113

111-
def get_cycode_client(client_id, client_secret, execution_type):
114+
def get_cycode_client(client_id, client_secret):
112115
if not client_id or not client_secret:
113116
client_id, client_secret = _get_configured_credentials()
114117
if not client_id:
115118
raise click.ClickException("Cycode client id needed.")
116119
if not client_secret:
117120
raise click.ClickException("Cycode client secret is needed.")
118121

119-
if execution_type == "code_scan":
120-
return ScanClient(client_secret=client_secret, client_id=client_id)
121-
122-
return K8SUpdaterClient(client_secret=client_secret, client_id=client_id)
122+
return ScanClient(client_secret=client_secret, client_id=client_id)
123123

124124

125125
def _get_configured_credentials():

cli/exceptions/custom_exceptions.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ def __init__(self, status_code: int, error_message: str):
66
super().__init__(self.error_message)
77

88
def __str__(self):
9-
return f'error occurred during scan request. status code: {self.status_code}, error message: ' \
9+
return f'error occurred during the request. status code: {self.status_code}, error message: ' \
1010
f'{self.error_message}'
1111

1212

@@ -27,3 +27,12 @@ def __init__(self, size_limit: int):
2727

2828
def __str__(self):
2929
return f'The size of zip to scan is too large, size limit: {self.size_limit}'
30+
31+
32+
class AuthProcessError(Exception):
33+
def __init__(self, error_message: str):
34+
self.error_message = error_message
35+
super().__init__()
36+
37+
def __str__(self):
38+
return f'Something went wrong during the authentication process, error message: {self.error_message}'

cyclient/__init__.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"""Cycode Client"""
2-
from .client import CycodeClient
2+
from .cycode_client import CycodeClient
33
from .scan_client import ScanClient
4-
from .k8s_updater_client import K8SUpdaterClient
54
from .config import logger
65

76
__version__ = "0.0.15"
87

98
__all__ = [
109
"CycodeClient",
1110
"ScanClient",
12-
"K8SUpdaterClient",
1311
"logger"
1412
]

cyclient/auth_client.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import requests.exceptions
2+
from requests import Response
3+
from typing import Optional
4+
from .cycode_client import CycodeClient
5+
from . import models
6+
from cli.exceptions.custom_exceptions import CycodeError
7+
8+
9+
class AuthClient:
10+
AUTH_CONTROLLER_PATH = 'api/v1/device-auth'
11+
12+
def __init__(self):
13+
self.cycode_client = CycodeClient()
14+
15+
def start_session(self, code_challenge: str):
16+
path = f"{self.AUTH_CONTROLLER_PATH}/start"
17+
body = {'code_challenge': code_challenge}
18+
try:
19+
response = self.cycode_client.post(url_path=path, body=body)
20+
return self.parse_start_session_response(response)
21+
except requests.exceptions.Timeout as e:
22+
raise CycodeError(504, e.response.text)
23+
except requests.exceptions.HTTPError as e:
24+
raise CycodeError(e.response.status_code, e.response.text)
25+
26+
def get_api_token(self, session_id: str, code_verifier: str) -> Optional[models.ApiTokenGenerationPollingResponse]:
27+
path = f"{self.AUTH_CONTROLLER_PATH}/token"
28+
body = {'session_id': session_id, 'code_verifier': code_verifier}
29+
try:
30+
response = self.cycode_client.post(url_path=path, body=body)
31+
return self.parse_api_token_polling_response(response)
32+
except requests.exceptions.HTTPError as e:
33+
return self.parse_api_token_polling_response(e.response)
34+
except Exception as e:
35+
return None
36+
37+
@staticmethod
38+
def parse_start_session_response(response: Response) -> models.AuthenticationSession:
39+
return models.AuthenticationSessionSchema().load(response.json())
40+
41+
@staticmethod
42+
def parse_api_token_polling_response(response: Response) -> Optional[models.ApiTokenGenerationPollingResponse]:
43+
try:
44+
return models.ApiTokenGenerationPollingResponseSchema().load(response.json())
45+
except Exception as e:
46+
return None

0 commit comments

Comments
 (0)