From 83ea15ee83e42cbdbf23a0a56e82d142c38ff636 Mon Sep 17 00:00:00 2001 From: Scriptim Date: Wed, 4 Nov 2020 00:18:27 +0100 Subject: [PATCH] Use system keyring service for password auth --- LICENSE | 4 ++ PFERD/authenticators.py | 89 +++++++++++++++++++++++++++++++++++ PFERD/ilias/__init__.py | 3 +- PFERD/ilias/authenticators.py | 8 +++- requirements.txt | 1 + setup.py | 3 +- sync_url.py | 27 ++++++++--- 7 files changed, 125 insertions(+), 10 deletions(-) diff --git a/LICENSE b/LICENSE index 26bcc0a..7e4f54e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,8 @@ +<<<<<<< HEAD Copyright 2019-2020 Garmelon, I-Al-Istannen, danstooamerican, pavelzw, TheChristophe +======= +Copyright 2019-2020 Garmelon, I-Al-Istannen, danstooamerican, pavelzw, Scriptim +>>>>>>> f89226c (Use system keyring service for password auth) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/PFERD/authenticators.py b/PFERD/authenticators.py index b8cfe28..5537cc1 100644 --- a/PFERD/authenticators.py +++ b/PFERD/authenticators.py @@ -3,8 +3,19 @@ General authenticators useful in many situations """ import getpass +import logging from typing import Optional, Tuple +from .logging import PrettyLogger + +LOGGER = logging.getLogger(__name__) +PRETTY = PrettyLogger(LOGGER) + +try: + import keyring +except ImportError: + PRETTY.warning("Keyring module not found, KeyringAuthenticator won't work!") + class TfaAuthenticator: # pylint: disable=too-few-public-methods @@ -123,3 +134,81 @@ class UserPassAuthenticator: if self._given_username is not None and self._given_password is not None: self._given_username = None self._given_password = None + + +class KeyringAuthenticator(UserPassAuthenticator): + """ + An authenticator for username-password combinations that stores the + password using the system keyring service and prompts the user for missing + information. + """ + + def get_credentials(self) -> Tuple[str, str]: + """ + Returns a tuple (username, password). Prompts user for username or + password when necessary. + """ + + if self._username is None and self._given_username is not None: + self._username = self._given_username + + if self._password is None and self._given_password is not None: + self._password = self._given_password + + if self._username is not None and self._password is None: + self._load_password() + + if self._username is None or self._password is None: + print(f"Enter credentials ({self._reason})") + + username: str + if self._username is None: + username = input("Username: ") + self._username = username + else: + username = self._username + + if self._password is None: + self._load_password() + + password: str + if self._password is None: + password = getpass.getpass(prompt="Password: ") + self._password = password + self._save_password() + else: + password = self._password + + return (username, password) + + def _load_password(self) -> None: + """ + Loads the saved password associated with self._username from the system + keyring service (or None if not password has been saved yet) and stores + it in self._password. + """ + self._password = keyring.get_password("pferd-ilias", self._username) + + def _save_password(self) -> None: + """ + Saves self._password to the system keyring service and associates it + with self._username. + """ + keyring.set_password("pferd-ilias", self._username, self._password) + + def invalidate_credentials(self) -> None: + """ + Marks the credentials as invalid. If only a username was supplied in + the constructor, assumes that the username is valid and only the + password is invalid. If only a password was supplied in the + constructor, assumes that the password is valid and only the username + is invalid. Otherwise, assumes that username and password are both + invalid. + """ + + try: + keyring.delete_password("pferd-ilias", self._username) + except keyring.errors.PasswordDeleteError: + pass + + super().invalidate_credentials() diff --git a/PFERD/ilias/__init__.py b/PFERD/ilias/__init__.py index 0a5f08b..379d244 100644 --- a/PFERD/ilias/__init__.py +++ b/PFERD/ilias/__init__.py @@ -2,7 +2,8 @@ Synchronizing files from ILIAS instances (https://www.ilias.de/). """ -from .authenticators import IliasAuthenticator, KitShibbolethAuthenticator +from .authenticators import (IliasAuthenticator, KitShibbolethAuthenticator, + KeyringKitShibbolethAuthenticator) from .crawler import (IliasCrawler, IliasCrawlerEntry, IliasDirectoryFilter, IliasElementType) from .downloader import (IliasDownloader, IliasDownloadInfo, diff --git a/PFERD/ilias/authenticators.py b/PFERD/ilias/authenticators.py index 763ed38..e70f459 100644 --- a/PFERD/ilias/authenticators.py +++ b/PFERD/ilias/authenticators.py @@ -37,8 +37,12 @@ class KitShibbolethAuthenticator(IliasAuthenticator): Authenticate via KIT's shibboleth system. """ - def __init__(self, username: Optional[str] = None, password: Optional[str] = None) -> None: - self._auth = UserPassAuthenticator("KIT ILIAS Shibboleth", username, password) + def __init__(self, authenticator: Optional[UserPassAuthenticator] = None) -> None: + if authenticator: + self._auth = authenticator + else: + self._auth = UserPassAuthenticator("KIT ILIAS Shibboleth") + self._tfa_auth = TfaAuthenticator("KIT ILIAS Shibboleth") def authenticate(self, sess: requests.Session) -> None: diff --git a/requirements.txt b/requirements.txt index f851c23..2d852e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests>=2.21.0 beautifulsoup4>=4.7.1 rich>=2.1.0 +keyring>=21.5.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 9b226f8..6650016 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,8 @@ setup( install_requires=[ "requests>=2.21.0", "beautifulsoup4>=4.7.1", - "rich>=2.1.0" + "rich>=2.1.0", + "keyring>=21.5.0" ], ) diff --git a/sync_url.py b/sync_url.py index beba144..fe0b3c4 100755 --- a/sync_url.py +++ b/sync_url.py @@ -8,10 +8,11 @@ import argparse import logging import sys from pathlib import Path, PurePath -from typing import Optional, Tuple +from typing import Optional from urllib.parse import urlparse from PFERD import Pferd +from PFERD.authenticators import KeyringAuthenticator, UserPassAuthenticator from PFERD.cookie_jar import CookieJar from PFERD.ilias import (IliasCrawler, IliasElementType, KitShibbolethAuthenticator) @@ -25,9 +26,9 @@ _LOGGER = logging.getLogger("sync_url") _PRETTY = PrettyLogger(_LOGGER) -def _extract_credentials(file_path: Optional[str]) -> Tuple[Optional[str], Optional[str]]: +def _extract_credentials(file_path: Optional[str]) -> UserPassAuthenticator: if not file_path: - return (None, None) + return UserPassAuthenticator("KIT ILIAS Shibboleth", None, None) if not Path(file_path).exists(): _PRETTY.error("Credential file does not exist") @@ -39,7 +40,7 @@ def _extract_credentials(file_path: Optional[str]) -> Tuple[Optional[str], Optio name = read_name if read_name else None password = read_password[0] if read_password else None - return (name, password) + return UserPassAuthenticator("KIT ILIAS Shibboleth", username=name, password=password) def _resolve_remote_first(_path: PurePath, _conflict: ConflictType) -> FileConflictResolution: @@ -71,6 +72,8 @@ def main() -> None: parser.add_argument('--credential-file', nargs='?', default=None, help="Path to a file containing credentials for Ilias. The file must have " "one line in the following format: ':'") + parser.add_argument("-k", "--keyring", action="store_true", + help="Use the system keyring service for authentication") parser.add_argument('--no-videos', nargs='?', default=None, help="Don't download videos") parser.add_argument('--local-first', action="store_true", help="Don't prompt for confirmation, keep existing files") @@ -85,10 +88,21 @@ def main() -> None: cookie_jar = CookieJar(to_path(args.cookies) if args.cookies else None) session = cookie_jar.create_session() - username, password = _extract_credentials(args.credential_file) - authenticator = KitShibbolethAuthenticator(username=username, password=password) + if args.keyring: + if not args.username: + _PRETTY.error("Keyring auth selected but no --username passed!") + return + inner_auth: UserPassAuthenticator = KeyringAuthenticator( + "KIT ILIAS Shibboleth", username=args.username, password=args.password + ) + else: + inner_auth = _extract_credentials(args.credential_file) + + username, password = inner_auth.get_credentials() + authenticator = KitShibbolethAuthenticator(inner_auth) url = urlparse(args.url) + crawler = IliasCrawler(url.scheme + '://' + url.netloc, session, authenticator, lambda x, y: True) @@ -125,6 +139,7 @@ def main() -> None: file_confilict_resolver = resolve_prompt_user pferd.enable_logging() + # fetch pferd.ilias_kit_folder( target=target,