607 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import json
import threading
import platform
from typing import Any, Dict
import aiofiles
# 跨平台文件锁支持
if platform.system() == 'Windows':
# Windows平台使用msvcrt
import msvcrt
HAS_MSVCRT = True
HAS_FCNTL = False
else:
# 其他平台尝试使用fcntl如果不可用则不设文件锁
HAS_MSVCRT = False
try:
import fcntl
HAS_FCNTL = True
except ImportError:
HAS_FCNTL = False
from .static_ak import StaticAKCredentialsProvider
from .ecs_ram_role import EcsRamRoleCredentialsProvider
from .ram_role_arn import RamRoleArnCredentialsProvider
from .oidc import OIDCRoleArnCredentialsProvider
from .static_sts import StaticSTSCredentialsProvider
from .cloud_sso import CloudSSOCredentialsProvider
from .oauth import OAuthCredentialsProvider, OAuthTokenUpdateCallback, OAuthTokenUpdateCallbackAsync
from .refreshable import Credentials
from alibabacloud_credentials_api import ICredentialsProvider
from alibabacloud_credentials.utils import auth_constant as ac
from alibabacloud_credentials.utils import auth_util as au
from alibabacloud_credentials.exceptions import CredentialException
async def _load_config_async(file_path: str) -> Any:
async with aiofiles.open(file_path, mode='r') as f:
content = await f.read()
return json.loads(content)
def _load_config(file_path: str) -> Any:
with open(file_path, mode='r') as f:
content = f.read()
return json.loads(content)
class CLIProfileCredentialsProvider(ICredentialsProvider):
def __init__(self, *,
profile_name: str = None,
profile_file: str = None,
allow_config_force_rewrite: bool = False):
self._profile_file = profile_file or os.path.join(ac.HOME, ".aliyun/config.json")
self._profile_name = profile_name or au.environment_profile_name
self._allow_config_force_rewrite = allow_config_force_rewrite
self.__innerProvider = None
# 文件锁,用于并发安全
self._file_lock = threading.RLock()
def _should_reload_credentials_provider(self) -> bool:
if self.__innerProvider is None:
return True
return False
def get_credentials(self) -> Credentials:
if au.environment_cli_profile_disabled.lower() == "true":
raise CredentialException('cli credentials file is disabled')
if self._should_reload_credentials_provider():
if not os.path.exists(self._profile_file) or not os.path.isfile(self._profile_file):
raise CredentialException(f'unable to open credentials file: {self._profile_file}')
try:
config = _load_config(self._profile_file)
except Exception as e:
raise CredentialException(
f'failed to parse credential form cli credentials file: {self._profile_file}')
if config is None:
raise CredentialException(
f'failed to parse credential form cli credentials file: {self._profile_file}')
profile_name = self._profile_name
if self._profile_name is None or self._profile_name == '':
profile_name = config.get('current')
self.__innerProvider = self._get_credentials_provider(config, profile_name)
cre = self.__innerProvider.get_credentials()
credentials = Credentials(
access_key_id=cre.get_access_key_id(),
access_key_secret=cre.get_access_key_secret(),
security_token=cre.get_security_token(),
provider_name=f'{self.get_provider_name()}/{cre.get_provider_name()}'
)
return credentials
async def get_credentials_async(self) -> Credentials:
if au.environment_cli_profile_disabled.lower() == "true":
raise CredentialException('cli credentials file is disabled')
if self._should_reload_credentials_provider():
if not os.path.exists(self._profile_file) or not os.path.isfile(self._profile_file):
raise CredentialException(f'unable to open credentials file: {self._profile_file}')
try:
config = await _load_config_async(self._profile_file)
except Exception as e:
raise CredentialException(
f'failed to parse credential form cli credentials file: {self._profile_file}')
if config is None:
raise CredentialException(
f'failed to parse credential form cli credentials file: {self._profile_file}')
profile_name = self._profile_name
if self._profile_name is None or self._profile_name == '':
profile_name = config.get('current')
self.__innerProvider = self._get_credentials_provider(config, profile_name)
cre = await self.__innerProvider.get_credentials_async()
credentials = Credentials(
access_key_id=cre.get_access_key_id(),
access_key_secret=cre.get_access_key_secret(),
security_token=cre.get_security_token(),
provider_name=f'{self.get_provider_name()}/{cre.get_provider_name()}'
)
return credentials
def _get_credentials_provider(self, config: Dict, profile_name: str) -> ICredentialsProvider:
if profile_name is None or profile_name == '':
raise CredentialException('invalid profile name')
profiles = config.get('profiles', [])
if not profiles:
raise CredentialException(f"unable to get profile with '{profile_name}' form cli credentials file.")
for profile in profiles:
if profile.get('name') is not None and profile['name'] == profile_name:
mode = profile.get('mode')
if mode == "AK":
return StaticAKCredentialsProvider(
access_key_id=profile.get('access_key_id'),
access_key_secret=profile.get('access_key_secret')
)
elif mode == "StsToken":
return StaticSTSCredentialsProvider(
access_key_id=profile.get('access_key_id'),
access_key_secret=profile.get('access_key_secret'),
security_token=profile.get('sts_token')
)
elif mode == "RamRoleArn":
pre_provider = StaticAKCredentialsProvider(
access_key_id=profile.get('access_key_id'),
access_key_secret=profile.get('access_key_secret')
)
return RamRoleArnCredentialsProvider(
credentials_provider=pre_provider,
role_arn=profile.get('ram_role_arn'),
role_session_name=profile.get('ram_session_name'),
duration_seconds=profile.get('expired_seconds'),
policy=profile.get('policy'),
external_id=profile.get('external_id'),
sts_region_id=profile.get('sts_region'),
enable_vpc=profile.get('enable_vpc'),
)
elif mode == "EcsRamRole":
return EcsRamRoleCredentialsProvider(
role_name=profile.get('ram_role_name')
)
elif mode == "OIDC":
return OIDCRoleArnCredentialsProvider(
role_arn=profile.get('ram_role_arn'),
oidc_provider_arn=profile.get('oidc_provider_arn'),
oidc_token_file_path=profile.get('oidc_token_file'),
role_session_name=profile.get('role_session_name'),
duration_seconds=profile.get('expired_seconds'),
policy=profile.get('policy'),
sts_region_id=profile.get('sts_region'),
enable_vpc=profile.get('enable_vpc'),
)
elif mode == "ChainableRamRoleArn":
previous_provider = self._get_credentials_provider(config, profile.get('source_profile'))
return RamRoleArnCredentialsProvider(
credentials_provider=previous_provider,
role_arn=profile.get('ram_role_arn'),
role_session_name=profile.get('ram_session_name'),
duration_seconds=profile.get('expired_seconds'),
policy=profile.get('policy'),
external_id=profile.get('external_id'),
sts_region_id=profile.get('sts_region'),
enable_vpc=profile.get('enable_vpc'),
)
elif mode == "CloudSSO":
return CloudSSOCredentialsProvider(
sign_in_url=profile.get('cloud_sso_sign_in_url'),
account_id=profile.get('cloud_sso_account_id'),
access_config=profile.get('cloud_sso_access_config'),
access_token=profile.get('access_token'),
access_token_expire=profile.get('cloud_sso_access_token_expire'),
)
elif mode == "OAuth":
# 获取 OAuth 配置
site_type = profile.get('oauth_site_type', 'CN')
oauth_base_url_map = {
'CN': 'https://oauth.aliyun.com',
'INTL': 'https://oauth.alibabacloud.com'
}
sign_in_url = oauth_base_url_map.get(site_type.upper())
if not sign_in_url:
raise CredentialException('Invalid OAuth site type, support CN or INTL')
oauth_client_map = {
'CN': '4038181954557748008',
'INTL': '4103531455503354461'
}
client_id = oauth_client_map.get(site_type.upper())
if not client_id:
raise CredentialException('Invalid OAuth site type, support CN or INTL')
return OAuthCredentialsProvider(
client_id=client_id,
sign_in_url=sign_in_url,
access_token=profile.get('oauth_access_token'),
access_token_expire=profile.get('oauth_access_token_expire'),
refresh_token=profile.get('oauth_refresh_token'),
token_update_callback=self._get_oauth_token_update_callback(),
token_update_callback_async=self._get_oauth_token_update_callback_async(),
)
else:
raise CredentialException(f"unsupported profile mode '{mode}' form cli credentials file.")
raise CredentialException(f"unable to get profile with '{profile_name}' form cli credentials file.")
def get_provider_name(self) -> str:
return 'cli_profile'
def _update_oauth_tokens(self, refresh_token: str, access_token: str, access_key: str, secret: str,
security_token: str, access_token_expire: int, sts_expire: int) -> None:
"""更新 OAuth 令牌并写回配置文件"""
def _find_source_oauth_profile(config: dict, profile_name: str) -> dict:
profiles = config.get('profiles', [])
profile = next((p for p in profiles if p.get('name') == profile_name), None)
if not profile:
raise CredentialException(f"unable to get profile with name '{profile_name}' from cli credentials file.")
if profile.get('mode') == 'OAuth':
return profile
else:
source_profile = profile.get('source_profile')
if source_profile:
return _find_source_oauth_profile(config, source_profile)
raise CredentialException(f"unable to get OAuth profile with name '{profile_name}' from cli credentials file.")
with self._file_lock:
try:
# 读取现有配置
config = _load_config(self._profile_file)
# 找到当前 profile 并更新 OAuth 令牌
profile_name = self._profile_name or config.get('current')
if not profile_name:
raise CredentialException(f"unable to get profile to updated.")
source_profile = _find_source_oauth_profile(config, profile_name)
# 更新 OAuth 令牌
source_profile['oauth_refresh_token'] = refresh_token
source_profile['oauth_access_token'] = access_token
source_profile['oauth_access_token_expire'] = access_token_expire
# 更新 STS 凭据
source_profile['access_key_id'] = access_key
source_profile['access_key_secret'] = secret
source_profile['sts_token'] = security_token
source_profile['sts_expiration'] = sts_expire
self._write_configuration_to_file_with_lock(self._profile_file, config)
except Exception as e:
raise CredentialException(f"failed to update OAuth tokens in config file: {e}")
def _write_configuration_to_file(self, config_path: str, config: Dict) -> None:
"""将配置写入文件,使用原子写入确保数据完整性"""
# 获取原文件权限(如果存在)
file_mode = 0o644
if os.path.exists(config_path):
file_mode = os.stat(config_path).st_mode
# 创建唯一临时文件
import time
temp_file = config_path + '.tmp-' + str(int(time.time() * 1000000)) # 微秒级时间戳
backup_file = None
try:
# 写入临时文件
self._write_config_file(temp_file, file_mode, config)
# 原子性重命名Windows下需要特殊处理
if platform.system() == 'Windows' and self._allow_config_force_rewrite:
# Windows下需要先删除目标文件使用备份机制确保数据安全
if os.path.exists(config_path):
backup_file = config_path + '.backup'
# 创建备份
import shutil
shutil.copy2(config_path, backup_file)
# 删除原文件
os.remove(config_path)
os.rename(temp_file, config_path)
# 成功后删除备份
if backup_file and os.path.exists(backup_file):
os.remove(backup_file)
except Exception as e:
# 恢复原文件(如果存在备份)
if backup_file and os.path.exists(backup_file):
try:
if not os.path.exists(config_path):
os.rename(backup_file, config_path)
except Exception as restore_error:
raise CredentialException(
f"Failed to restore original file after write error: {restore_error}. Original error: {e}")
raise e
def _write_config_file(self, filename: str, file_mode: int, config: Dict) -> None:
try:
with open(filename, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=4, ensure_ascii=False)
# 设置文件权限
os.chmod(filename, file_mode)
except Exception as e:
raise CredentialException(f"Failed to write config file: {e}")
def _write_configuration_to_file_with_lock(self, config_path: str, config: Dict) -> None:
"""使用操作系统级别的文件锁写入配置文件"""
# 获取原文件权限(如果存在)
file_mode = 0o644
if os.path.exists(config_path):
file_mode = os.stat(config_path).st_mode
backup_file = None
try:
# 确保文件存在
if not os.path.exists(config_path):
# 创建空文件
with open(config_path, 'w') as f:
json.dump({}, f)
# 在获取文件锁之前创建备份Windows下需要
if platform.system() == 'Windows' and self._allow_config_force_rewrite and os.path.exists(config_path):
backup_file = config_path + '.backup'
import shutil
shutil.copy2(config_path, backup_file)
# 打开文件用于锁定
with open(config_path, 'r+') as f:
# 获取独占锁(阻塞其他进程)
if HAS_MSVCRT:
# Windows使用msvcrt
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
elif HAS_FCNTL:
# Unix/Linux使用fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
# 如果都不支持,则跳过文件锁(仅进程内保护)
try:
if platform.system() == 'Windows' and self._allow_config_force_rewrite:
# Windows下直接在锁定的文件中写入
f.seek(0)
f.truncate() # 清空文件内容
json.dump(config, f, indent=4, ensure_ascii=False)
f.flush()
else:
# 其他环境使用临时文件+rename在文件锁内部进行原子操作
import time
temp_file = config_path + '.tmp-' + str(int(time.time() * 1000000))
self._write_config_file(temp_file, file_mode, config)
# 在文件锁内部进行原子重命名
os.rename(temp_file, config_path)
finally:
# 释放锁
try:
if HAS_MSVCRT:
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
elif HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except (OSError, PermissionError):
# 在Windows下如果文件被重命名文件句柄可能已经无效
# 这种情况下锁会自动释放,所以忽略错误
pass
# 成功后删除备份
if backup_file and os.path.exists(backup_file):
os.remove(backup_file)
except Exception as e:
# 恢复原文件(如果存在备份)
if backup_file and os.path.exists(backup_file):
try:
if not os.path.exists(config_path):
os.rename(backup_file, config_path)
except Exception as restore_error:
raise CredentialException(
f"Failed to restore original file after write error: {restore_error}. Original error: {e}")
raise e
def _get_oauth_token_update_callback(self) -> OAuthTokenUpdateCallback:
"""获取 OAuth 令牌更新回调函数"""
return lambda refresh_token, access_token, access_key, secret, security_token, access_token_expire, sts_expire: self._update_oauth_tokens(
refresh_token, access_token, access_key, secret, security_token, access_token_expire, sts_expire
)
async def _write_configuration_to_file_async(self, config_path: str, config: Dict) -> None:
"""异步将配置写入文件,使用原子写入确保数据完整性"""
# 获取原文件权限(如果存在)
file_mode = 0o644
if os.path.exists(config_path):
file_mode = os.stat(config_path).st_mode
# 创建唯一临时文件
import time
temp_file = config_path + '.tmp-' + str(int(time.time() * 1000000)) # 微秒级时间戳
backup_file = None
try:
# 异步写入临时文件
await self._write_config_file_async(temp_file, file_mode, config)
# 原子性重命名Windows下需要特殊处理
if platform.system() == 'Windows' and self._allow_config_force_rewrite:
# Windows下需要先删除目标文件使用备份机制确保数据安全
if os.path.exists(config_path):
backup_file = config_path + '.backup'
# 创建备份
import shutil
shutil.copy2(config_path, backup_file)
# 删除原文件
os.remove(config_path)
os.rename(temp_file, config_path)
# 成功后删除备份
if backup_file and os.path.exists(backup_file):
os.remove(backup_file)
except Exception as e:
# 恢复原文件(如果存在备份)
if backup_file and os.path.exists(backup_file):
try:
if not os.path.exists(config_path):
os.rename(backup_file, config_path)
except Exception as restore_error:
raise CredentialException(
f"Failed to restore original file after write error: {restore_error}. Original error: {e}")
raise e
async def _write_config_file_async(self, filename: str, file_mode: int, config: Dict) -> None:
try:
async with aiofiles.open(filename, 'w', encoding='utf-8') as f:
await f.write(json.dumps(config, indent=4, ensure_ascii=False))
# 设置文件权限
os.chmod(filename, file_mode)
except Exception as e:
raise CredentialException(f"Failed to write config file: {e}")
async def _write_configuration_to_file_with_lock_async(self, config_path: str, config: Dict) -> None:
"""异步使用操作系统级别的文件锁写入配置文件"""
# 获取原文件权限(如果存在)
file_mode = 0o644
if os.path.exists(config_path):
file_mode = os.stat(config_path).st_mode
backup_file = None
try:
# 确保文件存在
if not os.path.exists(config_path):
# 创建空文件
with open(config_path, 'w') as f:
json.dump({}, f)
# 在获取文件锁之前创建备份Windows下需要
if platform.system() == 'Windows' and self._allow_config_force_rewrite and os.path.exists(config_path):
backup_file = config_path + '.backup'
import shutil
shutil.copy2(config_path, backup_file)
# 打开文件用于锁定
with open(config_path, 'r+') as f:
# 获取独占锁(阻塞其他进程)
if HAS_MSVCRT:
# Windows使用msvcrt
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
elif HAS_FCNTL:
# Unix/Linux使用fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
# 如果都不支持,则跳过文件锁(仅进程内保护)
try:
if platform.system() == 'Windows' and self._allow_config_force_rewrite:
# Windows下直接在锁定的文件中写入
f.seek(0)
f.truncate() # 清空文件内容
json.dump(config, f, indent=4, ensure_ascii=False)
f.flush()
else:
# 其他环境使用临时文件+rename在文件锁内部进行原子操作
import time
temp_file = config_path + '.tmp-' + str(int(time.time() * 1000000))
await self._write_config_file_async(temp_file, file_mode, config)
# 在文件锁内部进行原子重命名
os.rename(temp_file, config_path)
finally:
# 释放锁
try:
if HAS_MSVCRT:
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
elif HAS_FCNTL:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except (OSError, PermissionError):
# 在Windows下如果文件被重命名文件句柄可能已经无效
# 这种情况下锁会自动释放,所以忽略错误
pass
# 成功后删除备份
if backup_file and os.path.exists(backup_file):
os.remove(backup_file)
except Exception as e:
# 恢复原文件(如果存在备份)
if backup_file and os.path.exists(backup_file):
try:
if not os.path.exists(config_path):
os.rename(backup_file, config_path)
except Exception as restore_error:
raise CredentialException(
f"Failed to restore original file after write error: {restore_error}. Original error: {e}")
raise e
async def _update_oauth_tokens_async(self, refresh_token: str, access_token: str, access_key: str, secret: str,
security_token: str, access_token_expire: int, sts_expire: int) -> None:
"""异步更新 OAuth 令牌并写回配置文件"""
def _find_source_oauth_profile(config: dict, profile_name: str) -> dict:
profiles = config.get('profiles', [])
profile = next((p for p in profiles if p.get('name') == profile_name), None)
if not profile:
raise CredentialException(f"unable to get profile with name '{profile_name}' from cli credentials file.")
if profile.get('mode') == 'OAuth':
return profile
else:
source_profile = profile.get('source_profile')
if source_profile:
return _find_source_oauth_profile(config, source_profile)
raise CredentialException(f"unable to get OAuth profile with name '{profile_name}' from cli credentials file.")
with self._file_lock:
try:
# 读取现有配置
config = await _load_config_async(self._profile_file)
# 找到当前 profile 并更新 OAuth 令牌
profile_name = self._profile_name or config.get('current')
if not profile_name:
raise CredentialException(f"unable to get profile to updated.")
source_profile = _find_source_oauth_profile(config, profile_name)
# 更新 OAuth 令牌
source_profile['oauth_refresh_token'] = refresh_token
source_profile['oauth_access_token'] = access_token
source_profile['oauth_access_token_expire'] = access_token_expire
# 更新 STS 凭据
source_profile['access_key_id'] = access_key
source_profile['access_key_secret'] = secret
source_profile['sts_token'] = security_token
source_profile['sts_expiration'] = sts_expire
await self._write_configuration_to_file_with_lock_async(self._profile_file, config)
except Exception as e:
raise CredentialException(f"failed to update OAuth tokens in config file: {e}")
def _get_oauth_token_update_callback_async(self) -> OAuthTokenUpdateCallbackAsync:
"""获取异步 OAuth 令牌更新回调函数"""
return lambda refresh_token, access_token, access_key, secret, security_token, access_token_expire, sts_expire: self._update_oauth_tokens_async(
refresh_token, access_token, access_key, secret, security_token, access_token_expire, sts_expire
)