Fix for multiple accounts and caching

This commit is contained in:
Mahbub Murshed 2023-02-18 23:57:20 -08:00
Родитель 09bd9e3152
Коммит 604dfc0b32
10 изменённых файлов: 170 добавлений и 136 удалений

Просмотреть файл

@ -13,7 +13,7 @@
<ProjectTypeGuids>{888888a0-9f3d-457c-b088-3a5042f75d52}</ProjectTypeGuids>
<LaunchProvider>Standard Python launcher</LaunchProvider>
<InterpreterId>MSBuild|venv|$(MSBuildProjectFullPath)</InterpreterId>
<CommandLineArguments>validate -d D:\src\PowerPlatformConnectors\tools\paconn-cli\shared_bugbash-5fcustomcodeconnectora-5f9518e3c234f950a8\apiDefinition.swagger.json</CommandLineArguments>
<CommandLineArguments>logout</CommandLineArguments>
<EnableNativeCodeDebugging>False</EnableNativeCodeDebugging>
<Name>paconn</Name>
<IsWindowsApplication>False</IsWindowsApplication>
@ -27,7 +27,6 @@
<Compile Include="paconn\apimanager\__init__.py" />
<Compile Include="paconn\authentication\auth.py" />
<Compile Include="paconn\authentication\msalprofile.py" />
<Compile Include="paconn\authentication\tokenmanager.py" />
<Compile Include="paconn\authentication\__init__.py" />
<Compile Include="paconn\commands\commands.py" />
<Compile Include="paconn\commands\logout.py" />

Просмотреть файл

@ -8,7 +8,7 @@
Initializer
"""
__VERSION__ = '0.1.1'
__VERSION__ = '0.1.2'
__CLI_NAME__ = 'paconn'
# Commands

Просмотреть файл

@ -7,22 +7,54 @@
"""
Authentication methods
"""
import os
import sys
from paconn.authentication.msalprofile import MsalProfile
from paconn.authentication.tokenmanager import TokenManager
from paconn.settings.authsettings import AuthSettings
from paconn.settings.authsettingsserializer import AuthSettingsSerializer
from paconn.common.util import get_config_dir
from msal_extensions import *
def get_authentication(settings, auth_type='interactive'):
TOKEN_FILE = 'accessTokens.bin'
def _build_persistence(token_file, fallback_to_plaintext=False):
"""Build a suitable persistence instance based your current OS"""
if sys.platform.startswith('win'):
return FilePersistenceWithDataProtection(token_file)
if sys.platform.startswith('darwin'):
return KeychainPersistence(token_file, "paconn", "paconn")
if sys.platform.startswith('linux'):
try:
return LibsecretPersistence(
token_file,
schema_name='paconn',
attributes={"appName": "paconn"})
except:
if not fallback_to_plaintext:
raise
logging.exception("Encryption unavailable. Opting in to plain text.")
return FilePersistence(token_file)
def _get_token_cache():
token_file = os.path.join(get_config_dir(), TOKEN_FILE)
persistence = _build_persistence(token_file)
token_cache = PersistedTokenCache(persistence)
return token_cache
def get_authentication(settings, auth_type='interactive', force_interactive=False):
"""
Logs the user in and saves the token in a file.
"""
# Read authentication settings
auth_settings = AuthSettingsSerializer.read_with_cli_settings(settings)
# Read last saved token
tokenmanager = TokenManager()
token_cache = tokenmanager.read()
token_cache = _get_token_cache()
# Get new token
profile = MsalProfile(
@ -30,18 +62,17 @@ def get_authentication(settings, auth_type='interactive'):
token_cache=token_cache)
if auth_type == 'interactive':
(result, account) = profile.auth_interactive()
(result, account) = profile.auth_interactive(force_interactive=force_interactive)
else:
(result, account) = profile.authenticate_silent()
# Save token
if token_cache.has_state_changed:
tokenmanager.write(token_cache)
# Save authentication settings
if account:
if account and 'username' in account:
auth_settings.username = account['username']
AuthSettingsSerializer.write(auth_settings)
else:
auth_settings.username = None
AuthSettingsSerializer.write(auth_settings)
return (result, account)
@ -51,5 +82,11 @@ def get_silent_authentication():
auth_type = 'silent')
def remove_authentication():
tokenmanager = TokenManager()
tokenmanager.delete_token_file()
# Read authentication settings
auth_settings = AuthSettingsSerializer.read()
token_cache = _get_token_cache()
profile = MsalProfile(
auth_settings=auth_settings,
token_cache=token_cache)
account = profile.logout()
return account

Просмотреть файл

@ -9,7 +9,6 @@ User profile management class.`
"""
from msal import PublicClientApplication
from knack.prompting import prompt_choice_list
from paconn.common.util import format_json
from knack.util import CLIError
from paconn.settings.authsettings import AuthSettings
@ -26,60 +25,96 @@ class MsalProfile:
authority=auth_settings.get_authority_tenant(),
token_cache=token_cache)
def get_account_from_username(self, username, prompt=False):
accounts = self.app.get_accounts(username)
if accounts:
return accounts[0]
return None
def authenticate_silent(self):
result = None
account = self.get_account_from_username(
username=self.auth_settings.username)
result = self.app.acquire_token_silent_with_error(
scopes=self.auth_settings.scopes,
account=account,
force_refresh=True)
return (result, account)
def get_account_from_username_prompt(self, username, prompt_option):
account = None
accounts = None
accounts = self.app.get_accounts()
if accounts:
acc_id = 0
if len(accounts) > 1:
usernames = list(account['username'] for account in accounts)
# Find the last used username
acc_id = 1
try:
acc_id = usernames.index(self.auth_settings.username)
except ValueError:
acc_id = 1
usernames = list(account['username'] for account in accounts)
acc_id = prompt_choice_list('Please select an account:', usernames, acc_id)
account = accounts[acc_id]
result = self.app.acquire_token_silent(self.auth_settings.scopes, account=account)
return (result, account)
def authenticate_device_flow(self):
"""
Authenticate the end-user using device auth.
"""
(result, account) = self.authenticate_silent()
if not result:
flow = self.app.initiate_device_flow(scopes=self.auth_settings.scopes)
if "user_code" not in flow:
raise CLIError("Fail to create device flow. Err: %s" % format_json(flow))
print(flow['message'])
sys.stdout.flush()
try:
acc_id = usernames.index(username)
except ValueError:
acc_id = len(usernames)
result = self.app.acquire_token_by_device_flow(flow)
usernames.append(prompt_option)
if not account:
accounts = self.app.get_accounts()
if accounts:
account = accounts[0]
acc_id = acc_id + 1
acc_id = prompt_choice_list(
'Please select an account:',
usernames,
acc_id)
if acc_id < len(accounts):
account = accounts[acc_id]
return account
def authenticate_silent_prompt(self):
account = self.get_account_from_username_prompt(
username=self.auth_settings.username,
prompt_option='Add a new account')
result = self.app.acquire_token_silent_with_error(
scopes=self.auth_settings.scopes,
account=account,
force_refresh=True)
return (result, account)
def auth_interactive(self):
(result, account) = self.authenticate_silent()
def auth_interactive(self, force_interactive=False):
(result, account) = (None, None)
if not result:
result = self.app.acquire_token_interactive(scopes=self.auth_settings.scopes)
if not force_interactive:
(result, account) = self.authenticate_silent_prompt()
if not account:
accounts = self.app.get_accounts()
if accounts:
account = accounts[0]
if not result or 'error' in result:
if not account:
account = self.get_account_from_username(self.auth_settings.username)
return (result, account)
username = None
if account and 'username' in account:
username = account['username']
result = self.app.acquire_token_interactive(
scopes=self.auth_settings.scopes,
login_hint=username,
prompt='select_account')
if result and not 'error' in result:
if 'id_token' in result and 'preferred_username' in result['id_token_claims']:
username = result['id_token_claims']['preferred_username']
account = self.get_account_from_username(username)
return (result, account)
def logout(self):
account = self.get_account_from_username_prompt(
username=self.auth_settings.username,
prompt_option='None')
if account:
self.app.remove_account(account)
return account

Просмотреть файл

@ -1,62 +0,0 @@
# -----------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -----------------------------------------------------------------------------
"""
Token file manager.
"""
import os
import msal
from knack.util import CLIError
from paconn.common.util import get_config_dir
TOKEN_FILE = 'accessTokens.json'
class TokenManager:
"""
Class to manager login token.
"""
def __init__(self, token_file=TOKEN_FILE):
self.token_file = os.path.join(get_config_dir(), token_file)
def get_credentials(self):
"""
Returns credential object from token file.
"""
credentials = self.read()
token_expired = TokenManager.is_expired(credentials)
if token_expired:
raise CLIError('Access token invalid. Please login again.')
return credentials
def read(self):
"""
Reads a login token file.
"""
token_cache = msal.SerializableTokenCache()
if os.path.isfile(self.token_file):
try:
with open(self.token_file, 'r') as cred_file:
token_cache.deserialize(cred_file.read())
except ValueError as exception:
raise CLIError("Failed to load access token. (Inner Error: {})".format(exception))
return token_cache
def write(self, token_cache):
"""
Writes the login credentials to a token file.
"""
try:
with os.fdopen(os.open(self.token_file, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), 'w+') as cred_file:
cred_file.write(token_cache.serialize())
except Exception as exception:
raise CLIError("Failed to save access token. (Inner Error: {})".format(exception))
def delete_token_file(self):
if os.path.isfile(self.token_file):
os.remove(self.token_file)

Просмотреть файл

@ -10,9 +10,9 @@ Login command.
from paconn.authentication.auth import get_authentication
from paconn.common.util import display
from paconn.settings.authsettings import AuthSettings
from knack.util import CLIError
def login(client_id, tenant, authority_url, scopes):
def login(client_id, tenant, authority_url, scopes, force_interactive):
"""
Login command.
"""
@ -33,12 +33,16 @@ def login(client_id, tenant, authority_url, scopes):
authority_url=authority_url,
scopes=scopes)
(result, account) = get_authentication(settings=settings)
(result, account) = get_authentication(
settings=settings,
force_interactive=force_interactive)
if result:
if account:
display('Logged in with account {}.'.format(account['username']))
else:
display('Login successful.')
if result and "error" in result:
raise CLIError(result["error_description"])
elif not result:
raise CLIError('Login failed.')
if account:
display('Logged in with account {}.'.format(account['username']))
else:
display('Login failed.')
display('Login successful.')

Просмотреть файл

@ -15,5 +15,6 @@ def logout():
"""
Logout command.
"""
remove_authentication()
display('Logout successful.')
account = remove_authentication()
if account:
display('Logged out from account {}.'.format(account['username']))

Просмотреть файл

@ -82,6 +82,15 @@ def load_arguments(self, command):
type=str,
required=False,
help='Scopes for login.')
arg_context.argument(
'force_interactive',
options_list=['--force_interactive', '-f'],
type=bool,
required=False,
nargs='?',
default=False,
const=True,
help='Force interactive login even if previous login exists.')
with ArgumentsContext(self, _DOWNLOAD) as arg_context:
arg_context.argument(

Просмотреть файл

@ -14,6 +14,8 @@ from paconn.apimanager.flowrpbuilder import FlowRPBuilder
from paconn.common.prompts import get_environment, get_connector_id
from paconn.settings.settingsserializer import SettingsSerializer
from paconn.authentication.auth import get_silent_authentication
from paconn.common.util import display
from knack.util import CLIError
# Setting file name
SETTINGS_FILE = 'settings.json'
@ -39,6 +41,14 @@ def load_powerapps_and_flow_rp(settings, command_context):
# Get credentials
(credentials, account) = get_silent_authentication()
if credentials and "error" in credentials:
raise CLIError(credentials['error_description'])
elif not credentials:
raise CLIError('Couldn\'t obtain login token. Please log in again.')
if account and 'username' in account:
display('Using account {}.'.format(account['username']))
# Get powerapps rp
powerapps_rp = PowerAppsRPBuilder.get_from_settings(
credentials=credentials,

Просмотреть файл

@ -9,7 +9,7 @@
import os
from setuptools import setup
__VERSION__ = '0.1.1'
__VERSION__ = '0.1.2'
def read(fname):
@ -57,7 +57,8 @@ setup(
'pytest-xdist',
'virtualenv',
'requests',
'adal',
'msal',
'msal_extensions',
'msrestazure',
'azure-storage-blob>=2.1,<12.0'
],