Removal of OAuth2 stuff from CLI

also from awxkit generally

Remove login command
This commit is contained in:
Alan Rominger
2024-11-04 16:21:36 -05:00
parent 670b7e7754
commit 6599f3f827
18 changed files with 17 additions and 320 deletions

View File

@@ -34,7 +34,8 @@ class ControllerAWXKitModule(ControllerModule):
def authenticate(self): def authenticate(self):
try: try:
if self.oauth_token: if self.oauth_token:
self.connection.login(None, None, token=self.oauth_token) # MERGE: fix conflicts with removal of OAuth2 token from collection branch
self.connection.login(None, None)
self.authenticated = True self.authenticated = True
elif self.username: elif self.username:
self.connection.login(username=self.username, password=self.password) self.connection.login(username=self.username, password=self.password)

View File

@@ -86,11 +86,6 @@ options:
- workflow names, IDs, or named URLs to export - workflow names, IDs, or named URLs to export
type: list type: list
elements: str elements: str
applications:
description:
- OAuth2 application names, IDs, or named URLs to export
type: list
elements: str
schedules: schedules:
description: description:
- schedule names, IDs, or named URLs to export - schedule names, IDs, or named URLs to export

View File

@@ -23,7 +23,6 @@ ASSETS = set([
"job_templates", "job_templates",
"workflow_job_templates", "workflow_job_templates",
"execution_environments", "execution_environments",
"applications",
"schedules", "schedules",
]) ])

View File

@@ -13,15 +13,6 @@ class ConnectionException(exc.Common):
pass pass
class Token_Auth(requests.auth.AuthBase):
def __init__(self, token):
self.token = token
def __call__(self, request):
request.headers['Authorization'] = 'Bearer {0.token}'.format(self)
return request
def log_elapsed(r, *args, **kwargs): # requests hook to display API elapsed time def log_elapsed(r, *args, **kwargs): # requests hook to display API elapsed time
log.debug('"{0.request.method} {0.url}" elapsed: {0.elapsed}'.format(r)) log.debug('"{0.request.method} {0.url}" elapsed: {0.elapsed}'.format(r))
@@ -47,7 +38,7 @@ class Connection(object):
self.get(config.api_base_path) # this causes a cookie w/ the CSRF token to be set self.get(config.api_base_path) # this causes a cookie w/ the CSRF token to be set
return dict(next=next) return dict(next=next)
def login(self, username=None, password=None, token=None, **kwargs): def login(self, username=None, password=None, **kwargs):
if username and password: if username and password:
_next = kwargs.get('next') _next = kwargs.get('next')
if _next: if _next:
@@ -62,8 +53,6 @@ class Connection(object):
self.uses_session_cookie = True self.uses_session_cookie = True
else: else:
self.session.auth = (username, password) self.session.auth = (username, password)
elif token:
self.session.auth = Token_Auth(token)
else: else:
self.session.auth = None self.session.auth = None

View File

@@ -4,13 +4,11 @@ from .base import * # NOQA
from .bulk import * # NOQA from .bulk import * # NOQA
from .access_list import * # NOQA from .access_list import * # NOQA
from .api import * # NOQA from .api import * # NOQA
from .authtoken import * # NOQA
from .roles import * # NOQA from .roles import * # NOQA
from .organizations import * # NOQA from .organizations import * # NOQA
from .notifications import * # NOQA from .notifications import * # NOQA
from .notification_templates import * # NOQA from .notification_templates import * # NOQA
from .users import * # NOQA from .users import * # NOQA
from .applications import * # NOQA
from .teams import * # NOQA from .teams import * # NOQA
from .credentials import * # NOQA from .credentials import * # NOQA
from .unified_jobs import * # NOQA from .unified_jobs import * # NOQA

View File

@@ -25,7 +25,6 @@ EXPORTABLE_RESOURCES = [
'job_templates', 'job_templates',
'workflow_job_templates', 'workflow_job_templates',
'execution_environments', 'execution_environments',
'applications',
'schedules', 'schedules',
] ]

View File

@@ -1,82 +0,0 @@
from awxkit.utils import random_title, update_payload, filter_by_class, PseudoNamespace
from awxkit.api.resources import resources
from awxkit.api.pages import Organization
from awxkit.api.mixins import HasCreate, DSAdapter
from . import page
from . import base
class OAuth2Application(HasCreate, base.Base):
dependencies = [Organization]
NATURAL_KEY = ('organization', 'name')
def payload(self, **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'OAuth2Application - {}'.format(random_title()),
description=kwargs.get('description') or random_title(10),
client_type=kwargs.get('client_type', 'public'),
authorization_grant_type=kwargs.get('authorization_grant_type', 'password'),
)
if kwargs.get('organization'):
payload.organization = kwargs['organization'].id
optional_fields = ('redirect_uris', 'skip_authorization')
update_payload(payload, optional_fields, kwargs)
return payload
def create_payload(self, organization=Organization, **kwargs):
self.create_and_update_dependencies(*filter_by_class((organization, Organization)))
organization = self.ds.organization if organization else None
payload = self.payload(organization=organization, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, organization=Organization, **kwargs):
payload = self.create_payload(organization=organization, **kwargs)
return self.update_identity(OAuth2Applications(self.connection).post(payload))
page.register_page((resources.application, (resources.applications, 'post')), OAuth2Application)
class OAuth2Applications(page.PageList, OAuth2Application):
pass
page.register_page(resources.applications, OAuth2Applications)
class OAuth2AccessToken(HasCreate, base.Base):
optional_dependencies = [OAuth2Application]
def payload(self, **kwargs):
payload = PseudoNamespace(description=kwargs.get('description') or random_title(10), scope=kwargs.get('scope', 'write'))
if kwargs.get('oauth_2_application'):
payload.application = kwargs['oauth_2_application'].id
optional_fields = ('expires',)
update_payload(payload, optional_fields, kwargs)
return payload
def create_payload(self, oauth_2_application=None, **kwargs):
self.create_and_update_dependencies(*filter_by_class((oauth_2_application, OAuth2Application)))
oauth_2_application = self.ds.oauth_2_application if oauth_2_application else None
payload = self.payload(oauth_2_application=oauth_2_application, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, oauth_2_application=None, **kwargs):
payload = self.create_payload(oauth_2_application=oauth_2_application, **kwargs)
return self.update_identity(OAuth2AccessTokens(self.connection).post(payload))
page.register_page((resources.token, (resources.tokens, 'post')), OAuth2AccessToken)
class OAuth2AccessTokens(page.PageList, OAuth2AccessToken):
pass
page.register_page(resources.tokens, OAuth2AccessTokens)

View File

@@ -1,10 +0,0 @@
from awxkit.api.resources import resources
from . import base
from . import page
class AuthToken(base.Base):
pass
page.register_page(resources.authtoken, AuthToken)

View File

@@ -1,23 +1,13 @@
import collections
import logging import logging
import typing
from requests.auth import HTTPBasicAuth from awxkit.api.pages import Page
from awxkit.api.pages import Page, get_registered_page, exception_from_status_code
from awxkit.config import config from awxkit.config import config
from awxkit.api.resources import resources
import awxkit.exceptions as exc import awxkit.exceptions as exc
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class AuthUrls(typing.TypedDict):
access_token: str
personal_token: str
class Base(Page): class Base(Page):
def silent_delete(self): def silent_delete(self):
"""Delete the object. If it's already deleted, ignore the error""" """Delete the object. If it's already deleted, ignore the error"""
@@ -135,66 +125,6 @@ class Base(Page):
for obj_role in Roles(self.connection, endpoint=url).get().json.results: for obj_role in Roles(self.connection, endpoint=url).get().json.results:
yield Role(self.connection, endpoint=obj_role.url).get() yield Role(self.connection, endpoint=obj_role.url).get()
def get_authtoken(self, username='', password=''):
default_cred = config.credentials.default
payload = dict(username=username or default_cred.username, password=password or default_cred.password)
auth_url = resources.authtoken
return get_registered_page(auth_url)(self.connection, endpoint=auth_url).post(payload).token
def load_authtoken(self, username='', password=''):
self.connection.login(token=self.get_authtoken(username, password))
return self
load_default_authtoken = load_authtoken
def _request_token(self, auth_urls, username, password, client_id, description, client_secret, scope):
req = collections.namedtuple('req', 'headers')({})
if client_id and client_secret:
HTTPBasicAuth(client_id, client_secret)(req)
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
resp = self.connection.post(
auth_urls["access_token"],
data={"grant_type": "password", "username": username, "password": password, "scope": scope},
headers=req.headers,
)
elif client_id:
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
resp = self.connection.post(
auth_urls["access_token"],
data={"grant_type": "password", "username": username, "password": password, "client_id": client_id, "scope": scope},
headers=req.headers,
)
else:
HTTPBasicAuth(username, password)(req)
resp = self.connection.post(
auth_urls['personal_token'],
json={"description": description, "application": None, "scope": scope},
headers=req.headers,
)
if resp.ok:
result = resp.json()
if client_id:
return result.pop('access_token', None)
else:
return result.pop('token', None)
else:
raise exception_from_status_code(resp.status_code)
def get_oauth2_token(self, username='', password='', client_id=None, description='AWX CLI', client_secret=None, scope='write'):
default_cred = config.credentials.default
username = username or default_cred.username
password = password or default_cred.password
# Try gateway first, fallback to controller
urls: AuthUrls = {"access_token": "/o/token/", "personal_token": f"{config.gateway_base_path}v1/tokens/"}
try:
return self._request_token(urls, username, password, client_id, description, client_secret, scope)
except exc.NotFound:
urls = {
"access_token": f"{config.api_base_path}o/token/",
"personal_token": f"{config.api_base_path}v2/users/{username}/personal_tokens/",
}
return self._request_token(urls, username, password, client_id, description, client_secret, scope)
def load_session(self, username='', password=''): def load_session(self, username='', password=''):
default_cred = config.credentials.default default_cred = config.credentials.default
self.connection.login( self.connection.login(

View File

@@ -12,10 +12,7 @@ class Resources(object):
_ad_hoc_related_cancel = r'ad_hoc_commands/\d+/cancel/' _ad_hoc_related_cancel = r'ad_hoc_commands/\d+/cancel/'
_ad_hoc_relaunch = r'ad_hoc_commands/\d+/relaunch/' _ad_hoc_relaunch = r'ad_hoc_commands/\d+/relaunch/'
_ansible_facts = r'hosts/\d+/ansible_facts/' _ansible_facts = r'hosts/\d+/ansible_facts/'
_application = r'applications/\d+/'
_applications = 'applications/'
_auth = 'auth/' _auth = 'auth/'
_authtoken = 'authtoken/'
_bulk = 'bulk/' _bulk = 'bulk/'
_bulk_job_launch = 'bulk/job_launch/' _bulk_job_launch = 'bulk/job_launch/'
_config = 'config/' _config = 'config/'
@@ -233,8 +230,6 @@ class Resources(object):
_team_permissions = r'teams/\d+/permissions/' _team_permissions = r'teams/\d+/permissions/'
_team_users = r'teams/\d+/users/' _team_users = r'teams/\d+/users/'
_teams = 'teams/' _teams = 'teams/'
_token = r'tokens/\d+/'
_tokens = 'tokens/'
_unified_job_template = r'unified_job_templates/\d+/' _unified_job_template = r'unified_job_templates/\d+/'
_unified_job_templates = 'unified_job_templates/' _unified_job_templates = 'unified_job_templates/'
_unified_jobs = 'unified_jobs/' _unified_jobs = 'unified_jobs/'
@@ -282,12 +277,7 @@ class Resources(object):
def __getattr__(self, resource): def __getattr__(self, resource):
if resource[:3] == '___': if resource[:3] == '___':
raise AttributeError('No existing resource: {}'.format(resource)) raise AttributeError('No existing resource: {}'.format(resource))
# Currently we don't handle anything under: # If/when we add another API version, this would be handled here
# /api/o/
# /api/login/
# /api/logout/
# If/when we do we will probably need to modify this __getattr__ method
# Also, if we add another API version, this would be handled here
prefix = 'v2' prefix = 'v2'
resource = '_' + resource resource = '_' + resource
return '{0}{1}'.format(getattr(self, prefix), getattr(self, resource)) return '{0}{1}'.format(getattr(self, prefix), getattr(self, resource))

View File

@@ -73,7 +73,6 @@ def check_related(resource):
@contextmanager @contextmanager
def as_user(v, username, password=None): def as_user(v, username, password=None):
"""Context manager to allow running tests as an alternative login user.""" """Context manager to allow running tests as an alternative login user."""
access_token = False
if not isinstance(v, api.client.Connection): if not isinstance(v, api.client.Connection):
connection = v.connection connection = v.connection
else: else:
@@ -83,11 +82,6 @@ def as_user(v, username, password=None):
password = username.password password = username.password
username = username.username username = username.username
if isinstance(username, api.OAuth2AccessToken):
access_token = username.token
username = None
password = None
try: try:
if config.use_sessions: if config.use_sessions:
session_id = None session_id = None
@@ -101,10 +95,7 @@ def as_user(v, username, password=None):
break break
if session_id: if session_id:
del connection.session.cookies[connection.session_cookie_name] del connection.session.cookies[connection.session_cookie_name]
if access_token: kwargs = connection.get_session_requirements()
kwargs = dict(token=access_token)
else:
kwargs = connection.get_session_requirements()
else: else:
previous_auth = connection.session.auth previous_auth = connection.session.auth
kwargs = dict() kwargs = dict()
@@ -112,8 +103,6 @@ def as_user(v, username, password=None):
yield yield
finally: finally:
if config.use_sessions: if config.use_sessions:
if access_token:
connection.session.auth = None
del connection.session.cookies[connection.session_cookie_name] del connection.session.cookies[connection.session_cookie_name]
if session_id: if session_id:
connection.session.cookies.set(connection.session_cookie_name, session_id, domain=domain) connection.session.cookies.set(connection.session_cookie_name, session_id, domain=domain)

View File

@@ -82,17 +82,9 @@ class CLI(object):
return '--help' in self.argv or '-h' in self.argv return '--help' in self.argv or '-h' in self.argv
def authenticate(self): def authenticate(self):
"""Configure the current session (or OAuth2.0 token)""" """Configure the current session for basic auth"""
token = self.get_config('token') config.use_sessions = True
if token: self.root.load_session().get()
self.root.connection.login(
None,
None,
token=token,
)
else:
config.use_sessions = True
self.root.load_session().get()
def connect(self): def connect(self):
"""Fetch top-level resources from /api/v2""" """Fetch top-level resources from /api/v2"""
@@ -141,7 +133,7 @@ class CLI(object):
"""Attempt to parse the <resource> (e.g., jobs) specified on the CLI """Attempt to parse the <resource> (e.g., jobs) specified on the CLI
If a valid resource is discovered, the user will be authenticated If a valid resource is discovered, the user will be authenticated
(either via an OAuth2.0 token or session-based auth) and the remaining (via session-based auth) and the remaining
CLI arguments will be processed (to determine the requested action CLI arguments will be processed (to determine the requested action
e.g., list, create, delete) e.g., list, create, delete)

View File

@@ -88,6 +88,3 @@ A few of the most important ones are:
``--conf.password, CONTROLLER_PASSWORD`` ``--conf.password, CONTROLLER_PASSWORD``
the AWX password to use for authentication the AWX password to use for authentication
``--conf.token, CONTROLLER_OAUTH_TOKEN``
an OAuth2.0 token to use for authentication

View File

@@ -30,12 +30,6 @@ def add_authentication_arguments(parser, env):
default=env.get('CONTROLLER_HOST', env.get('TOWER_HOST', 'https://127.0.0.1:443')), default=env.get('CONTROLLER_HOST', env.get('TOWER_HOST', 'https://127.0.0.1:443')),
metavar='https://example.awx.org', metavar='https://example.awx.org',
) )
auth.add_argument(
'--conf.token',
default=env.get('CONTROLLER_OAUTH_TOKEN', env.get('CONTROLLER_TOKEN', env.get('TOWER_OAUTH_TOKEN', env.get('TOWER_TOKEN', '')))),
help='an OAuth2.0 token (get one by using `awx login`)',
metavar='TEXT',
)
config_username, config_password = get_config_credentials() config_username, config_password = get_config_credentials()
# options configured via cli args take higher precedence than those from the config # options configured via cli args take higher precedence than those from the config

View File

@@ -1,14 +1,12 @@
import yaml import yaml
import json import json
import os
from awxkit import api, config, yaml_file from awxkit import config, yaml_file
from awxkit.exceptions import ImportExportError from awxkit.exceptions import ImportExportError
from awxkit.utils import to_str
from awxkit.api.pages import Page from awxkit.api.pages import Page
from awxkit.api.pages.api import EXPORTABLE_RESOURCES from awxkit.api.pages.api import EXPORTABLE_RESOURCES
from awxkit.cli.format import FORMATTERS, format_response, add_authentication_arguments, add_formatting_import_export from awxkit.cli.format import format_response, add_formatting_import_export
from awxkit.cli.utils import CustomRegistryMeta, cprint from awxkit.cli.utils import CustomRegistryMeta
CONTROL_RESOURCES = ['ping', 'config', 'me', 'metrics', 'mesh_visualizer'] CONTROL_RESOURCES = ['ping', 'config', 'me', 'metrics', 'mesh_visualizer']
@@ -66,44 +64,6 @@ class CustomCommand(metaclass=CustomRegistryMeta):
raise NotImplementedError() raise NotImplementedError()
class Login(CustomCommand):
name = 'login'
help_text = 'authenticate and retrieve an OAuth2 token'
def print_help(self, parser):
add_authentication_arguments(parser, os.environ)
parser.print_help()
def handle(self, client, parser):
auth = parser.add_argument_group('OAuth2.0 Options')
auth.add_argument('--description', help='description of the generated OAuth2.0 token', metavar='TEXT')
auth.add_argument('--conf.client_id', metavar='TEXT')
auth.add_argument('--conf.client_secret', metavar='TEXT')
auth.add_argument('--conf.scope', choices=['read', 'write'], default='write')
if client.help:
self.print_help(parser)
raise SystemExit()
parsed = parser.parse_known_args()[0]
kwargs = {
'client_id': getattr(parsed, 'conf.client_id', None),
'client_secret': getattr(parsed, 'conf.client_secret', None),
'scope': getattr(parsed, 'conf.scope', None),
}
if getattr(parsed, 'description', None):
kwargs['description'] = parsed.description
try:
token = api.Api().get_oauth2_token(**kwargs)
except Exception as e:
self.print_help(parser)
cprint('Error retrieving an OAuth2.0 token ({}).'.format(e.__class__), 'red')
else:
fmt = client.get_config('format')
if fmt == 'human':
print('export CONTROLLER_OAUTH_TOKEN={}'.format(token))
else:
print(to_str(FORMATTERS[fmt]({'token': token}, '.')).strip())
class Config(CustomCommand): class Config(CustomCommand):
name = 'config' name = 'config'
help_text = 'print current configuration values' help_text = 'print current configuration values'
@@ -114,7 +74,6 @@ class Config(CustomCommand):
raise SystemExit() raise SystemExit()
return { return {
'base_url': config.base_url, 'base_url': config.base_url,
'token': client.get_config('token'),
'use_sessions': config.use_sessions, 'use_sessions': config.use_sessions,
'credentials': config.credentials, 'credentials': config.credentials,
} }

View File

@@ -37,7 +37,7 @@ class WSClient(object):
'control': ['limit_reached']} 'control': ['limit_reached']}
e.x: e.x:
``` ```
ws = WSClient(token, port=8013, secure=False).connect() ws = WSClient(port=8013, secure=False, session_id='xyz', csrftoken='abc').connect()
ws.job_details() ws.job_details()
... # launch job ... # launch job
job_messages = [msg for msg in ws] job_messages = [msg for msg in ws]
@@ -52,7 +52,6 @@ class WSClient(object):
def __init__( def __init__(
self, self,
token=None,
hostname='', hostname='',
port=443, port=443,
secure=True, secure=True,
@@ -80,15 +79,12 @@ class WSClient(object):
self.suffix = ws_suffix self.suffix = ws_suffix
self._use_ssl = secure self._use_ssl = secure
self.hostname = hostname self.hostname = hostname
self.token = token
self.session_id = session_id self.session_id = session_id
self.csrftoken = csrftoken self.csrftoken = csrftoken
self._recv_queue = Queue() self._recv_queue = Queue()
self._ws_closed = False self._ws_closed = False
self._ws_connected_flag = threading.Event() self._ws_connected_flag = threading.Event()
if self.token is not None: if self.session_id is not None:
auth_cookie = 'token="{0.token}";'.format(self)
elif self.session_id is not None:
auth_cookie = '{1}="{0.session_id}"'.format(self, session_cookie_name) auth_cookie = '{1}="{0.session_id}"'.format(self, session_cookie_name)
if self.csrftoken: if self.csrftoken:
auth_cookie += ';csrftoken={0.csrftoken}'.format(self) auth_cookie += ';csrftoken={0.csrftoken}'.format(self)

View File

@@ -1,9 +1,6 @@
from http.client import NOT_FOUND from http.client import NOT_FOUND
import pytest import pytest
from pytest_mock import MockerFixture
from requests import Response
from awxkit.api.pages import Base
from awxkit.config import config from awxkit.config import config
@@ -22,38 +19,3 @@ def response(mocker):
"access_token": "my_token", "access_token": "my_token",
} }
return r return r
@pytest.mark.parametrize(
("auth_creds", "url", "token"),
[
({"client_id": "foo", "client_secret": "bar"}, "/o/token/", "my_token"),
({"client_id": "foo"}, "/o/token/", "my_token"),
({}, "/api/gateway/v1/tokens/", "my_personal_token"),
],
)
def test_get_oauth2_token_from_gateway(mocker: MockerFixture, response: Response, auth_creds, url, token):
post = mocker.patch("requests.Session.post", return_value=response)
base = Base()
ret = base.get_oauth2_token(**auth_creds)
assert post.call_count == 1
assert post.call_args.args[0] == url
assert ret == token
@pytest.mark.parametrize(
("auth_creds", "url", "token"),
[
({"client_id": "foo", "client_secret": "bar"}, "/api/o/token/", "my_token"),
({"client_id": "foo"}, "/api/o/token/", "my_token"),
({}, "/api/v2/users/foo/personal_tokens/", "my_personal_token"),
],
)
def test_get_oauth2_token_from_controller(mocker: MockerFixture, response: Response, auth_creds, url, token):
type(response).ok = mocker.PropertyMock(side_effect=[False, True])
post = mocker.patch("requests.Session.post", return_value=response)
base = Base()
ret = base.get_oauth2_token(**auth_creds)
assert post.call_count == 2
assert post.call_args.args[0] == url
assert ret == token

View File

@@ -10,11 +10,10 @@ ParseResult = namedtuple("ParseResult", ["port", "hostname", "secure"])
def test_explicit_hostname(): def test_explicit_hostname():
client = WSClient("token", "some-hostname", 556, False) client = WSClient(hostname="some-hostname", port=556, secure=False)
assert client.port == 556 assert client.port == 556
assert client.hostname == "some-hostname" assert client.hostname == "some-hostname"
assert client._use_ssl == False assert client._use_ssl == False
assert client.token == "token"
def test_websocket_suffix(): def test_websocket_suffix():
@@ -35,7 +34,7 @@ def test_urlparsing(url, result):
with patch("awxkit.ws.config") as mock_config: with patch("awxkit.ws.config") as mock_config:
mock_config.base_url = url mock_config.base_url = url
client = WSClient("token") client = WSClient(hostname=None)
assert client.port == result.port assert client.port == result.port
assert client.hostname == result.hostname assert client.hostname == result.hostname
assert client._use_ssl == result.secure assert client._use_ssl == result.secure