"""VeSync Authentication Module.""" from __future__ import annotations import asyncio import logging from pathlib import Path from typing import TYPE_CHECKING import orjson from mashumaro.exceptions import MissingField, UnserializableDataError from pyvesync.const import DEFAULT_REGION, NON_EU_COUNTRY_CODES from pyvesync.models.vesync_models import ( RequestGetTokenModel, RequestLoginTokenModel, RespGetTokenResultModel, RespLoginTokenResultModel, ResponseLoginModel, ) from pyvesync.utils.errors import ( ErrorCodes, ErrorTypes, VeSyncAPIResponseError, VeSyncLoginError, VeSyncServerError, ) if TYPE_CHECKING: from pyvesync.vesync import VeSync logger = logging.getLogger(__name__) class VeSyncAuth: """VeSync Authentication Manager. Handles login, token management, and persistent storage of authentication credentials for VeSync API access. Args: manager: VeSync manager instance for API calls username: VeSync account username (email) password: VeSync account password country_code: Country code in ISO 3166 Alpha-2 format Note: Either username/password or token/account_id must be provided. If token_file_path is provided, credentials will be saved/loaded automatically. When loading credentials, the current working directory and home directory are checked for the token file if no path is provided. When saving credentials, if no path is provided, it will save to the current working directory. """ __slots__ = ( '_account_id', '_country_code', '_current_region', '_password', '_token', '_token_file_path', '_username', 'manager', ) def __init__( self, manager: VeSync, username: str, password: str, country_code: str = DEFAULT_REGION, ) -> None: """Initialize VeSync Authentication Manager.""" self.manager = manager self._username = username self._password = password self._token: str | None = None self._account_id: str | None = None self._country_code = country_code.upper() self._current_region = self._country_code_to_region() self._token_file_path: Path | None = None def _country_code_to_region(self) -> str: """Convert country code to region string for API use.""" if self._country_code in NON_EU_COUNTRY_CODES: return 'US' return 'EU' @property def credentials_saved(self) -> bool: """Return whether credentials have been saved to file.""" return self._token_file_path is not None and self._token_file_path.exists() @property def token(self) -> str: """Return VeSync API token.""" if self._token is None: raise AttributeError( 'Token not set, run VeSync.login or VeSync.set_token method' ) return self._token @property def account_id(self) -> str: """Return VeSync account ID.""" if self._account_id is None: raise AttributeError( 'Account ID not set, run VeSync.login or VeSync.set_token method' ) return self._account_id @property def country_code(self) -> str: """Return country code.""" return self._country_code @country_code.setter def country_code(self, value: str) -> None: """Set country code.""" self._country_code = value.upper() @property def current_region(self) -> str: """Return current region.""" return self._current_region @property def is_authenticated(self) -> bool: """Check if user is authenticated.""" return self._token is not None and self._account_id is not None def set_credentials( self, token: str, account_id: str, country_code: str, region: str, ) -> None: """Set authentication credentials. Args: token: Authentication token account_id: Account ID country_code: Country code in ISO 3166 Alpha-2 format region: Current region code """ self._token = token self._account_id = account_id self._country_code = country_code.upper() self._current_region = region async def reauthenticate(self) -> bool: """Re-authenticate using stored username and password. Returns: True if re-authentication successful, False otherwise Raises: VeSyncLoginError: If login fails due to invalid credentials VeSyncAPIResponseError: If API response is invalid VeSyncServerError: If server returns an error """ had_saved_credentials = self.credentials_saved self.clear_credentials() success = await self.login() logger.debug('Re-authentication successful for user: %s', self._username) if had_saved_credentials: await self.save_credentials_to_file(self._token_file_path) return success async def load_credentials_from_file( self, file_path: str | Path | None = None ) -> bool: """Load credentials from token file if path is set. If no path is provided, it will try to load from the current working directory and then the user's home directory. """ locations = [Path.cwd() / '.vesync_auth', Path.home() / '.vesync_auth'] file_path_object: Path | None = None if file_path is None: for location in locations: if location.exists(): file_path_object = location break else: file_path_object = Path(file_path) if not file_path_object or not file_path_object.exists(): logger.debug('Credentials file not found: %s', file_path_object) return False self._token_file_path = file_path_object try: data = await asyncio.to_thread(file_path_object.read_text, encoding='utf-8') data = orjson.loads(data) self._token = data['token'] self._account_id = data['account_id'] self._country_code = data['country_code'].upper() self._current_region = data['current_region'].upper() logger.debug('Credentials loaded from file: %s', file_path_object) except (orjson.JSONDecodeError, KeyError) as exc: logger.warning('Failed to load credentials from file: %s', exc) return False if self._token is None or self._account_id is None: logger.debug('Incomplete credentials in token file') self.manager.enabled = False return False self.manager.enabled = True return True def output_credentials_dict(self) -> dict[str, str] | None: """Output current credentials as a dictionary.""" if self._token is None or self._account_id is None: logger.debug('No credentials to output, not authenticated') return None return { 'token': self._token, 'account_id': self._account_id, 'country_code': self._country_code, 'current_region': self._current_region, } def output_credentials_json(self) -> str | None: """Output current authentication credentials as a JSON string.""" if not self.is_authenticated: logger.debug('No credentials to output, not authenticated') return None credentials = self.output_credentials_dict() if credentials is None: return None try: return orjson.dumps(credentials).decode('utf-8') except orjson.JSONEncodeError as exc: logger.warning('Failed to serialize credentials: %s', exc) return None async def save_credentials_to_file(self, file_path: str | Path | None = None) -> None: """Save authentication credentials to file.""" if file_path is not None: file_path_object = Path(file_path) elif self._token_file_path is not None: file_path_object = self._token_file_path else: logger.debug('No token file path set, saving to default location') file_path_object = Path.cwd() / '.vesync_auth' if not self.is_authenticated: logger.debug('No credentials to save, not authenticated') return credentials = { 'token': self._token, 'account_id': self._account_id, 'country_code': self._country_code, 'current_region': self.current_region, } try: data = orjson.dumps(credentials).decode('utf-8') await asyncio.to_thread(file_path_object.write_text, data, encoding='utf-8') logger.debug('Credentials saved to file: %s', file_path_object) except (orjson.JSONEncodeError, OSError) as exc: logger.warning('Failed to save credentials to file: %s', exc) def clear_credentials(self) -> None: """Clear all stored credentials.""" self._token = None self._account_id = None # Remove token file if it exists if self._token_file_path and self._token_file_path.exists(): try: self._token_file_path.unlink() logger.debug('Token file deleted: %s', self._token_file_path) except OSError as exc: logger.warning('Failed to delete token file: %s', exc) async def login(self) -> bool: """Log into VeSync server using username/password or existing token. Returns: True if login successful, False otherwise Raises: VeSyncLoginError: If login fails due to invalid credentials VeSyncAPIResponseError: If API response is invalid VeSyncServerError: If server returns an error """ # Attempt username/password login if self._username and self._password: return await self._login_with_credentials() raise VeSyncLoginError( 'No valid authentication method available. ' 'Provide either username/password or valid token/account_id.' ) async def _login_with_credentials(self) -> bool: """Login using username and password. Returns: True if login successful Raises: VeSyncLoginError: If login fails VeSyncAPIResponseError: If API response is invalid """ if not isinstance(self._username, str) or len(self._username) == 0: raise VeSyncLoginError('Username must be a non-empty string') if not isinstance(self._password, str) or len(self._password) == 0: raise VeSyncLoginError('Password must be a non-empty string') try: # Step 1: Get authorization code auth_code = await self._get_authorization_code() # Step 2: Exchange authorization code for token await self._exchange_authorization_code(auth_code) logger.debug('Login successful for user: %s', self._username) except (VeSyncLoginError, VeSyncAPIResponseError, VeSyncServerError): raise except Exception as exc: msg = f'Unexpected error during login: {exc}' raise VeSyncLoginError(msg) from exc return True async def _get_authorization_code(self) -> str: """Get authorization code using username and password. Returns: Authorization code Raises: VeSyncAPIResponseError: If API response is invalid VeSyncLoginError: If authentication fails """ request_auth = RequestGetTokenModel( email=self._username, # type: ignore[arg-type] method='authByPWDOrOTM', password=self._password, # type: ignore[arg-type] ) resp_dict, _ = await self.manager.async_call_api( '/globalPlatform/api/accountAuth/v1/authByPWDOrOTM', 'post', json_object=request_auth, ) if resp_dict is None: raise VeSyncAPIResponseError('Error receiving response to auth request') if resp_dict.get('code') != 0: error_info = ErrorCodes.get_error_info(resp_dict.get('code')) resp_message = resp_dict.get('msg') if resp_message is not None: error_info.message = f'{error_info.message} ({resp_message})' msg = f'Authentication failed - {error_info.message}' raise VeSyncLoginError(msg) try: response_model = ResponseLoginModel.from_dict(resp_dict) except UnserializableDataError as exc: logger.debug('Error parsing auth response: %s', exc) raise VeSyncAPIResponseError('Error parsing authentication response') from exc except MissingField as exc: logger.debug('Error parsing auth response: %s', exc) raise VeSyncAPIResponseError('Error parsing authentication response') from exc result = response_model.result if not isinstance(result, RespGetTokenResultModel): raise VeSyncAPIResponseError('Invalid authentication response format') self._account_id = result.accountID return result.authorizeCode async def _exchange_authorization_code( self, auth_code: str, region_change_token: str | None = None, ) -> None: """Exchange authorization code for access token. Args: auth_code: Authorization code from first auth step region_change_token: Token for region change (retry scenario) Raises: VeSyncLoginError: If login fails VeSyncAPIResponseError: If API response is invalid """ request_login = RequestLoginTokenModel( method='loginByAuthorizeCode4Vesync', authorizeCode=auth_code, bizToken=region_change_token, userCountryCode=self._country_code, regionChange='lastRegion' if region_change_token else None, ) resp_dict, _ = await self.manager.async_call_api( '/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', 'post', json_object=request_login, ) if resp_dict is None: raise VeSyncAPIResponseError('Error receiving response to login request') try: response_model = ResponseLoginModel.from_dict(resp_dict) if not isinstance(response_model.result, RespLoginTokenResultModel): raise VeSyncAPIResponseError('Invalid login response format') if response_model.code != 0: error_info = ErrorCodes.get_error_info(resp_dict.get('code')) # Handle cross region error by retrying with new region if error_info.error_type == ErrorTypes.CROSS_REGION: result = response_model.result self._country_code = result.countryCode self._current_region = result.currentRegion logger.debug( 'Cross-region error, retrying with country: %s', self._country_code, ) return await self._exchange_authorization_code( auth_code, region_change_token=result.bizToken ) resp_message = resp_dict.get('msg') if resp_message is not None: error_info.message = f'{error_info.message} ({resp_message})' msg = f'Login failed - {error_info.message}' # Raise appropriate exception based on error type if error_info.error_type == ErrorTypes.SERVER_ERROR: raise VeSyncServerError(msg) raise VeSyncLoginError(msg) result = response_model.result self._token = result.token self._account_id = result.accountID self._country_code = result.countryCode except (MissingField, UnserializableDataError) as exc: logger.debug('Error parsing login response: %s', exc) raise VeSyncAPIResponseError('Error parsing login response') from exc def __repr__(self) -> str: """Return string representation of VeSyncAuth.""" return ( f'VeSyncAuth(username={self._username!r}, ' f'country_code={self._country_code!r}, ' f'authenticated={self.is_authenticated})' )