Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
27 changes: 21 additions & 6 deletions pyicloud/cli/commands/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
HttpsProxyOption,
LogLevelOption,
NoVerifySslOption,
OneFactorOption,
OutputFormatOption,
SessionDirOption,
UsernameOption,
Expand Down Expand Up @@ -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."""

Expand All @@ -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(
Expand Down Expand Up @@ -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."""

Expand All @@ -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:
Expand Down Expand Up @@ -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."""

Expand All @@ -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,
Expand Down Expand Up @@ -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."""

Expand All @@ -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,
Expand Down Expand Up @@ -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."""

Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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."""

Expand All @@ -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.")
Expand Down
68 changes: 67 additions & 1 deletion pyicloud/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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."""

Expand Down
16 changes: 16 additions & 0 deletions pyicloud/cli/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions pyicloud/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading