diff --git a/PFERD/authenticators.py b/PFERD/authenticators.py index b8cfe28..1d59cfd 100644 --- a/PFERD/authenticators.py +++ b/PFERD/authenticators.py @@ -5,6 +5,12 @@ General authenticators useful in many situations import getpass from typing import Optional, Tuple +# optional import for KeyringAuthenticator +try: + # pylint: disable=import-error + import keyring +except ModuleNotFoundError: + pass class TfaAuthenticator: # pylint: disable=too-few-public-methods @@ -123,3 +129,82 @@ 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..02ebba6 100644 --- a/PFERD/ilias/authenticators.py +++ b/PFERD/ilias/authenticators.py @@ -9,7 +9,7 @@ from typing import Optional import bs4 import requests -from ..authenticators import TfaAuthenticator, UserPassAuthenticator +from ..authenticators import TfaAuthenticator, UserPassAuthenticator, KeyringAuthenticator from ..utils import soupify LOGGER = logging.getLogger(__name__) @@ -129,3 +129,13 @@ class KitShibbolethAuthenticator(IliasAuthenticator): @staticmethod def _tfa_required(soup: bs4.BeautifulSoup) -> bool: return soup.find(id="j_tokenNumber") is not None + + +class KeyringKitShibbolethAuthenticator(KitShibbolethAuthenticator): + """ + Authenticate via KIT's shibboleth system using the system keyring service. + """ + + def __init__(self, username: Optional[str] = None, password: Optional[str] = None) -> None: + super().__init__() + self._auth = KeyringAuthenticator("KIT ILIAS Shibboleth", username, password) diff --git a/sync_url.py b/sync_url.py index d2dce94..c3be0b9 100755 --- a/sync_url.py +++ b/sync_url.py @@ -11,7 +11,8 @@ from urllib.parse import urlparse from PFERD import Pferd from PFERD.cookie_jar import CookieJar from PFERD.ilias import (IliasCrawler, IliasElementType, - KitShibbolethAuthenticator) + KitShibbolethAuthenticator, + KeyringKitShibbolethAuthenticator) from PFERD.utils import to_path @@ -20,6 +21,7 @@ def main() -> None: parser.add_argument("--test-run", action="store_true") parser.add_argument('-c', '--cookies', nargs='?', default=None, help="File to store cookies in") parser.add_argument('--no-videos', nargs='?', default=None, help="Don't download videos") + parser.add_argument("-k", "--keyring", action="store_true", help="Use the system keyring service for authentication") parser.add_argument('url', help="URL to the course page") parser.add_argument('folder', nargs='?', default=None, help="Folder to put stuff into") args = parser.parse_args() @@ -28,7 +30,8 @@ def main() -> None: cookie_jar = CookieJar(to_path(args.cookies) if args.cookies else None) session = cookie_jar.create_session() - authenticator = KitShibbolethAuthenticator() + authenticator = (KeyringKitShibbolethAuthenticator() if args.keyring + else KitShibbolethAuthenticator()) crawler = IliasCrawler(url.scheme + '://' + url.netloc, session, authenticator, lambda x, y: True) @@ -55,11 +58,14 @@ def main() -> None: pferd.enable_logging() # fetch + (username, password) = authenticator._auth.get_credentials() pferd.ilias_kit_folder( target=folder, full_url=args.url, cookies=args.cookies, - dir_filter=dir_filter + dir_filter=dir_filter, + username=username, + password=password )