From 2f0dcd1ac2f4170ac5ceb5334c364a0c91e49604 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Tue, 23 Feb 2016 17:44:03 -0800 Subject: [PATCH] [#64] Adds Keychain-based Authentication Squashed commit of the following: commit 0eb23aa87c264152716933e03827f040742e6d70 Author: Adam Coddington Date: Sat Feb 20 14:21:48 2016 -0800 Updating readme to reflect updated flow. commit 840268e2db6093b5cb573c6a3e71204bf5b08b48 Author: Adam Coddington Date: Sat Feb 20 14:18:39 2016 -0800 Dropping python 2.6 support workaround. commit 9dcbd460482c2925bda490be2be884a2a2526062 Author: Adam Coddington Date: Sat Feb 20 14:18:00 2016 -0800 Adding additional behavior at @torarnv's request. commit 6c711bb12beea7c792b5d386203373423b6e56e2 Author: Adam Coddington Date: Sat Jan 23 15:08:29 2016 -0800 Workaround for obsolete versions of Python 2. commit b0765b7b6bf9974348061043da9a110c6bd7d985 Author: Adam Coddington Date: Sat Jan 23 14:56:53 2016 -0800 Style changes to avoid line length overage. commit 4decc576432ef23edae01b9621f2689b4f3c6c84 Author: Adam Coddington Date: Sat Jan 23 14:01:27 2016 -0800 Adding documentation; also adding --delete-from-keyring command-line option. commit a6b0224e93a8bc9159cf06ba5792a384f7fbb060 Author: Adam Coddington Date: Sat Jan 23 13:44:09 2016 -0800 Adding functionality allowing authentication using iCloud passwords stored in the system keychain. Adds the following new command-line options: * `--password-interactive`: Allows you to specify your password interactively rather than typing it into the command-line. * `--store-in-keychain`: Allows you to store the password in use in the system keychain. If no password is specified when instantiating `PyiCloudService` or when using the command-line utility (via either `--password-interactive` or `--password`), the system keychain will be queried for a stored password, and an exception will be raised if one was not found. commit 4ba03fb02d51673dfb7183dde49ab4c0bec4afb3 Author: Adam Coddington Date: Sat Jan 23 13:43:39 2016 -0800 Removing unused imports. --- README.rst | 16 +++++++- pyicloud/base.py | 10 +++-- pyicloud/cmdline.py | 83 ++++++++++++++++++++++++++++++++++++------ pyicloud/exceptions.py | 13 +++++-- pyicloud/utils.py | 63 ++++++++++++++++++++++++++++++++ requirements.txt | 3 ++ 6 files changed, 170 insertions(+), 18 deletions(-) create mode 100644 pyicloud/utils.py diff --git a/README.rst b/README.rst index f4adbb1..327a703 100644 --- a/README.rst +++ b/README.rst @@ -15,13 +15,27 @@ At its core, PyiCloud connects to iCloud using your username and password, then Authentication ============== -Authentication is as simple as passing your username and password to the ``PyiCloudService`` class: +Authentication without using a saved password is as simple as passing your username and password to the ``PyiCloudService`` class: >>> from pyicloud import PyiCloudService >>> api = PyiCloudService('jappleseed@apple.com', 'password') In the event that the username/password combination is invalid, a ``PyiCloudFailedLoginException`` exception is thrown. +You can also store your password in the system keyring using the command-line tool: + +>>> icloud --username=jappleseed@apple.com +ICloud Password for jappleseed@apple.com: +Save password in keyring? (y/N) + +If you have stored a password in the keyring, you will not be required to provide a password when interacting with the command-line tool or instantiating the ``PyiCloudService`` class for the username you stored the password for. + +>>> api = PyiCloudService('jappleseed@apple.com') + +If you would like to delete a password stored in your system keyring, you can clear a stored password using the ``--delete-from-keyring`` command-line option: + +>>> icloud --username=jappleseed@apple.com --delete-from-keyring + ======= Devices ======= diff --git a/pyicloud/base.py b/pyicloud/base.py index c1bb149..5024b38 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -1,10 +1,8 @@ import six -import copy import uuid import hashlib import json import logging -import pickle import requests import sys import tempfile @@ -19,6 +17,7 @@ from pyicloud.services import ( ContactsService, RemindersService ) +from pyicloud.utils import get_password_from_keyring if six.PY3: import http.cookiejar as cookielib @@ -39,7 +38,12 @@ class PyiCloudService(object): pyicloud = PyiCloudService('username@apple.com', 'password') pyicloud.iphone.location() """ - def __init__(self, apple_id, password, cookie_directory=None, verify=True): + def __init__( + self, apple_id, password=None, cookie_directory=None, verify=True + ): + if password is None: + password = get_password_from_keyring(apple_id) + self.discovery = None self.client_id = str(uuid.uuid1()).upper() self.user = {'apple_id': apple_id, 'password': password} diff --git a/pyicloud/cmdline.py b/pyicloud/cmdline.py index e7a1f70..91c7953 100644 --- a/pyicloud/cmdline.py +++ b/pyicloud/cmdline.py @@ -9,7 +9,10 @@ import argparse import pickle import sys +from click import confirm + import pyicloud +from . import utils DEVICE_ERROR = ( @@ -52,7 +55,25 @@ def main(args=None): action="store", dest="password", default="", - help="Apple ID Password to Use", + help=( + "Apple ID Password to Use; if unspecified, password will be " + "fetched from the system keyring." + ) + ) + parser.add_argument( + "-n", + "--non-interactive", + action="store_false", + dest="interactive", + default=True, + help="Disable interactive prompts." + ) + parser.add_argument( + "--delete-from-keyring", + action="store_true", + dest="delete_from_keyring", + default=False, + help="Delete stored password in system keyring for this username.", ) parser.add_argument( "--list", @@ -152,17 +173,57 @@ def main(args=None): ) command_line = parser.parse_args(args) - if not command_line.username or not command_line.password: - parser.error('No username or password supplied') - from pyicloud import PyiCloudService - try: - api = PyiCloudService( - command_line.username.strip(), - command_line.password.strip() - ) - except pyicloud.exceptions.PyiCloudFailedLoginException: - raise RuntimeError('Bad username or password') + username = command_line.username + password = command_line.password + + if username and command_line.delete_from_keyring: + utils.delete_password_in_keyring(username) + + failure_count = 0 + while True: + # Which password we use is determined by your username, so we + # do need to check for this first and separately. + if not username: + parser.error('No username supplied') + + if not password: + password = utils.get_password( + username, + interactive=command_line.interactive + ) + + if not password: + parser.error('No password supplied') + + try: + api = pyicloud.PyiCloudService( + username.strip(), + password.strip() + ) + if ( + not utils.password_exists_in_keyring(username) and + command_line.interactive and + confirm("Save password in keyring? ") + ): + utils.store_password_in_keyring(username, password) + break + except pyicloud.exceptions.PyiCloudFailedLoginException: + # If they have a stored password; we just used it and + # it did not work; let's delete it if there is one. + if utils.password_exists_in_keyring(username): + utils.delete_password_in_keyring(username) + + message = "Bad username or password for {username}".format( + username=username, + ) + password = None + + failure_count += 1 + if failure_count >= 3: + raise RuntimeError(message) + + print(message, file=sys.stderr) for dev in api.devices: if ( diff --git a/pyicloud/exceptions.py b/pyicloud/exceptions.py index ded3635..2ae4c6b 100644 --- a/pyicloud/exceptions.py +++ b/pyicloud/exceptions.py @@ -1,8 +1,15 @@ - -class PyiCloudNoDevicesException(Exception): +class PyiCloudException(Exception): pass -class PyiCloudFailedLoginException(Exception): +class PyiCloudNoDevicesException(PyiCloudException): + pass + + +class PyiCloudFailedLoginException(PyiCloudException): + pass + + +class NoStoredPasswordAvailable(PyiCloudException): pass diff --git a/pyicloud/utils.py b/pyicloud/utils.py new file mode 100644 index 0000000..edccde3 --- /dev/null +++ b/pyicloud/utils.py @@ -0,0 +1,63 @@ +import getpass +import keyring + +from .exceptions import NoStoredPasswordAvailable + + +KEYRING_SYSTEM = 'pyicloud://icloud-password' + + +def get_password(username, interactive=True): + try: + return get_password_from_keyring(username) + except NoStoredPasswordAvailable: + if not interactive: + raise + + return getpass.getpass( + 'ICloud Password for {username}: '.format( + username=username, + ) + ) + + +def password_exists_in_keyring(username): + try: + get_password_from_keyring(username) + except NoStoredPasswordAvailable: + return False + + return True + + +def get_password_from_keyring(username): + result = keyring.get_password( + KEYRING_SYSTEM, + username + ) + if result is None: + raise NoStoredPasswordAvailable( + "No pyicloud password for {username} could be found " + "in the system keychain. Use the `--store-in-keyring` " + "command-line option for storing a password for this " + "username.".format( + username=username, + ) + ) + + return result + + +def store_password_in_keyring(username, password): + return keyring.set_password( + KEYRING_SYSTEM, + username, + password, + ) + + +def delete_password_in_keyring(username): + return keyring.delete_password( + KEYRING_SYSTEM, + username, + ) diff --git a/requirements.txt b/requirements.txt index bff0de2..6829ac6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ requests>=1.2 +keyring>=8.0,<9.0 +keyrings.alt>=1.0,<2.0 +click>=6.0,<7.0 six pytz certifi