#!/usr/bin/env python3 """Add, change or remove an account in GNOME Online Accounts, the way Settings does. Settings adds accounts by calling AddAccount on the Online Accounts service, which saves the account and keeps its password in the GNOME keyring. This calls the same method. The service can't change a password, so when you sign in again, Settings saves the new one in the keyring itself and asks the service to check it (EnsureCredentials); update does the same. Passwords come on stdin rather than as arguments, so they don't show up in the process list. Usage: goa-account exists goa-account add < account.json goa-account update < credentials.json goa-account check goa-account disabled goa-account enable goa-account attention goa-account pause goa-account paused goa-account remove account.json: {"provider": "owncloud", "identity": "...", "presentation_identity": "...", "credentials": {"password": "..."}, "details": {"Uri": "...", ...}} credentials.json is the account's "credentials", e.g. {"password": "..."}. check signs in with what's saved, and fails if the server says no. update exits with 3 if it saved the password but the server didn't accept it. disabled prints the features CoreDesktop uses that are turned off in Settings, e.g. "Calendar, Files", and enable turns them back on. attention exits 0 if Online Accounts found that the server no longer takes the saved password (it then shows the account as needing attention). It doesn't sign in, so it doesn't add to the server's count of failed logins. Online Accounts 3.58 only finds this for Nextcloud: a mail server refusing the password is reported as a general failure (see system/email-watch). pause empties the account's saved password, keeping its place in the keyring. Online Accounts signs in again whenever the network changes, and Evolution whenever it fetches mail; with no password neither tries, so a mail server with fail2ban doesn't block this computer for repeated wrong passwords. update puts a password back, which ends the pause. paused exits 0 if paused. """ import json import sys import gi gi.require_version("Gio", "2.0") gi.require_version("Secret", "1") from gi.repository import Gio, GLib, Secret # noqa: E402 BUS_NAME = "org.gnome.OnlineAccounts" # How Online Accounts keeps passwords in the keyring: the credentials printed # as a GVariant, under ":gen:" SCHEMA = Secret.Schema.new("org.gnome.OnlineAccounts", Secret.SchemaFlags.DONT_MATCH_NAME, {"goa-identity": Secret.SchemaAttributeType.STRING}) # The features CoreDesktop sets each kind of account up for. Every account has # a Disabled property for each feature, used or not. FEATURES = {"owncloud": ["Calendar", "Contacts", "Files"], "imap_smtp": ["Mail"]} def call(bus, path, interface, method, parameters, reply_type): return bus.call_sync(BUS_NAME, path, interface, method, parameters, GLib.VariantType(reply_type), Gio.DBusCallFlags.NONE, -1, None).unpack() def accounts(bus): (objects,) = call(bus, "/org/gnome/OnlineAccounts", "org.freedesktop.DBus.ObjectManager", "GetManagedObjects", None, "(a{oa{sa{sv}}})") for path, interfaces in objects.items(): account = interfaces.get("org.gnome.OnlineAccounts.Account") if account: yield path, account def find_accounts(bus, provider, presentation_identity): return [(path, account) for path, account in accounts(bus) if account.get("ProviderType") == provider and account.get("PresentationIdentity") == presentation_identity] def find(bus, provider, presentation_identity): return [path for path, _ in find_accounts(bus, provider, presentation_identity)] def exists(bus, provider, presentation_identity): return bool(find(bus, provider, presentation_identity)) def add(bus, account): credentials = {key: GLib.Variant("s", value) for key, value in account["credentials"].items()} parameters = GLib.Variant("(sssa{sv}a{ss})", ( account["provider"], account["identity"], account["presentation_identity"], credentials, account["details"])) call(bus, "/org/gnome/OnlineAccounts/Manager", "org.gnome.OnlineAccounts.Manager", "AddAccount", parameters, "(o)") def ensure_credentials(bus, path): call(bus, path, "org.gnome.OnlineAccounts.Account", "EnsureCredentials", None, "(i)") # N in the keyring's name comes from the provider, so this saves over the # account's own item rather than making one def store_credentials(provider, account_id, credentials): items = Secret.password_search_sync( None, {"xdg:schema": "org.gnome.OnlineAccounts"}, Secret.SearchFlags.ALL | Secret.SearchFlags.UNLOCK, None) identities = [item.get_attributes().get("goa-identity", "") for item in items] identities = [identity for identity in identities if identity.startswith(f"{provider}:gen") and identity.endswith(f":{account_id}")] if not identities: raise LookupError(f"there's no saved password for {account_id} in the keyring") secret = GLib.Variant("a{sv}", {key: GLib.Variant("s", value) for key, value in credentials.items()}).print_(True) for identity in identities: Secret.password_store_sync(SCHEMA, {"goa-identity": identity}, Secret.COLLECTION_DEFAULT, f"GOA {provider} credentials for identity {account_id}", secret, None) # The account's saved credentials, e.g. {"imap-password": "..."} def lookup_credentials(provider, account_id): items = Secret.password_search_sync( None, {"xdg:schema": "org.gnome.OnlineAccounts"}, Secret.SearchFlags.ALL | Secret.SearchFlags.UNLOCK | Secret.SearchFlags.LOAD_SECRETS, None) for item in items: identity = item.get_attributes().get("goa-identity", "") if identity.startswith(f"{provider}:gen") and identity.endswith(f":{account_id}"): return GLib.Variant.parse(None, item.get_secret().get_text(), None, None).unpack() raise LookupError(f"there's no saved password for {account_id} in the keyring") # The credential each kind of account signs in with PASSWORD_KEYS = {"owncloud": "password", "imap_smtp": "imap-password"} def attention(bus, provider, presentation_identity): found = find_accounts(bus, provider, presentation_identity) if not found: raise LookupError(f"there's no {provider} account for {presentation_identity}") return any(account.get("AttentionNeeded") for _, account in found) def pause(bus, provider, presentation_identity): found = find_accounts(bus, provider, presentation_identity) if not found: raise LookupError(f"there's no {provider} account for {presentation_identity}") for _, account in found: store_credentials(provider, account["Id"], {}) def paused(bus, provider, presentation_identity): found = find_accounts(bus, provider, presentation_identity) if not found: raise LookupError(f"there's no {provider} account for {presentation_identity}") return any(PASSWORD_KEYS[provider] not in lookup_credentials(provider, account["Id"]) for _, account in found) def update(bus, provider, presentation_identity, credentials): found = find_accounts(bus, provider, presentation_identity) if not found: raise LookupError(f"there's no {provider} account for {presentation_identity}") for path, account in found: store_credentials(provider, account["Id"], credentials) try: for path, _ in found: ensure_credentials(bus, path) except GLib.Error as error: print(f"GNOME Online Accounts: {error.message}", file=sys.stderr) return 3 return 0 def check(bus, provider, presentation_identity): found = find(bus, provider, presentation_identity) if not found: raise LookupError(f"there's no {provider} account for {presentation_identity}") for path in found: ensure_credentials(bus, path) def disabled(bus, provider, presentation_identity): found = find_accounts(bus, provider, presentation_identity) if not found: raise LookupError(f"there's no {provider} account for {presentation_identity}") return [feature for feature in FEATURES.get(provider, []) if any(account.get(f"{feature}Disabled") for _, account in found)] # Settings turns a feature off or on by setting its property def enable(bus, provider, presentation_identity): for path, account in find_accounts(bus, provider, presentation_identity): for feature in FEATURES.get(provider, []): if account.get(f"{feature}Disabled"): call(bus, path, "org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.gnome.OnlineAccounts.Account", f"{feature}Disabled", GLib.Variant("b", False))), "()") def remove(bus, provider, presentation_identity): for path in find(bus, provider, presentation_identity): call(bus, path, "org.gnome.OnlineAccounts.Account", "Remove", None, "()") def main(): bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) if len(sys.argv) == 4 and sys.argv[1] == "exists": return 0 if exists(bus, sys.argv[2], sys.argv[3]) else 1 if len(sys.argv) == 2 and sys.argv[1] == "add": account = json.load(sys.stdin) if exists(bus, account["provider"], account["presentation_identity"]): return 0 add(bus, account) return 0 if len(sys.argv) == 4 and sys.argv[1] == "update": return update(bus, sys.argv[2], sys.argv[3], json.load(sys.stdin)) if len(sys.argv) == 4 and sys.argv[1] == "check": check(bus, sys.argv[2], sys.argv[3]) return 0 if len(sys.argv) == 4 and sys.argv[1] == "disabled": print(", ".join(disabled(bus, sys.argv[2], sys.argv[3]))) return 0 if len(sys.argv) == 4 and sys.argv[1] == "enable": enable(bus, sys.argv[2], sys.argv[3]) return 0 if len(sys.argv) == 4 and sys.argv[1] == "attention": return 0 if attention(bus, sys.argv[2], sys.argv[3]) else 1 if len(sys.argv) == 4 and sys.argv[1] == "pause": pause(bus, sys.argv[2], sys.argv[3]) return 0 if len(sys.argv) == 4 and sys.argv[1] == "paused": return 0 if paused(bus, sys.argv[2], sys.argv[3]) else 1 if len(sys.argv) == 4 and sys.argv[1] == "remove": remove(bus, sys.argv[2], sys.argv[3]) return 0 print(__doc__.strip().split("\n\n")[2], file=sys.stderr) return 2 if __name__ == "__main__": try: sys.exit(main()) except GLib.Error as error: print(f"GNOME Online Accounts: {error.message}", file=sys.stderr) sys.exit(1) except LookupError as error: print(f"GNOME Online Accounts: {error}", file=sys.stderr) sys.exit(1)