Skip to content

Commit

Permalink
Merge pull request #5 from aitomatic/feat/implement_login
Browse files Browse the repository at this point in the history
Refactor login command
  • Loading branch information
phamhoangtuan authored Mar 31, 2022
2 parents 22cc915 + 85cb267 commit 97645ac
Show file tree
Hide file tree
Showing 9 changed files with 112 additions and 74 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,9 @@ Both the above commands would install the package globally and `aito` will be av

## How to use

- `aito login`: Login to Aitomatic account
- `aito app deploy`: Deploy app to Aitomatic cloud

## Feedback

In order to report issues, please open one in https://github.com/aitomatic/aitomatic-cli/issues
3 changes: 2 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ classifiers =
packages =
src
src.app
src.login
install_requires =
click >= 8.0.4
requests >= 2.27.1
python_requires = >=3.7

[options.entry_points]
console_scripts =
aito = src.cli:cli
aito = src.aito:cli
32 changes: 32 additions & 0 deletions src/aito.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import click
import json
from pathlib import Path
from src.app.main import app
from src.login.main import login

CREDENTIAL_FILE = Path.home().joinpath('.aitomatic/credentials')


def load_config():
if CREDENTIAL_FILE.exists():
return json.loads(CREDENTIAL_FILE.read_text())
else:
return {}


AUTH_INFO = load_config()


@click.group(
context_settings={'obj': AUTH_INFO},
)
def cli():
'''Aitomatic CLI tool to help manage aitomatic projects and apps'''


cli.add_command(login)
cli.add_command(app)


if __name__ == '__main__':
cli()
11 changes: 0 additions & 11 deletions src/app/cli.py

This file was deleted.

3 changes: 2 additions & 1 deletion src/app/deploy.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import click
from src.login.cli import authenticated
from src.login.main import authenticated


@click.command()
@authenticated
def deploy():
'''Deploy app to Aitomatic cluster'''
click.echo('Deploy app to Aitomatic')
# click.echo('User info', obj)
10 changes: 10 additions & 0 deletions src/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import click
from src.app.deploy import deploy


@click.group()
def app():
'''CLI sub-command to help manage aitomatic apps'''


app.add_command(deploy)
27 changes: 0 additions & 27 deletions src/cli.py

This file was deleted.

93 changes: 60 additions & 33 deletions src/login/cli.py → src/login/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,127 +5,154 @@
from pathlib import Path
from functools import update_wrapper

ORG = 'aitomaticinc.us.auth0.com';
ORG = 'aitomaticinc.us.auth0.com'
CLIENT_ID = "zk9AB0KtNqJY0gVeF1p0ZmUb2tlcXpYq"
AUDIENCE = "https://apps.aitomatic.com/dev"
SCOPE = "openid profile email offline_access"
CONFIG_FILE = Path.home().joinpath('.aitomatic')
CREDENTIAL_FILE = Path.home().joinpath('.aitomatic/credentials')

@click.command(help='''
Login to Aitomatic cloud
''')

@click.command()
@click.pass_obj
def login(obj):
if obj.get("at") is not None or CONFIG_FILE.exists():
relogin = click.confirm("You're logged in. Do you want to log in again?", default=False, abort=False, prompt_suffix=': ', show_default=True, err=False)
'''Login to Aitomatic cloud'''
if obj.get("access_token") is not None or CREDENTIAL_FILE.exists():
re_login = click.confirm(
"You're logged in. Do you want to log in again?",
default=False,
abort=False,
prompt_suffix=': ',
show_default=True,
err=False,
)

if not relogin:
if not re_login:
exit(0)

do_login()


def do_login():
click.echo('Logging into Aitomatic cloud...')
device_info = request_device_code()
display_device_info(device_info)
poll_authentication_status(device_info)


def request_device_code():
res = requests.post(
url="https://{}/oauth/device/code".format(ORG),
data={"client_id": CLIENT_ID, "scope": SCOPE, "audience": AUDIENCE},
headers={ "content-type": "application/x-www-form-urlencoded" }
headers={"content-type": "application/x-www-form-urlencoded"},
)

# see details https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-device-authorization-flow#request-device-code
# print(device_response)
# {
# 'device_code': 'kJ3fIJ90RYXbdAOlhns3v7t3',
# 'user_code': '873280791',
# 'verification_uri': 'https://aitomaticinc.us.auth0.com/activate',
# 'expires_in': 900,
# 'interval': 5,
# 'device_code': 'kJ3fIJ90RYXbdAOlhns3v7t3',
# 'user_code': '873280791',
# 'verification_uri': 'https://aitomaticinc.us.auth0.com/activate',
# 'expires_in': 900,
# 'interval': 5,
# 'verification_uri_complete': 'https://aitomaticinc.us.auth0.com/activate?user_code=873280791'
# }

return res.json()


def display_device_info(device_info):
code = device_info['user_code']
url = device_info['verification_uri_complete']

click.echo("""
click.echo(
"""
Please visit:
{}
to login to Aitomatic cloud.
Verification code: {}
""".format(url, code))
""".format(
url, code
)
)

click.launch(url)

click.echo("Waiting for authentication...")


@click.pass_obj
def poll_authentication_status(obj, device_info):
res = requests.post(
url="https://{}/oauth/token".format(ORG),
data={
"client_id": CLIENT_ID,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_info['device_code']
"client_id": CLIENT_ID,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_info['device_code'],
},
headers={ "content-type": "application/x-www-form-urlencoded" }
headers={"content-type": "application/x-www-form-urlencoded"},
)

# response example
# {'error': 'authorization_pending', 'error_description': 'User has yet to authorize device code.'}
# {"error": "expired_token", "error_description": "..." }
# {"error": "access_denied", "error_description": "..." }
# {"access_token": "...", "id_token": "...", "refresh_token": "..."}
polling_data = res.json();
polling_data = res.json()

if polling_data.get('error') == 'authorization_pending':
time.sleep(device_info['interval'])
poll_authentication_status(device_info)

if polling_data.get('error') == 'expired_token' or polling_data.get('error') == 'access_denied':
if (
polling_data.get('error') == 'expired_token'
or polling_data.get('error') == 'access_denied'
):
click.echo(polling_data['error_description'])
exit(1)

if polling_data.get('access_token') is not None:
save_config({ 'at': polling_data['access_token'], 'rt': polling_data['refresh_token'], 'id': polling_data['id_token'] })
save_credential(
{
'access_token': polling_data['access_token'],
'refresh_token': polling_data['refresh_token'],
'id': polling_data['id_token'],
}
)
click.echo("Login successful!")


@click.pass_obj
def save_config(obj, data):
obj['at'] = data['at']
obj['rt'] = data['rt']
def save_credential(obj, data):
obj['access_token'] = data['access_token']
obj['refresh_token'] = data['refresh_token']
obj['id'] = data['id']
CONFIG_FILE.write_text(json.dumps(data))
if not CREDENTIAL_FILE.exists():
CREDENTIAL_FILE.parent.mkdir(parents=True)
CREDENTIAL_FILE.write_text(json.dumps(data))


def authenticated(f):
@click.pass_obj
def wrapper(obj, *args, **kwargs):
token = obj and obj.get("at")
token = obj and obj.get("access_token")

if token is None:
prompt_login()
exit(1)

res = requests.get(
url="https://{}/userinfo".format(ORG),
headers={ "Authorization": "Bearer {}".format(token) }
headers={"Authorization": "Bearer {}".format(token)},
)

if (res.status_code == 200):
if res.status_code == 200:
f(*args, **kwargs)
else:
prompt_login()
exit(1)

return update_wrapper(wrapper, f)


def prompt_login():
click.echo("You're not logged in. Please run `aito login` first.")

2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.0
0.3.0

0 comments on commit 97645ac

Please sign in to comment.