Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integration with Password Managers #83

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ You will need a little experience running things from the command line to use th

```
usage: gcexport.py [-h] [--version] [-v] [--username USERNAME]
[--password PASSWORD] [-c COUNT] [-e EXTERNAL] [-a ARGS]
[--password PASSWORD] [--passmanager PASSWORD_MANAGER]
[-c COUNT] [-e EXTERNAL] [-a ARGS]
[-f {gpx,tcx,original,json}] [-d DIRECTORY] [-s SUBDIR]
[-lp LOGPATH] [-u] [-ot] [--desc [DESC]] [-t TEMPLATE]
[-fp] [-sa START_ACTIVITY_NO] [-ex FILE]
Expand All @@ -64,6 +65,8 @@ options:
-v, --verbosity increase output and log verbosity, save more intermediate files
--username USERNAME your Garmin Connect username or email address (otherwise, you will be prompted)
--password PASSWORD your Garmin Connect password (otherwise, you will be prompted)
--passmanager PASSWORD_MANAGER
use password manager for Garmin Connect credentials (supported: BitWarden, 1Password)
-c COUNT, --count COUNT
number of recent activities to download, or 'all' (default: 1)
-e EXTERNAL, --external EXTERNAL
Expand Down
28 changes: 23 additions & 5 deletions gcexport.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,8 @@ def parse_arguments(argv):
help='your Garmin Connect username or email address (otherwise, you will be prompted)')
parser.add_argument('--password',
help='your Garmin Connect password (otherwise, you will be prompted)')
parser.add_argument('--passmanager', type=str, metavar='PASSWORD_MANAGER',
help='use password manager for Garmin Connect credentials (supported: BitWarden, 1Password)')
parser.add_argument('-c', '--count', default='1',
help='number of recent activities to download, or \'all\' (default: 1)')
parser.add_argument('-e', '--external',
Expand Down Expand Up @@ -504,11 +506,27 @@ def parse_arguments(argv):


def login_to_garmin_connect(args):
"""
Perform all HTTP requests to login to Garmin Connect.
"""
username = args.username if args.username else input('Username: ')
password = args.password if args.password else getpass()
"""Perform all HTTP requests to login to Garmin Connect."""
if args.passmanager:
if any((args.username, args.password)):
logging.error('Password manager cannot be used with the --username/--password arguments.')
sys.exit(1)

passmanager = args.passmanager.lower()
if passmanager == 'bitwarden':
from passmngt import BitWarden as PasswordManager
elif passmanager == '1password':
from passmngt import OnePassword as PasswordManager
else:
logging.error(f'{passmanager} password manager is not supported.')
sys.exit(1)

with PasswordManager() as pm:
username = pm.user
password = pm.password
else:
username = args.username if args.username else input('Username: ')
password = args.password if args.password else getpass()

logging.debug("Login params: %s", urlencode(DATA))

Expand Down
222 changes: 222 additions & 0 deletions passmngt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
# -*- coding: utf-8 -*-
#
# Copyright 2022 (c) by Bart Skowron <[email protected]>
# All rights reserved. Licensed under the MIT license.
#
#
# Password Managers support for Garmin Connect Export
#
#
# Credentials must be stored in the following meta-fields:
# - URL: garmin.com
# - login: username
# - password: password
#
# For setup instructions, see the official documentation for Password Manager
#

import logging
import os
import sys
from abc import ABCMeta, abstractmethod
from subprocess import run


class PasswordManager(metaclass=ABCMeta):
"""Password Manager Abstract Class"""

name = "Abstract Password Manager"

# Protected methods that require super() to be called when overriding
SUPERCALLS = ("destroy_session",)

def __new__(cls, *args, **kwargs):
for method in cls.SUPERCALLS:
if not getattr(cls, method).__code__.co_freevars:
logging.error("Unsafe Password Manager plugin detected.")
raise Exception(
f"The super() call is required in {cls.__name__}.{method}()"
)
return super().__new__(cls, *args, **kwargs)

def __init__(self, url="garmin.com"):
self.url = url
self.session = None
self.user = None
self.password = None

self.__clear_credentials()

@abstractmethod
def get_session(self, cmd):
"""
Get Session ID for Password Manager.

:return: session ID
"""
...

@abstractmethod
def destroy_session(self):
"""
Destroy stored credentials on the Password Manager object.

First call super() and then follow the PM-specific steps.
"""
self.__clear_credentials()

@abstractmethod
def get_user(self):
"""
Return the looked-up login.

:return: the Garmin Connect login
"""
...

@abstractmethod
def get_password(self):
"""
Return the looked-up password.

:return: the Garmin Connect password
"""
...

def __enter__(self):
"""ContextManager to provide credentials in a safe way."""
self.session = self.get_session()
self.user = self.get_user()
self.password = self.get_password()

if not all((self.user, self.password)):
logging.error(f"{self.name} cannot find credentials for: {self.url}")
self.destroy_session()
sys.exit(1)

return self

def __exit__(self, exc_type, exc_value, exc_traceback):
self.destroy_session()

def __clear_credentials(self):
self.user = None
self.password = None
self.session = None

def _check_returncode(self, proc):
"""
Verify the EXIT STATUS for proc.

:return: True if 0, otherwise, log the error and exit.
"""
if proc.returncode:
errmsg = proc.stderr
try:
errmsg = errmsg.decode()
except AttributeError:
errmsg = str(errmsg)

logging.error(errmsg)
self.destroy_session()
sys.exit(1)

return True

def _remove_eol(self, string):
"""
Remove EOL (line separator) from the string.

This function provides a safe way to remove trailing newline characters
from passwords that contain trailing whitespaces.

:param string: string to chomp
:return: string without EOL (if existed)
"""
return string[: -len(os.linesep)] if string.endswith(os.linesep) else string


class BitWarden(PasswordManager):
name = "BitWarden"
META = "BW_SESSION="

def get_session(self):
print("Enter your master password: ", end="", flush=True)
bw = run(["bw", "unlock"], capture_output=True, text=True)
print()

self._check_returncode(bw)

output = bw.stdout
try:
session_start = output.index(self.META) + len(self.META)
session_stop = output.index('"', session_start + 1)
except ValueError:
logging.error(bw.stderr)
self.destroy_session()
sys.exit(1)

return output[session_start:session_stop]

def destroy_session(self):
super().destroy_session()
run(["bw", "lock"])

def get_user(self):
return run(
["bw", "get", "username", self.url, "--session", self.session],
capture_output=True,
text=True,
).stdout

def get_password(self):
return run(
["bw", "get", "password", self.url, "--session", self.session],
capture_output=True,
text=True,
).stdout


class OnePassword(PasswordManager):
name = "1Password"

def get_session(self):
op = run(["op", "signin", "--raw"], capture_output=True, text=True)
self._check_returncode(op)
return op.stdout

def destroy_session(self):
super().destroy_session()
run(["op", "signout"])

def get_user(self):
user = run(
[
"op",
"item",
"get",
self.url,
"--fields",
"label=username",
f"--session={self.session}",
],
capture_output=True,
text=True,
).stdout
return self._remove_eol(user)

def get_password(self):
password = run(
[
"op",
"item",
"get",
self.url,
"--fields",
"label=password",
f"--session={self.session}",
],
capture_output=True,
text=True,
).stdout
return self._remove_eol(password)