diff --git a/cli/lesspass/cli.py b/cli/lesspass/cli.py index bba6e2e..b09bf8c 100644 --- a/cli/lesspass/cli.py +++ b/cli/lesspass/cli.py @@ -2,7 +2,6 @@ import argparse import os from lesspass import version -from lesspass import name from lesspass import description EXAMPLES = """ @@ -27,6 +26,12 @@ copyright: """ +def _get_config_path(): + DEFAULT_XDG_CONFIG_HOME = os.path.join(os.path.expanduser("~"), ".config") + data_home_path = os.environ.get("XDG_CONFIG_HOME", DEFAULT_XDG_CONFIG_HOME) + return os.path.join(data_home_path, "lesspass") + + def range_type(value_string): value = int(value_string) if value not in range(5, 35 + 1): @@ -83,7 +88,9 @@ def parse_args(args): help="copy the password to clipboard", ) parser.add_argument( - "--exclude", default=None, help="exclude char from generated password", + "--exclude", + default=None, + help="exclude char from generated password", ) parser.add_argument( "--no-fingerprint", @@ -91,6 +98,34 @@ def parse_args(args): action="store_true", help="hide visual fingerprint of the master password when you type", ) + config_home_path = _get_config_path() + backup_file = os.path.join(config_home_path, "profiles.json") + parser.add_argument( + "--save", + dest="save_path", + nargs="?", + const=backup_file, + default=None, + help=f"[beta] Save your password profiles. /!\ File not encrypted. Use carefully. (default: {backup_file})", + ) + parser.add_argument( + "--load", + dest="load_path", + default=None, + help="[beta] Load your password profiles file", + ) + parser.add_argument( + "--config-home-path", + dest="config_home_path", + default=config_home_path, + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--url", + dest="url", + default="https://api.lesspass.com/", + help="[beta] LessPass Database URL used by --save and --load command", + ) lowercase_group = parser.add_mutually_exclusive_group() lowercase_group.add_argument( "-l", diff --git a/cli/lesspass/connected.py b/cli/lesspass/connected.py new file mode 100644 index 0000000..5b32bc3 --- /dev/null +++ b/cli/lesspass/connected.py @@ -0,0 +1,102 @@ +import getpass +import json +import os +import sys + +import requests + +from lesspass.password import generate_password + + +def _login(config_home_path, url): + os.makedirs(config_home_path, exist_ok=True) + config_path = os.path.join(config_home_path, "config.json") + tokens = None + if os.path.exists(config_path): + with open(config_path, encoding="utf-8") as f: + tokens = json.load(f) + + if tokens: + refresh_tokens = requests.post( + f"{url}auth/jwt/refresh/", + json=tokens, + ) + if refresh_tokens.status_code == 200: + token_refreshed = refresh_tokens.json() + with open(config_path, "w", encoding="utf-8") as f: + json.dump(token_refreshed, f, ensure_ascii=False, indent=4) + return token_refreshed["access"] + + print("LessPass login") + email = input("Email: ") + master_password = getpass.getpass("Master Password: ") + if not email or not master_password: + print("Email and Master Password are mandatory") + sys.exit(1) + default_lesspass_profile = { + "site": "lesspass.com", + "login": email, + "lowercase": True, + "uppercase": True, + "digits": True, + "symbols": True, + "length": 16, + "counter": 1, + } + encrypted_password = generate_password(default_lesspass_profile, master_password) + r = requests.post( + f"{url}auth/jwt/create/", + json={"email": email, "password": encrypted_password}, + ) + if r.status_code != 200: + print("Wrong email and/or master password") + sys.exit(1) + tokens = r.json() + with open(config_path, "w", encoding="utf-8") as f: + json.dump(tokens, f, ensure_ascii=False, indent=4) + print(f"Access and refresh tokens saved in {config_path}") + return tokens["access"] + + +def save_password_profiles(config_home_path, url, backup_path): + token = _login(config_home_path, url) + r = requests.get(f"{url}passwords/", headers={"Authorization": "JWT %s" % token}) + with open(backup_path, "w", encoding="utf-8") as f: + json.dump(r.json(), f, ensure_ascii=False, indent=4) + + print(f"Password profiles saved in {backup_path}") + + +def load_password_profiles(config_home_path, url, backup_path): + token = _login(config_home_path, url) + with open(backup_path, encoding="utf-8") as f: + data = json.load(f) + + for password_profile in data["results"]: + get_password_profiles = requests.get( + f"{url}passwords/{password_profile['id']}/", + headers={"Authorization": "JWT %s" % token}, + ) + if get_password_profiles.status_code == 200: + print( + f"Password profile for site {password_profile['site']} and login {password_profile['login']} already exists. Skipping." + ) + elif get_password_profiles.status_code == 404: + create_password = requests.post( + f"{url}passwords/", + json=password_profile, + headers={"Authorization": "JWT %s" % token}, + ) + print(create_password.text) + create_password.raise_for_status() + print( + f"Password profile for site {password_profile['site']} and login {password_profile['login']} successfully imported." + ) + else: + get_password_profiles.raise_for_status() + + get_password_profiles = requests.get( + f"{url}passwords/", headers={"Authorization": "JWT %s" % token} + ) + with open(backup_path, "w", encoding="utf-8") as f: + json.dump(get_password_profiles.json(), f, ensure_ascii=False, indent=4) diff --git a/cli/lesspass/core.py b/cli/lesspass/core.py index 12a5398..236df11 100644 --- a/cli/lesspass/core.py +++ b/cli/lesspass/core.py @@ -11,6 +11,7 @@ from lesspass.profile import create_profile from lesspass.password import generate_password from lesspass.clipboard import copy, get_system_copy_command from lesspass.fingerprint import getpass_with_fingerprint +from lesspass.connected import save_password_profiles, load_password_profiles signal.signal(signal.SIGINT, lambda s, f: sys.exit(0)) @@ -24,6 +25,12 @@ def main(args=sys.argv[1:]): ) sys.exit(3) + if args.save_path: + return save_password_profiles(args.config_home_path, args.url, args.save_path) + + if args.load_path: + return load_password_profiles(args.config_home_path, args.url, args.load_path) + if args.prompt: if not args.site: args.site = getpass.getpass("Site: ") diff --git a/cli/lesspass/version.py b/cli/lesspass/version.py index b7a5e07..7c417a9 100644 --- a/cli/lesspass/version.py +++ b/cli/lesspass/version.py @@ -1 +1 @@ -__version__ = "10.0.2" +__version__ = "10.1.0" diff --git a/cli/requirements.txt b/cli/requirements.txt index 8b13789..945c9b4 100644 --- a/cli/requirements.txt +++ b/cli/requirements.txt @@ -1 +1 @@ - +. \ No newline at end of file diff --git a/cli/setup.py b/cli/setup.py index b43602c..f6a2e9a 100644 --- a/cli/setup.py +++ b/cli/setup.py @@ -16,7 +16,7 @@ setuptools.setup( description="LessPass stateless password generator", long_description=long_description, long_description_content_type="text/markdown", - install_requires=[], + install_requires=["requests"], entry_points=""" [console_scripts] lesspass=lesspass.core:main diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index 15d5e50..edc9dcf 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -118,3 +118,34 @@ class TestParseArgs(unittest.TestCase): def test_parse_no_fingerprint(self): self.assertTrue(parse_args(["site", "--no-fingerprint"]).no_fingerprint) self.assertFalse(parse_args(["site"]).no_fingerprint) + + def test_parse_args_save_path(self): + self.assertEqual( + parse_args(["--save", "/tmp/profiles.json"]).save_path, "/tmp/profiles.json" + ) + self.assertEqual(parse_args(["site"]).save_path, None) + with patch.dict("os.environ", {"XDG_CONFIG_HOME": "/tmp"}): + self.assertEqual( + parse_args(["--save"]).save_path, "/tmp/lesspass/profiles.json" + ) + + def test_parse_args_load_path(self): + self.assertEqual( + parse_args(["--load", "/tmp/profiles.json"]).load_path, "/tmp/profiles.json" + ) + self.assertEqual(parse_args(["site"]).load_path, None) + + def test_parse_args_config_home_path(self): + self.assertTrue(parse_args([]).config_home_path.endswith("/.config/lesspass")) + + def test_parse_args_XDG_CONFIG_HOME_env_variable(self): + with patch.dict("os.environ", {"XDG_CONFIG_HOME": "/tmp"}): + self.assertEqual(parse_args([]).config_home_path, "/tmp/lesspass") + + def test_parse_args_default_base_url(self): + self.assertEqual(parse_args([]).url, "https://api.lesspass.com/") + + def test_parse_args_default_base_url(self): + self.assertEqual( + parse_args(["--url", "https://example.org/"]).url, "https://example.org/" + )