260 lines
11 KiB
Python
Executable File
260 lines
11 KiB
Python
Executable File
#!/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 <provider> <presentation-identity>
|
|
goa-account add < account.json
|
|
goa-account update <provider> <presentation-identity> < credentials.json
|
|
goa-account check <provider> <presentation-identity>
|
|
goa-account disabled <provider> <presentation-identity>
|
|
goa-account enable <provider> <presentation-identity>
|
|
goa-account attention <provider> <presentation-identity>
|
|
goa-account pause <provider> <presentation-identity>
|
|
goa-account paused <provider> <presentation-identity>
|
|
goa-account remove <provider> <presentation-identity>
|
|
|
|
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 "<provider>:gen<N>:<account id>"
|
|
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 <feature>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)
|