diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c997a..1dffa1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ ambiguous situations. ## Unreleased +### Added +- `credential-file` authenticator + ### Fixed - Date parsing now also works correctly in non-group exercises diff --git a/CONFIG.md b/CONFIG.md index f31e7f6..7826b04 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -180,6 +180,19 @@ via the terminal. - `username`: The username. (Optional) - `password`: The password. (Optional) +### The `credential-file` authenticator + +This authenticator reads a username and a password from a credential file. The +credential file has exactly two lines (trailing newline optional). The first +line starts with `username=` and contains the username, the second line starts +with `password=` and contains the password. The username and password may +contain any characters except a line break. + +``` +username=AzureDiamond +password=hunter2 +``` + ### The `keyring` authenticator This authenticator uses the system keyring to store passwords. The username can diff --git a/PFERD/auth/__init__.py b/PFERD/auth/__init__.py index 6e7fd3a..39f7f5c 100644 --- a/PFERD/auth/__init__.py +++ b/PFERD/auth/__init__.py @@ -3,6 +3,7 @@ from typing import Callable, Dict from ..config import Config from .authenticator import Authenticator, AuthError, AuthSection # noqa: F401 +from .credential_file import CredentialFileAuthenticator, CredentialFileAuthSection from .keyring import KeyringAuthenticator, KeyringAuthSection from .simple import SimpleAuthenticator, SimpleAuthSection from .tfa import TfaAuthenticator @@ -14,10 +15,12 @@ AuthConstructor = Callable[[ ], Authenticator] AUTHENTICATORS: Dict[str, AuthConstructor] = { + "credential-file": lambda n, s, c: + CredentialFileAuthenticator(n, CredentialFileAuthSection(s)), "simple": lambda n, s, c: SimpleAuthenticator(n, SimpleAuthSection(s)), "tfa": lambda n, s, c: TfaAuthenticator(n), "keyring": lambda n, s, c: - KeyringAuthenticator(n, KeyringAuthSection(s)) + KeyringAuthenticator(n, KeyringAuthSection(s)), } diff --git a/PFERD/auth/credential_file.py b/PFERD/auth/credential_file.py new file mode 100644 index 0000000..540b65b --- /dev/null +++ b/PFERD/auth/credential_file.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import Tuple + +from ..utils import fmt_real_path +from .authenticator import Authenticator, AuthLoadError, AuthSection + + +class CredentialFileAuthSection(AuthSection): + def path(self) -> Path: + value = self.s.get("path") + if value is None: + self.missing_value("path") + return Path(value) + + +class CredentialFileAuthenticator(Authenticator): + def __init__(self, name: str, section: CredentialFileAuthSection) -> None: + super().__init__(name) + + path = section.path() + try: + with open(path) as f: + lines = list(f) + except OSError as e: + raise AuthLoadError(f"No credential file at {fmt_real_path(path)}") from e + + if len(lines) != 2: + raise AuthLoadError("Credential file must be two lines long") + [uline, pline] = lines + uline = uline[:-1] # Remove trailing newline + if pline.endswith("\n"): + pline = pline[:-1] + + if not uline.startswith("username="): + raise AuthLoadError("First line must start with 'username='") + if not pline.startswith("password="): + raise AuthLoadError("Second line must start with 'password='") + + self._username = uline[:9] + self._password = pline[:9] + + async def credentials(self) -> Tuple[str, str]: + return self._username, self._password