[#64] Adds Keychain-based Authentication

Squashed commit of the following:

commit 0eb23aa87c264152716933e03827f040742e6d70
Author: Adam Coddington <me@adamcoddington.net>
Date:   Sat Feb 20 14:21:48 2016 -0800

    Updating readme to reflect updated flow.

commit 840268e2db6093b5cb573c6a3e71204bf5b08b48
Author: Adam Coddington <me@adamcoddington.net>
Date:   Sat Feb 20 14:18:39 2016 -0800

    Dropping python 2.6 support workaround.

commit 9dcbd460482c2925bda490be2be884a2a2526062
Author: Adam Coddington <me@adamcoddington.net>
Date:   Sat Feb 20 14:18:00 2016 -0800

    Adding additional behavior at @torarnv's request.

commit 6c711bb12beea7c792b5d386203373423b6e56e2
Author: Adam Coddington <me@adamcoddington.net>
Date:   Sat Jan 23 15:08:29 2016 -0800

    Workaround for obsolete versions of Python 2.

commit b0765b7b6bf9974348061043da9a110c6bd7d985
Author: Adam Coddington <me@adamcoddington.net>
Date:   Sat Jan 23 14:56:53 2016 -0800

    Style changes to avoid line length overage.

commit 4decc576432ef23edae01b9621f2689b4f3c6c84
Author: Adam Coddington <me@adamcoddington.net>
Date:   Sat Jan 23 14:01:27 2016 -0800

    Adding documentation; also adding --delete-from-keyring command-line option.

commit a6b0224e93a8bc9159cf06ba5792a384f7fbb060
Author: Adam Coddington <me@adamcoddington.net>
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 <me@adamcoddington.net>
Date:   Sat Jan 23 13:43:39 2016 -0800

    Removing unused imports.
This commit is contained in:
Adam Coddington 2016-02-23 17:44:03 -08:00
parent 77978b391f
commit 2f0dcd1ac2
6 changed files with 170 additions and 18 deletions

View file

@ -15,13 +15,27 @@ At its core, PyiCloud connects to iCloud using your username and password, then
Authentication 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 >>> from pyicloud import PyiCloudService
>>> api = PyiCloudService('jappleseed@apple.com', 'password') >>> api = PyiCloudService('jappleseed@apple.com', 'password')
In the event that the username/password combination is invalid, a ``PyiCloudFailedLoginException`` exception is thrown. 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 Devices
======= =======

View file

@ -1,10 +1,8 @@
import six import six
import copy
import uuid import uuid
import hashlib import hashlib
import json import json
import logging import logging
import pickle
import requests import requests
import sys import sys
import tempfile import tempfile
@ -19,6 +17,7 @@ from pyicloud.services import (
ContactsService, ContactsService,
RemindersService RemindersService
) )
from pyicloud.utils import get_password_from_keyring
if six.PY3: if six.PY3:
import http.cookiejar as cookielib import http.cookiejar as cookielib
@ -39,7 +38,12 @@ class PyiCloudService(object):
pyicloud = PyiCloudService('username@apple.com', 'password') pyicloud = PyiCloudService('username@apple.com', 'password')
pyicloud.iphone.location() 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.discovery = None
self.client_id = str(uuid.uuid1()).upper() self.client_id = str(uuid.uuid1()).upper()
self.user = {'apple_id': apple_id, 'password': password} self.user = {'apple_id': apple_id, 'password': password}

View file

@ -9,7 +9,10 @@ import argparse
import pickle import pickle
import sys import sys
from click import confirm
import pyicloud import pyicloud
from . import utils
DEVICE_ERROR = ( DEVICE_ERROR = (
@ -52,7 +55,25 @@ def main(args=None):
action="store", action="store",
dest="password", dest="password",
default="", 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( parser.add_argument(
"--list", "--list",
@ -152,17 +173,57 @@ def main(args=None):
) )
command_line = parser.parse_args(args) 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 username = command_line.username
try: password = command_line.password
api = PyiCloudService(
command_line.username.strip(), if username and command_line.delete_from_keyring:
command_line.password.strip() 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: except pyicloud.exceptions.PyiCloudFailedLoginException:
raise RuntimeError('Bad username or password') # 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: for dev in api.devices:
if ( if (

View file

@ -1,8 +1,15 @@
class PyiCloudException(Exception):
class PyiCloudNoDevicesException(Exception):
pass pass
class PyiCloudFailedLoginException(Exception): class PyiCloudNoDevicesException(PyiCloudException):
pass
class PyiCloudFailedLoginException(PyiCloudException):
pass
class NoStoredPasswordAvailable(PyiCloudException):
pass pass

63
pyicloud/utils.py Normal file
View file

@ -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,
)

View file

@ -1,4 +1,7 @@
requests>=1.2 requests>=1.2
keyring>=8.0,<9.0
keyrings.alt>=1.0,<2.0
click>=6.0,<7.0
six six
pytz pytz
certifi certifi