add user auth flow
[ADD] DCOS-831 Add login feature
This commit is contained in:
@@ -33,7 +33,7 @@ from subprocess import PIPE, Popen
|
||||
|
||||
import dcoscli
|
||||
import docopt
|
||||
from dcos.api import constants, emitting, errors, http, subcommand, util
|
||||
from dcos.api import auth, constants, emitting, errors, http, subcommand, util
|
||||
from dcoscli import analytics
|
||||
|
||||
emitter = emitting.FlatEmitter()
|
||||
@@ -45,6 +45,11 @@ def main():
|
||||
if not _is_valid_configuration():
|
||||
return 1
|
||||
|
||||
if not auth.check_if_user_authenticated():
|
||||
auth_status = auth.force_auth()
|
||||
if not auth_status:
|
||||
return 1
|
||||
|
||||
args = docopt.docopt(
|
||||
__doc__,
|
||||
version='dcos version {}'.format(dcoscli.version),
|
||||
|
||||
@@ -68,7 +68,8 @@ setup(
|
||||
'toml',
|
||||
'virtualenv',
|
||||
'rollbar',
|
||||
'futures'
|
||||
'futures',
|
||||
'oauth2client'
|
||||
],
|
||||
|
||||
# If there are data files included in your packages that need to be
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
[core]
|
||||
reporting = false
|
||||
email = "test@mail.com"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
[core]
|
||||
reporting = true
|
||||
email = "test@mail.com"
|
||||
|
||||
3
cli/tests/data/auth/dcos_with_credentials.toml
Normal file
3
cli/tests/data/auth/dcos_with_credentials.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[core]
|
||||
reporting = false
|
||||
email = "test@mail.com"
|
||||
2
cli/tests/data/auth/dcos_without_credentials.toml
Normal file
2
cli/tests/data/auth/dcos_without_credentials.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[core]
|
||||
reporting = false
|
||||
@@ -1,10 +1,11 @@
|
||||
[subcommand]
|
||||
pip_find_links = "../dist"
|
||||
[marathon]
|
||||
port = 8080
|
||||
host = "localhost"
|
||||
[package]
|
||||
cache = "tmp/cache"
|
||||
sources = [ "git://github.com/mesosphere/universe.git", "https://github.com/mesosphere/universe/archive/master.zip",]
|
||||
[core]
|
||||
email = "test@mail.com"
|
||||
reporting = false
|
||||
[marathon]
|
||||
host = "localhost"
|
||||
port = 8080
|
||||
[package]
|
||||
sources = [ "git://github.com/mesosphere/universe.git", "https://github.com/mesosphere/universe/archive/master.zip",]
|
||||
cache = "tmp/cache"
|
||||
|
||||
52
cli/tests/integrations/cli/test_auth.py
Normal file
52
cli/tests/integrations/cli/test_auth.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import os
|
||||
import webbrowser
|
||||
|
||||
from dcos.api import auth, constants, util
|
||||
from dcoscli.main import main
|
||||
|
||||
from mock import Mock, patch
|
||||
|
||||
|
||||
def test_no_browser_auth():
|
||||
|
||||
webbrowser.get = Mock(side_effect=webbrowser.Error())
|
||||
with patch('webbrowser.open') as op:
|
||||
_mock_dcos_run([util.which('dcos')], False)
|
||||
assert op.call_count == 0
|
||||
|
||||
|
||||
def test_when_authenticated():
|
||||
with patch('dcos.api.auth.force_auth'):
|
||||
|
||||
_mock_dcos_run([util.which('dcos')], True)
|
||||
assert auth.force_auth.call_count == 0
|
||||
|
||||
|
||||
def test_anonymous_login():
|
||||
with patch('six.moves.input',
|
||||
return_value=''), patch('uuid.uuid1',
|
||||
return_value='anonymous@email'):
|
||||
|
||||
assert _mock_dcos_run([util.which('dcos'),
|
||||
'config', 'show'], False) == 0
|
||||
assert _mock_dcos_run([util.which('dcos'), 'config',
|
||||
'show', 'core.email'], False) == 0
|
||||
assert _mock_dcos_run([util.which('dcos'), 'config',
|
||||
'unset', 'core.email'], False) == 0
|
||||
|
||||
|
||||
def _mock_dcos_run(args, authenticated=True):
|
||||
env = _config_with_credentials() if authenticated else \
|
||||
_config_without_credentials()
|
||||
with patch('sys.argv', args), patch.dict(os.environ, env):
|
||||
return main()
|
||||
|
||||
|
||||
def _config_with_credentials():
|
||||
return {constants.DCOS_CONFIG_ENV: os.path.join(
|
||||
'tests', 'data', 'auth', 'dcos_with_credentials.toml')}
|
||||
|
||||
|
||||
def _config_without_credentials():
|
||||
return {constants.DCOS_CONFIG_ENV: os.path.join(
|
||||
'tests', 'data', 'auth', 'dcos_without_credentials.toml')}
|
||||
@@ -69,7 +69,8 @@ def test_list_property(env):
|
||||
env)
|
||||
|
||||
assert returncode == 0
|
||||
assert stdout == b"""core.reporting=False
|
||||
assert stdout == b"""core.email=test@mail.com
|
||||
core.reporting=False
|
||||
marathon.host=localhost
|
||||
marathon.port=8080
|
||||
package.cache=tmp/cache
|
||||
|
||||
152
dcos/api/auth.py
Normal file
152
dcos/api/auth.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pkg_resources
|
||||
import toml
|
||||
from dcos.api import config, constants, emitting, errors, http, jsonitem
|
||||
from six import iteritems, moves
|
||||
|
||||
from oauth2client import client
|
||||
|
||||
CLIENT_ID = '6a552732-ab9b-410d-9b7d-d8c6523b09a1'
|
||||
CLIENT_SECRET = 'f56c1e2b-8599-40ca-b6a0-3aba3e702eae'
|
||||
AUTH_URL = 'https://accounts.mesosphere.com/oauth/authorize'
|
||||
TOKEN_URL = 'https://accounts.mesosphere.com/oauth/token'
|
||||
USER_INFO_URL = 'https://accounts.mesosphere.com/api/v1/user.json'
|
||||
CORE_TOKEN_KEY = 'token'
|
||||
CORE_EMAIL_KEY = 'email'
|
||||
emitter = emitting.FlatEmitter()
|
||||
|
||||
|
||||
def _authorize():
|
||||
"""Create OAuth flow and authorize user
|
||||
|
||||
:return: Tuple of credentials dict end Error
|
||||
:rtype: (dict, dcos.api.errors.Error)
|
||||
"""
|
||||
try:
|
||||
flow = client.OAuth2WebServerFlow(
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
scope='',
|
||||
auth_uri=AUTH_URL,
|
||||
token_uri=TOKEN_URL,
|
||||
redirect_uri=client.OOB_CALLBACK_URN,
|
||||
response_type='code'
|
||||
)
|
||||
res = _run(flow)
|
||||
return res, None
|
||||
except:
|
||||
err = errors.DefaultError('There was a problem with '
|
||||
'web authentication.')
|
||||
return None, err
|
||||
|
||||
|
||||
def make_oauth_request(code, flow):
|
||||
"""Make request to auth server using auth_code.
|
||||
|
||||
:param: code: auth_code read from cli
|
||||
:param: flow: OAuth2 web server flow
|
||||
:return: dict with the keys token and email
|
||||
:rtype: dict
|
||||
"""
|
||||
credential = flow.step2_exchange(code)
|
||||
token = credential.access_token
|
||||
headers = {'Authorization': str('Bearer ' + token)}
|
||||
data = http.requests.get(USER_INFO_URL, headers=headers).json()
|
||||
mail = data['email']
|
||||
credentials = {CORE_TOKEN_KEY: credential.access_token,
|
||||
CORE_EMAIL_KEY: mail}
|
||||
return credentials
|
||||
|
||||
|
||||
def _run(flow):
|
||||
"""Make authorization and retrieve access token and user email.
|
||||
|
||||
:param flow: OAuth2 web server flow
|
||||
:param launch_browser: if possible to run browser
|
||||
:return: dict with the keys token and email
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
auth_url = flow.step1_get_authorize_url()
|
||||
message = """Thank you for installing the Mesosphere DCOS CLI.
|
||||
Please log in with your Mesosphere Account by pasting
|
||||
the following URL into your browser to continue."""
|
||||
emitter.publish(errors.DefaultError(
|
||||
'{message}\n\n {url}\n\n'.format(message=message,
|
||||
url=auth_url,)))
|
||||
|
||||
code = moves.input('Please enter Mesosphere verification code: ').strip()
|
||||
if not code:
|
||||
email = moves.input('Skipping authentication.'
|
||||
' Please enter email address:').strip()
|
||||
if not email:
|
||||
emitter.publish(errors.DefaultError('Skipping email input,'
|
||||
' using anonymous id:'))
|
||||
email = str(uuid.uuid1())
|
||||
return {CORE_EMAIL_KEY: email}
|
||||
|
||||
return make_oauth_request(code, flow)
|
||||
|
||||
|
||||
def check_if_user_authenticated():
|
||||
""" check if user is authenticated already
|
||||
|
||||
:returns user auth status
|
||||
:rtype: boolean
|
||||
"""
|
||||
|
||||
dcos_config = config.load_from_path(
|
||||
os.environ[constants.DCOS_CONFIG_ENV])
|
||||
return dcos_config.get('core.email', '') != ''
|
||||
|
||||
|
||||
def force_auth():
|
||||
""" Make user authentication process
|
||||
|
||||
:returns authentication process status
|
||||
:rtype: boolean
|
||||
"""
|
||||
|
||||
credentials, error = _authorize()
|
||||
if error is not None:
|
||||
emitter.publish(error)
|
||||
return False
|
||||
else:
|
||||
error = _save_auth_keys(credentials)
|
||||
if error is not None:
|
||||
emitter.publish(error)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _save_auth_keys(key_dict):
|
||||
"""
|
||||
:param key_dict: auth parameters dict
|
||||
:type key_dict: dict
|
||||
:returns: Error value
|
||||
:rtype: Error
|
||||
"""
|
||||
|
||||
config_path = os.environ[constants.DCOS_CONFIG_ENV]
|
||||
toml_config = config.mutable_load_from_path(config_path)
|
||||
|
||||
section = 'core'
|
||||
config_schema = json.loads(
|
||||
pkg_resources.resource_string(
|
||||
'dcoscli',
|
||||
'data/config-schema/core.json').decode('utf-8'))
|
||||
for k, v in iteritems(key_dict):
|
||||
python_value, err = jsonitem.parse_json_value(k, v, config_schema)
|
||||
if err is not None:
|
||||
return err
|
||||
name = '{}.{}'.format(section, k)
|
||||
toml_config[name] = python_value
|
||||
|
||||
serial = toml.dumps(toml_config._dictionary)
|
||||
with open(config_path, 'w') as config_file:
|
||||
config_file.write(serial)
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user