diff --git a/README.md b/README.md index 1653f945..e1dfc7ca 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ icloud auth status icloud auth login --username jappleseed@apple.com icloud auth login --username jappleseed@apple.com --china-mainland icloud auth login --username jappleseed@apple.com --accept-terms +icloud devices list --one-factor icloud account summary icloud account summary --format json icloud devices list --locate @@ -189,6 +190,42 @@ icloud auth logout --remove-keyring icloud auth keyring delete --username jappleseed@apple.com ``` +### Find My without a two-factor code + +Apple lets one service through on the password alone: Find My. Of the apps it +advertises for an account, `find` is the only one flagged +`canLaunchWithOneFactor` -- the same reason `icloud.com/find` works in a private +window after just a password. + +```console +icloud devices list --one-factor +icloud devices sound "Example iPhone" --one-factor +``` + +The password comes from your keyring, or is prompted for. + +It is an option on the `devices` commands rather than a way to log in, because +Apple issues no `X-APPLE-WEBAUTH-TOKEN` for a session that skipped the 2FA +challenge and answers `/validate` on one with a `421`. Such a session cannot be +saved and reopened; it lasts for the one command that created it. Its cookies go +to a temporary directory, so running this never disturbs a session you already +have. + +`list`, `show`, `sound`, `message`, `lost-mode` and `export` all accept the +option. Note that `lost-mode` locks the device and can set a new passcode, so it +is worth the same care on a password-only sign-in as it is on a full one. + +`icloud devices erase` is the exception and does not accept `--one-factor`. A +remote wipe is the one irreversible action in the group, and it should cost a +full session: + +```console +icloud auth login --username jappleseed@apple.com +``` + +Everything outside Find My needs that full session too -- on a password-only +sign-in, `icloud account` answers `401` and `icloud drive` answers `421`. + If you would like to delete a password stored in your system keyring, use the dedicated keyring subcommand: diff --git a/pyicloud/cli/commands/devices.py b/pyicloud/cli/commands/devices.py index 3edd36ee..b146814a 100644 --- a/pyicloud/cli/commands/devices.py +++ b/pyicloud/cli/commands/devices.py @@ -15,6 +15,7 @@ HttpsProxyOption, LogLevelOption, NoVerifySslOption, + OneFactorOption, OutputFormatOption, SessionDirOption, UsernameOption, @@ -48,6 +49,7 @@ def devices_list( output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, log_level: LogLevelOption = DEFAULT_LOG_LEVEL, with_family: WithFamilyOption = False, + one_factor: OneFactorOption = False, ) -> None: """List Find My devices.""" @@ -61,9 +63,10 @@ def devices_list( output_format=output_format, log_level=log_level, with_family=with_family, + one_factor=one_factor, ) state = get_state(ctx) - api = state.get_api() + api = state.get_one_factor_api() if one_factor else state.get_api() payload = [ normalize_device_summary(device, locate=locate) for device in service_call( @@ -110,6 +113,7 @@ def devices_show( output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, log_level: LogLevelOption = DEFAULT_LOG_LEVEL, with_family: WithFamilyOption = False, + one_factor: OneFactorOption = False, ) -> None: """Show detailed information for one device.""" @@ -123,9 +127,10 @@ def devices_show( output_format=output_format, log_level=log_level, with_family=with_family, + one_factor=one_factor, ) state = get_state(ctx) - api = state.get_api() + api = state.get_one_factor_api() if one_factor else state.get_api() idevice = resolve_device(api, device) payload = idevice.data if raw else normalize_device_details(idevice, locate=locate) if state.json_output: @@ -163,6 +168,7 @@ def devices_sound( output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, log_level: LogLevelOption = DEFAULT_LOG_LEVEL, with_family: WithFamilyOption = False, + one_factor: OneFactorOption = False, ) -> None: """Play a sound on a device.""" @@ -176,9 +182,10 @@ def devices_sound( output_format=output_format, log_level=log_level, with_family=with_family, + one_factor=one_factor, ) state = get_state(ctx) - api = state.get_api() + api = state.get_one_factor_api() if one_factor else state.get_api() idevice = resolve_device(api, device) service_call( FIND_MY, @@ -207,6 +214,7 @@ def devices_message( output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, log_level: LogLevelOption = DEFAULT_LOG_LEVEL, with_family: WithFamilyOption = False, + one_factor: OneFactorOption = False, ) -> None: """Display a message on a device.""" @@ -220,9 +228,10 @@ def devices_message( output_format=output_format, log_level=log_level, with_family=with_family, + one_factor=one_factor, ) state = get_state(ctx) - api = state.get_api() + api = state.get_one_factor_api() if one_factor else state.get_api() idevice = resolve_device(api, device) service_call( FIND_MY, @@ -262,6 +271,7 @@ def devices_lost_mode( output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, log_level: LogLevelOption = DEFAULT_LOG_LEVEL, with_family: WithFamilyOption = False, + one_factor: OneFactorOption = False, ) -> None: """Enable lost mode for a device.""" @@ -275,9 +285,10 @@ def devices_lost_mode( output_format=output_format, log_level=log_level, with_family=with_family, + one_factor=one_factor, ) state = get_state(ctx) - api = state.get_api() + api = state.get_one_factor_api() if one_factor else state.get_api() idevice = resolve_device(api, device, require_unique=True) service_call( FIND_MY, @@ -330,6 +341,8 @@ def devices_erase( with_family=with_family, ) state = get_state(ctx) + # No --one-factor here on purpose: a remote wipe is the one irreversible + # thing in this group, and it should cost a full session. api = state.get_api() idevice = resolve_device(api, device, require_unique=True) if not force and not typer.confirm( @@ -372,6 +385,7 @@ def devices_export( output_format: OutputFormatOption = DEFAULT_OUTPUT_FORMAT, log_level: LogLevelOption = DEFAULT_LOG_LEVEL, with_family: WithFamilyOption = False, + one_factor: OneFactorOption = False, ) -> None: """Export a device snapshot to JSON.""" @@ -385,9 +399,10 @@ def devices_export( output_format=output_format, log_level=log_level, with_family=with_family, + one_factor=one_factor, ) state = get_state(ctx) - api = state.get_api() + api = state.get_one_factor_api() if one_factor else state.get_api() idevice = resolve_device(api, device) if raw and normalized: raise typer.BadParameter("Choose either --raw or --normalized, not both.") diff --git a/pyicloud/cli/context.py b/pyicloud/cli/context.py index bce583f0..25039e35 100644 --- a/pyicloud/cli/context.py +++ b/pyicloud/cli/context.py @@ -2,13 +2,14 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import ExitStack from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum import logging from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory from typing import IO, Any, cast from click import confirm @@ -17,6 +18,7 @@ from pyicloud import PyiCloudService, utils from pyicloud.base import resolve_cookie_directory +from pyicloud.const import FIND_MY_APP_KEY from pyicloud.exceptions import ( PyiCloudAPIResponseException, PyiCloudAuthRequiredException, @@ -74,6 +76,7 @@ class CLICommandOptions: china_mainland: bool | None = None interactive: bool = True accept_terms: bool = False + one_factor: bool = False with_family: bool = False session_dir: str | None = None http_proxy: str | None = None @@ -94,6 +97,7 @@ def __init__( china_mainland: bool | None, interactive: bool, accept_terms: bool, + one_factor: bool, with_family: bool, session_dir: str | None, http_proxy: str | None, @@ -108,6 +112,7 @@ def __init__( self.china_mainland = china_mainland self.interactive = interactive self.accept_terms = accept_terms + self.one_factor = one_factor self.with_family = with_family self.session_dir = session_dir self.http_proxy = http_proxy @@ -133,6 +138,7 @@ def from_options(cls, options: CLICommandOptions) -> CLIState: china_mainland=options.china_mainland, interactive=options.interactive, accept_terms=options.accept_terms, + one_factor=options.one_factor, with_family=options.with_family, session_dir=options.session_dir, http_proxy=options.http_proxy, @@ -429,6 +435,66 @@ def _handle_2sa(self, api: PyiCloudService) -> None: if not api.validate_verification_code(device, code): raise CLIAbort("Failed to verify the 2SA code.") + def get_one_factor_api(self) -> PyiCloudService: + """Return a password-only session that can reach Find My and nothing else. + + Apple issues no ``X-APPLE-WEBAUTH-TOKEN`` for a session that skipped the + 2FA challenge, and answers ``/validate`` on one with a 421, so it cannot + be written down and picked up by a later command. It lives and dies + inside this process, which is why this is a per-command option rather + than a way to log in. + + The cookies go to a throwaway directory for the same reason: persisting + them where the real session lives would replace a working trusted + session with one that only Find My accepts. + """ + + if self._api is not None: + return self._api + + username = self._resolve_username() + password, _ = self._password_for_login(username) + if not password: + raise CLIAbort("No password supplied and no stored password was found.") + + self._configure_logging() + scratch = self._stack.enter_context( + TemporaryDirectory(prefix="pyicloud-one-factor-") + ) + try: + api = PyiCloudService( + apple_id=username, + password=password, + china_mainland=self.resolved_china_mainland(username), + cookie_directory=scratch, + accept_terms=self.accept_terms, + with_family=self.with_family, + pause_2fa=True, + ) + except PyiCloudFailedLoginException as err: + raise CLIAbort(f"Bad username or password for {username}") from err + + if not self._grants_one_factor(api): + raise CLIAbort( + f"Apple did not grant {username} password-only access to Find " + "My. Run `icloud auth login` for a full session instead." + ) + + self._api = api + return api + + @staticmethod + def _grants_one_factor(api: PyiCloudService) -> bool: + """Return whether Apple flagged Find My as reachable without 2FA. + + Checked before the first request so an ineligible account gets a clear + answer rather than an authentication error from somewhere deeper. + """ + + apps: Any = api.data.get("apps") + entry: Any = apps.get(FIND_MY_APP_KEY) if isinstance(apps, Mapping) else None + return bool(isinstance(entry, Mapping) and entry.get("canLaunchWithOneFactor")) + def get_login_api(self) -> PyiCloudService: """Return a PyiCloudService, bootstrapping login if needed.""" diff --git a/pyicloud/cli/options.py b/pyicloud/cli/options.py index de06d5f7..a988d314 100644 --- a/pyicloud/cli/options.py +++ b/pyicloud/cli/options.py @@ -23,6 +23,12 @@ CHINA_MAINLAND_OPTION_HELP = "Use China mainland Apple web service endpoints." INTERACTIVE_OPTION_HELP = "Enable or disable interactive prompts." ACCEPT_TERMS_OPTION_HELP = "Automatically accept pending Apple iCloud web terms." +ONE_FACTOR_OPTION_HELP = ( + "Run this command with a password-only sign-in, skipping the two-factor " + "prompt. Apple grants that to Find My alone, and the session it creates " + "cannot be saved, so it lasts for this one command and no session is " + "written to disk." +) WITH_FAMILY_OPTION_HELP = "Include family devices in Find My device listings." SESSION_DIR_OPTION_HELP = "Directory to store session and cookie files." HTTP_PROXY_OPTION_HELP = "HTTP proxy URL for requests." @@ -82,6 +88,14 @@ rich_help_panel=AUTHENTICATION_PANEL, ), ] +OneFactorOption = Annotated[ + bool, + typer.Option( + "--one-factor", + help=ONE_FACTOR_OPTION_HELP, + rich_help_panel=AUTHENTICATION_PANEL, + ), +] HttpProxyOption = Annotated[ str | None, typer.Option( @@ -142,6 +156,7 @@ def store_command_options( china_mainland: bool | None = None, interactive: bool = True, accept_terms: bool = False, + one_factor: bool = False, with_family: bool = False, session_dir: str | None = None, http_proxy: str | None = None, @@ -158,6 +173,7 @@ def store_command_options( "china_mainland": china_mainland, "interactive": interactive, "accept_terms": accept_terms, + "one_factor": one_factor, "with_family": with_family, "session_dir": session_dir, "http_proxy": http_proxy, diff --git a/pyicloud/const.py b/pyicloud/const.py index 82f24a58..267473f8 100644 --- a/pyicloud/const.py +++ b/pyicloud/const.py @@ -23,6 +23,14 @@ ACCOUNT_NAME = "accountName" +#: Find My's key in the ``apps`` map Apple returns from /validate, and the only +#: app there flagged ``canLaunchWithOneFactor``. It is *not* ``findme``, which +#: is this service's key in the ``webservices`` map -- the two maps name the +#: same service differently, so a lookup under the wrong key finds nothing and +#: reads as "not eligible". +FIND_MY_APP_KEY = "find" + + ERROR_ACCESS_DENIED = "ACCESS_DENIED" ERROR_ZONE_NOT_FOUND = "ZONE_NOT_FOUND" ERROR_AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index b4ca7f7f..ee0723e0 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -1632,6 +1632,231 @@ def fake_service(*, apple_id: str, **_kwargs: Any) -> FakeAPI: assert "leaf@example.com" in result.stdout +_FAKE_ONE_FACTOR_DIR = "/tmp/pyicloud-one-factor-fake" + + +def test_devices_one_factor_uses_a_paused_password_only_login() -> None: + """--one-factor signs in with the password and skips the 2FA challenge.""" + + session_dir = _unique_session_dir("one-factor-paused") + fake_api = FakeAPI(session_dir=session_dir) + fake_api.is_trusted_session = False + fake_api.data = {"apps": {"find": {"canLaunchWithOneFactor": True}}} + captured: dict[str, Any] = {} + + def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: + assert apple_id == "user@example.com" + captured.update(kwargs) + return fake_api + + with ( + patch.object(context_module, "PyiCloudService", side_effect=fake_service), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module.utils, "get_password_from_keyring", return_value="secret" + ), + # The real one makes a directory, which the filesystem guard forbids. + patch.object( + context_module, + "TemporaryDirectory", + lambda **_: nullcontext(_FAKE_ONE_FACTOR_DIR), + ), + ): + result = _runner().invoke( + app, + [ + "devices", + "list", + "--username", + "user@example.com", + "--one-factor", + ], + ) + + assert result.exit_code == 0 + assert captured["pause_2fa"] is True + + +def test_devices_one_factor_never_writes_over_the_stored_session() -> None: + """The paused login is isolated: it must not touch the real cookie jar. + + Persisting it there would replace a working trusted session with one only + Find My accepts, so the cookies go to a throwaway directory instead. + """ + + session_dir = _unique_session_dir("one-factor-isolated") + fake_api = FakeAPI(session_dir=session_dir) + fake_api.data = {"apps": {"find": {"canLaunchWithOneFactor": True}}} + captured: dict[str, Any] = {} + + def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: + assert apple_id == "user@example.com" + captured.update(kwargs) + return fake_api + + with ( + patch.object(context_module, "PyiCloudService", side_effect=fake_service), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module.utils, "get_password_from_keyring", return_value="secret" + ), + # The real one makes a directory, which the filesystem guard forbids. + patch.object( + context_module, + "TemporaryDirectory", + lambda **_: nullcontext(_FAKE_ONE_FACTOR_DIR), + ), + ): + result = _runner().invoke( + app, + [ + "devices", + "list", + "--username", + "user@example.com", + "--session-dir", + str(session_dir), + "--one-factor", + ], + ) + + assert result.exit_code == 0 + cookie_dir = captured["cookie_directory"] + assert cookie_dir != str(session_dir) + assert "one-factor" in cookie_dir + + +def test_devices_one_factor_refuses_an_account_apple_will_not_grant() -> None: + """An account without the grant is told so, before any Find My request.""" + + session_dir = _unique_session_dir("one-factor-ineligible") + fake_api = FakeAPI(session_dir=session_dir) + # Apple advertises Find My but withholds the one-factor grant. + fake_api.data = {"apps": {"find": {}}} + + with ( + patch.object(context_module, "PyiCloudService", return_value=fake_api), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object( + context_module.utils, "get_password_from_keyring", return_value="secret" + ), + # The real one makes a directory, which the filesystem guard forbids. + patch.object( + context_module, + "TemporaryDirectory", + lambda **_: nullcontext(_FAKE_ONE_FACTOR_DIR), + ), + ): + result = _runner().invoke( + app, + [ + "devices", + "list", + "--username", + "user@example.com", + "--one-factor", + ], + ) + + assert result.exit_code != 0 + assert "password-only" in str(result.exception) + + +def test_devices_without_one_factor_uses_the_stored_session() -> None: + """The default path is unchanged and does not log in again.""" + + session_dir = _unique_session_dir("one-factor-default-path") + _remember_local_account(session_dir, "user@example.com", has_session_file=True) + fake_api = FakeAPI(session_dir=session_dir) + captured: dict[str, Any] = {} + + def fake_service(*, apple_id: str, **kwargs: Any) -> FakeAPI: + assert apple_id == "user@example.com" + captured.update(kwargs) + return fake_api + + with ( + patch.object(context_module, "PyiCloudService", side_effect=fake_service), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + ): + result = _runner().invoke( + app, + [ + "devices", + "list", + "--username", + "user@example.com", + "--session-dir", + str(session_dir), + ], + ) + + assert result.exit_code == 0 + assert "pause_2fa" not in captured + + +def test_devices_erase_has_no_one_factor_escape_hatch() -> None: + """A remote wipe is irreversible, so it always costs a full session.""" + + result = _runner().invoke(app, ["devices", "erase", "--help"]) + + assert result.exit_code == 0 + assert "--one-factor" not in _plain_output(result) + + +def test_devices_erase_never_takes_the_one_factor_path() -> None: + """Hiding the flag is not enough: erase must not reach that code at all.""" + + session_dir = _unique_session_dir("erase-full-session") + _remember_local_account(session_dir, "user@example.com", has_session_file=True) + fake_api = FakeAPI(session_dir=session_dir) + one_factor = MagicMock() + + with ( + patch.object(context_module, "PyiCloudService", return_value=fake_api), + patch.object( + context_module, "configurable_ssl_verification", return_value=nullcontext() + ), + patch.object(context_module.CLIState, "get_one_factor_api", one_factor), + ): + result = _runner().invoke( + app, + [ + "devices", + "erase", + "Example iPhone", + "--force", + "--username", + "user@example.com", + "--session-dir", + str(session_dir), + ], + ) + + # The erase has to actually run, or "it did not take the one-factor path" + # is satisfied by aborting before reaching any path at all. + assert result.exit_code == 0 + assert fake_api.devices[0].erase_message is not None + one_factor.assert_not_called() + + +def test_auth_login_has_no_one_factor_flag() -> None: + """A paused session cannot be persisted, so login must not offer it.""" + + result = _runner().invoke(app, ["auth", "login", "--help"]) + + assert result.exit_code == 0 + assert "--one-factor" not in _plain_output(result) + + def test_leaf_session_dir_option_is_used_for_service_commands() -> None: """Leaf --session-dir should be honored by service commands.""" @@ -2108,6 +2333,7 @@ def build_api(**kwargs: Any) -> FakeAPI: china_mainland=None, interactive=False, accept_terms=False, + one_factor=False, with_family=False, session_dir=str(session_dir), http_proxy=None, @@ -2180,6 +2406,7 @@ def build_api(**kwargs: Any) -> FakeAPI: china_mainland=None, interactive=False, accept_terms=False, + one_factor=False, with_family=False, session_dir=str(session_dir), http_proxy=None,