From 7780003d01178afa190a9734225b4db34256fd80 Mon Sep 17 00:00:00 2001 From: ShahRustam Date: Sun, 19 Apr 2020 19:06:06 +0300 Subject: [PATCH] New Account Management System (#3443) * add login for PIO account to account cli * Remove PyJWT lib. Fixes. * Add password change for account * Refactoring. Add Account Client. * Fixes. * http -> https. * adding error handling for expired session. * Change broker requests from json to form-data. * Add pio accoint register command. fixes * Fixes. * Fixes. * Add username and password validation * fixes * Add token, forgot commands to pio account * fix domain * add update command for pio account * fixes * refactor profile update output * lint * Update exception text. * Fix logout * Add custom user-agent for pio account * add profile show command. minor fixes. * Fix pio account show output format. * Move account related exceptions * cleaning * minor fix * Remove try except for account command authenticated/non-authenticated errors * fix profile update cli command * rename first name and last name vars to 'firstname' and 'lastname' --- platformio/commands/account.py | 72 ----- platformio/commands/account/__init__.py | 13 + platformio/commands/account/client.py | 217 ++++++++++++++ platformio/commands/account/command.py | 278 ++++++++++++++++++ platformio/commands/account/exception.py | 30 ++ .../commands/home/rpc/handlers/piocore.py | 2 +- 6 files changed, 539 insertions(+), 73 deletions(-) delete mode 100644 platformio/commands/account.py create mode 100644 platformio/commands/account/__init__.py create mode 100644 platformio/commands/account/client.py create mode 100644 platformio/commands/account/command.py create mode 100644 platformio/commands/account/exception.py diff --git a/platformio/commands/account.py b/platformio/commands/account.py deleted file mode 100644 index d728a558..00000000 --- a/platformio/commands/account.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright (c) 2014-present PlatformIO -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# pylint: disable=unused-argument - -import sys - -import click - -from platformio.managers.core import pioplus_call - - -@click.group("account", short_help="Manage PIO Account") -def cli(): - pass - - -@cli.command("register", short_help="Create new PIO Account") -@click.option("-u", "--username") -def account_register(**kwargs): - pioplus_call(sys.argv[1:]) - - -@cli.command("login", short_help="Log in to PIO Account") -@click.option("-u", "--username") -@click.option("-p", "--password") -def account_login(**kwargs): - pioplus_call(sys.argv[1:]) - - -@cli.command("logout", short_help="Log out of PIO Account") -def account_logout(): - pioplus_call(sys.argv[1:]) - - -@cli.command("password", short_help="Change password") -@click.option("--old-password") -@click.option("--new-password") -def account_password(**kwargs): - pioplus_call(sys.argv[1:]) - - -@cli.command("token", short_help="Get or regenerate Authentication Token") -@click.option("-p", "--password") -@click.option("--regenerate", is_flag=True) -@click.option("--json-output", is_flag=True) -def account_token(**kwargs): - pioplus_call(sys.argv[1:]) - - -@cli.command("forgot", short_help="Forgot password") -@click.option("-u", "--username") -def account_forgot(**kwargs): - pioplus_call(sys.argv[1:]) - - -@cli.command("show", short_help="PIO Account information") -@click.option("--offline", is_flag=True) -@click.option("--json-output", is_flag=True) -def account_show(**kwargs): - pioplus_call(sys.argv[1:]) diff --git a/platformio/commands/account/__init__.py b/platformio/commands/account/__init__.py new file mode 100644 index 00000000..b0514903 --- /dev/null +++ b/platformio/commands/account/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2014-present PlatformIO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/platformio/commands/account/client.py b/platformio/commands/account/client.py new file mode 100644 index 00000000..4c49922a --- /dev/null +++ b/platformio/commands/account/client.py @@ -0,0 +1,217 @@ +# Copyright (c) 2014-present PlatformIO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=unused-argument + +import os +import time + +import requests.adapters +from requests.packages.urllib3.util.retry import Retry # pylint:disable=import-error + +from platformio import app +from platformio.commands.account import exception + + +class AccountClient(object): + def __init__( + self, api_base_url="https://api.accounts.platformio.org", retries=3, + ): + if api_base_url.endswith("/"): + api_base_url = api_base_url[:-1] + self.api_base_url = api_base_url + self._session = requests.Session() + self._session.headers.update({"User-Agent": app.get_user_agent()}) + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=2, + method_whitelist=list(Retry.DEFAULT_METHOD_WHITELIST) + ["POST"], + ) + adapter = requests.adapters.HTTPAdapter(max_retries=retry) + self._session.mount(api_base_url, adapter) + + def login(self, username, password): + try: + self.fetch_authentication_token() + except: # pylint:disable=bare-except + pass + else: + raise exception.AccountAlreadyAuthenticated( + app.get_state_item("account", {}).get("email", "") + ) + + response = self._session.post( + self.api_base_url + "/v1/login", + data={"username": username, "password": password}, + ) + result = self.raise_error_from_response(response) + app.set_state_item("account", result) + return result + + def logout(self): + try: + refresh_token = self.get_refresh_token() + except: # pylint:disable=bare-except + raise exception.AccountNotAuthenticated() + response = requests.post( + self.api_base_url + "/v1/logout", data={"refresh_token": refresh_token}, + ) + try: + self.raise_error_from_response(response) + except exception.AccountError: + pass + app.delete_state_item("account") + return True + + def change_password(self, old_password, new_password): + try: + token = self.fetch_authentication_token() + except: # pylint:disable=bare-except + raise exception.AccountNotAuthenticated() + response = self._session.post( + self.api_base_url + "/v1/password", + headers={"Authorization": "Bearer %s" % token}, + data={"old_password": old_password, "new_password": new_password}, + ) + self.raise_error_from_response(response) + return True + + def registration( + self, username, email, password, firstname, lastname + ): # pylint:disable=too-many-arguments + try: + self.fetch_authentication_token() + except: # pylint:disable=bare-except + pass + else: + raise exception.AccountAlreadyAuthenticated( + app.get_state_item("account", {}).get("email", "") + ) + + response = self._session.post( + self.api_base_url + "/v1/registration", + data={ + "username": username, + "email": email, + "password": password, + "firstname": firstname, + "lastname": lastname, + }, + ) + return self.raise_error_from_response(response) + + def auth_token(self, password, regenerate): + try: + token = self.fetch_authentication_token() + except: # pylint:disable=bare-except + raise exception.AccountNotAuthenticated() + response = self._session.post( + self.api_base_url + "/v1/token", + headers={"Authorization": "Bearer %s" % token}, + data={"password": password, "regenerate": 1 if regenerate else 0}, + ) + return self.raise_error_from_response(response).get("auth_token") + + def forgot_password(self, username): + response = self._session.post( + self.api_base_url + "/v1/forgot", data={"username": username}, + ) + return self.raise_error_from_response(response).get("auth_token") + + def get_profile(self): + try: + token = self.fetch_authentication_token() + except: # pylint:disable=bare-except + raise exception.AccountNotAuthenticated() + response = self._session.get( + self.api_base_url + "/v1/profile", + headers={"Authorization": "Bearer %s" % token}, + ) + return self.raise_error_from_response(response) + + def update_profile(self, profile, current_password): + try: + token = self.fetch_authentication_token() + except: # pylint:disable=bare-except + raise exception.AccountNotAuthenticated() + profile["current_password"] = current_password + response = self._session.put( + self.api_base_url + "/v1/profile", + headers={"Authorization": "Bearer %s" % token}, + data=profile, + ) + return self.raise_error_from_response(response) + + def get_account_info(self, offline): + if offline: + account = app.get_state_item("account") + if not account: + raise exception.AccountNotAuthenticated() + return { + "profile": { + "email": account.get("email"), + "username": account.get("username"), + } + } + try: + token = self.fetch_authentication_token() + except: # pylint:disable=bare-except + raise exception.AccountNotAuthenticated() + response = self._session.get( + self.api_base_url + "/v1/summary", + headers={"Authorization": "Bearer %s" % token}, + ) + return self.raise_error_from_response(response) + + def fetch_authentication_token(self): + if "PLATFORMIO_AUTH_TOKEN" in os.environ: + return os.environ["PLATFORMIO_AUTH_TOKEN"] + auth = app.get_state_item("account", {}).get("auth", {}) + if auth.get("access_token") and auth.get("access_token_expire"): + if auth.get("access_token_expire") > time.time(): + return auth.get("access_token") + if auth.get("refresh_token"): + response = self._session.post( + self.api_base_url + "/v1/login", + headers={"Authorization": "Bearer %s" % auth.get("refresh_token")}, + ) + result = self.raise_error_from_response(response) + app.set_state_item("account", result) + return result.get("auth").get("access_token") + raise exception.AccountNotAuthenticated() + + @staticmethod + def get_refresh_token(): + try: + auth = app.get_state_item("account").get("auth").get("refresh_token") + return auth + except: # pylint:disable=bare-except + raise exception.AccountNotAuthenticated() + + @staticmethod + def raise_error_from_response(response, expected_codes=(200, 201, 202)): + if response.status_code in expected_codes: + try: + return response.json() + except ValueError: + pass + try: + message = response.json()["message"] + except (KeyError, ValueError): + message = response.text + if "Authorization session has been expired" in message: + app.delete_state_item("account") + raise exception.AccountError(message) diff --git a/platformio/commands/account/command.py b/platformio/commands/account/command.py new file mode 100644 index 00000000..e24261a6 --- /dev/null +++ b/platformio/commands/account/command.py @@ -0,0 +1,278 @@ +# Copyright (c) 2014-present PlatformIO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=unused-argument + +import datetime +import json +import re + +import click +from tabulate import tabulate + +from platformio.commands.account import exception +from platformio.commands.account.client import AccountClient + + +@click.group("account", short_help="Manage PIO Account") +def cli(): + pass + + +def validate_username(value): + value = str(value).strip() + if not re.match(r"^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){3,38}$", value, flags=re.I): + raise click.BadParameter( + "Invalid username format. " + "Username must contain at least 4 characters including single hyphens," + " and cannot begin or end with a hyphen" + ) + return value + + +def validate_email(value): + value = str(value).strip() + if not re.match(r"^[a-z\d_.+-]+@[a-z\d\-]+\.[a-z\d\-.]+$", value, flags=re.I): + raise click.BadParameter("Invalid E-Mail address") + return value + + +def validate_password(value): + value = str(value).strip() + if not re.match(r"^(?=.*[a-z])(?=.*\d).{8,}$", value): + raise click.BadParameter( + "Invalid password format. " + "Password must contain at least 8 characters" + " including a number and a lowercase letter" + ) + return value + + +@cli.command("register", short_help="Create new PIO Account") +@click.option( + "-u", + "--username", + prompt=True, + callback=lambda _, __, value: validate_username(value), +) +@click.option( + "-e", "--email", prompt=True, callback=lambda _, __, value: validate_email(value) +) +@click.option( + "-p", + "--password", + prompt=True, + hide_input=True, + confirmation_prompt=True, + callback=lambda _, __, value: validate_password(value), +) +@click.option("--firstname", prompt=True) +@click.option("--lastname", prompt=True) +def account_register(username, email, password, firstname, lastname): + client = AccountClient() + client.registration(username, email, password, firstname, lastname) + return click.secho( + "An account has been successfully created. " + "Please check your mail to activate your account and verify your email address.", + fg="green", + ) + + +@cli.command("login", short_help="Log in to PIO Account") +@click.option("-u", "--username", prompt="Username or e-mail") +@click.option("-p", "--password", prompt=True, hide_input=True) +def account_login(username, password): + client = AccountClient() + client.login(username, password) + return click.secho("Successfully logged in!", fg="green") + + +@cli.command("logout", short_help="Log out of PIO Account") +def account_logout(): + client = AccountClient() + client.logout() + return click.secho("Successfully logged out!", fg="green") + + +@cli.command("password", short_help="Change password") +@click.option("--old-password", prompt=True, hide_input=True) +@click.option("--new-password", prompt=True, hide_input=True, confirmation_prompt=True) +def account_password(old_password, new_password): + client = AccountClient() + client.change_password(old_password, new_password) + return click.secho("Password successfully changed!", fg="green") + + +@cli.command("token", short_help="Get or regenerate Authentication Token") +@click.option("-p", "--password", prompt=True, hide_input=True) +@click.option("--regenerate", is_flag=True) +@click.option("--json-output", is_flag=True) +def account_token(password, regenerate, json_output): + client = AccountClient() + auth_token = client.auth_token(password, regenerate) + if json_output: + return click.echo(json.dumps({"status": "success", "result": auth_token})) + return click.secho("Personal Authentication Token: %s" % auth_token, fg="green") + + +@cli.command("forgot", short_help="Forgot password") +@click.option("--username", prompt="Username or e-mail") +def account_forgot(username): + client = AccountClient() + client.forgot_password(username) + return click.secho( + "If this account is registered, we will send the " + "further instructions to your E-Mail.", + fg="green", + ) + + +@cli.command("update", short_help="Update profile information") +@click.option("--current-password", prompt=True, hide_input=True) +@click.option("--username") +@click.option("--email") +@click.option("--firstname") +@click.option("--lastname") +def account_update(current_password, **kwargs): + client = AccountClient() + profile = client.get_profile() + new_profile = profile.copy() + if not any(kwargs.values()): + for field in profile: + new_profile[field] = click.prompt( + field.replace("_", " ").capitalize(), default=profile[field] + ) + if field == "email": + validate_email(new_profile[field]) + if field == "username": + validate_username(new_profile[field]) + else: + new_profile.update({key: value for key, value in kwargs.items() if value}) + client.update_profile(new_profile, current_password) + click.secho("Profile successfully updated!", fg="green") + username_changed = new_profile["username"] != profile["username"] + email_changed = new_profile["email"] != profile["email"] + if not username_changed and not email_changed: + return None + try: + client.logout() + except exception.AccountNotAuthenticated: + pass + if email_changed: + return click.secho( + "Please check your mail to verify your new email address and re-login. ", + fg="yellow", + ) + return click.secho("Please re-login.", fg="yellow") + + +@cli.command("show", short_help="PIO Account information") +@click.option("--offline", is_flag=True) +@click.option("--json-output", is_flag=True) +def account_show(offline, json_output): + client = AccountClient() + info = client.get_account_info(offline) + if json_output: + return click.echo(json.dumps(info)) + click.echo() + if info.get("profile"): + print_profile(info["profile"]) + if info.get("packages"): + print_packages(info["packages"]) + if info.get("subscriptions"): + print_subscriptions(info["subscriptions"]) + return click.echo() + + +def print_profile(profile): + click.secho("Profile", fg="cyan", bold=True) + click.echo("=" * len("Profile")) + data = [] + if profile.get("username"): + data.append(("Username:", profile["username"])) + if profile.get("email"): + data.append(("Email:", profile["email"])) + if profile.get("firstname"): + data.append(("First name:", profile["firstname"])) + if profile.get("lastname"): + data.append(("Last name:", profile["lastname"])) + click.echo(tabulate(data, tablefmt="plain")) + + +def print_packages(packages): + click.echo() + click.secho("Packages", fg="cyan") + click.echo("=" * len("Packages")) + for package in packages: + click.echo() + click.secho(package.get("name"), bold=True) + click.echo("-" * len(package.get("name"))) + if package.get("description"): + click.echo(package.get("description")) + data = [] + expire = "-" + if "subscription" in package: + expire = datetime.datetime.strptime( + ( + package["subscription"].get("end_at") + or package["subscription"].get("next_bill_at") + ), + "%Y-%m-%dT%H:%M:%SZ", + ).strftime("%Y-%m-%d") + data.append(("Expire:", expire)) + services = [] + for key in package: + if not key.startswith("service."): + continue + if isinstance(package[key], dict): + services.append(package[key].get("title")) + else: + services.append(package[key]) + if services: + data.append(("Services:", ", ".join(services))) + click.echo(tabulate(data, tablefmt="plain")) + + +def print_subscriptions(subscriptions): + click.echo() + click.secho("Subscriptions", fg="cyan") + click.echo("=" * len("Subscriptions")) + for subscription in subscriptions: + click.echo() + click.secho(subscription.get("product_name"), bold=True) + click.echo("-" * len(subscription.get("product_name"))) + data = [("State:", subscription.get("status"))] + begin_at = datetime.datetime.strptime( + subscription.get("begin_at"), "%Y-%m-%dT%H:%M:%SZ" + ).strftime("%Y-%m-%d %H:%M:%S") + data.append(("Start date:", begin_at or "-")) + end_at = subscription.get("end_at") + if end_at: + end_at = datetime.datetime.strptime( + subscription.get("end_at"), "%Y-%m-%dT%H:%M:%SZ" + ).strftime("%Y-%m-%d %H:%M:%S") + data.append(("End date:", end_at or "-")) + next_bill_at = subscription.get("next_bill_at") + if next_bill_at: + next_bill_at = datetime.datetime.strptime( + subscription.get("next_bill_at"), "%Y-%m-%dT%H:%M:%SZ" + ).strftime("%Y-%m-%d %H:%M:%S") + data.append(("Next payment:", next_bill_at or "-")) + data.append( + ("Edit:", click.style(subscription.get("update_url"), fg="blue") or "-") + ) + data.append( + ("Cancel:", click.style(subscription.get("cancel_url"), fg="blue") or "-") + ) + click.echo(tabulate(data, tablefmt="plain")) diff --git a/platformio/commands/account/exception.py b/platformio/commands/account/exception.py new file mode 100644 index 00000000..213be0e1 --- /dev/null +++ b/platformio/commands/account/exception.py @@ -0,0 +1,30 @@ +# Copyright (c) 2014-present PlatformIO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from platformio.exception import PlatformioException + + +class AccountError(PlatformioException): + + MESSAGE = "{0}" + + +class AccountNotAuthenticated(AccountError): + + MESSAGE = "You are not authenticated! Please login to PIO Account." + + +class AccountAlreadyAuthenticated(AccountError): + + MESSAGE = "You are already authenticated with {0} account." diff --git a/platformio/commands/home/rpc/handlers/piocore.py b/platformio/commands/home/rpc/handlers/piocore.py index 41009c4e..9bcef9e5 100644 --- a/platformio/commands/home/rpc/handlers/piocore.py +++ b/platformio/commands/home/rpc/handlers/piocore.py @@ -96,7 +96,7 @@ class PIOCoreRPC(object): to_json = "--json-output" in args try: - if args and args[0] in ("account", "remote"): + if args and args[0] == "remote": result = yield PIOCoreRPC._call_subprocess(args, options) defer.returnValue(PIOCoreRPC._process_result(result, to_json)) else: