From b95afdfdfac96d02e2c2448fa1951bf63a16a7a0 Mon Sep 17 00:00:00 2001 From: Holger Nahrstaedt <holger@nahrstaedt.de> Date: Mon, 28 May 2018 23:07:47 +0200 Subject: [PATCH] Include steemconnect v2 to beem steem * add steemconnect in init, when set, steemconnect is used for broadasting steemconnect * new class can be used to broadcast operation with steemconnect v2 storage * Token class to store encrypted token Transactionbuilder * use steemconnect broadcast with set in steem Wallet * add token storage class * add setToken, clear_local_token, encrypt_token, decrypt_token, addToken, getTokenForAccountName, removeTokenFromPublicName, getPublicNames Example * Add login app for testing steemconnect --- beem/cli.py | 5 +- beem/steem.py | 4 + beem/steemconnect.py | 190 ++++++++++++++++++++++++++++++++++++ beem/storage.py | 135 ++++++++++++++++++++++++- beem/transactionbuilder.py | 12 ++- beem/version.py | 2 +- beem/wallet.py | 111 ++++++++++++++++++++- beemapi/version.py | 2 +- beembase/version.py | 2 +- beemgraphenebase/version.py | 2 +- examples/login_app/app.py | 36 +++++++ setup.py | 2 +- 12 files changed, 492 insertions(+), 11 deletions(-) create mode 100644 beem/steemconnect.py create mode 100644 examples/login_app/app.py diff --git a/beem/cli.py b/beem/cli.py index 771605a0..ccb72496 100644 --- a/beem/cli.py +++ b/beem/cli.py @@ -65,7 +65,8 @@ availableConfigurationKeys = [ "default_account", "default_vote_weight", "nodes", - "password_storage" + "password_storage", + "client_id", ] @@ -220,6 +221,8 @@ def set(key, value): print("") if value == "environment": print("The wallet password can be stored in the UNLOCK invironment variable to skip password prompt!") + elif key == "client_id": + stm.config["client_id"] = value else: print("wrong key") diff --git a/beem/steem.py b/beem/steem.py index 8f6fe4e3..b0d86d2f 100644 --- a/beem/steem.py +++ b/beem/steem.py @@ -26,6 +26,7 @@ from .exceptions import ( AccountDoesNotExistsException ) from .wallet import Wallet +from .steemconnect import SteemConnect from .transactionbuilder import TransactionBuilder from .utils import formatTime, resolve_authorperm, derive_permlink, remove_from_dict from beem.constants import STEEM_VOTE_REGENERATION_SECONDS @@ -147,6 +148,9 @@ class Steem(object): self.unsigned = bool(kwargs.get("unsigned", False)) self.expiration = int(kwargs.get("expiration", 30)) self.bundle = bool(kwargs.get("bundle", False)) + self.steemconnect = kwargs.get("steemconnect", None) + if self.steemconnect is not None and not isinstance(self.steemconnect, SteemConnect): + raise ValueError("steemconnect musst be SteemConnect object") self.blocking = kwargs.get("blocking", False) # Store config for access through other Classes diff --git a/beem/steemconnect.py b/beem/steemconnect.py new file mode 100644 index 00000000..8c0aef31 --- /dev/null +++ b/beem/steemconnect.py @@ -0,0 +1,190 @@ +# This Python file uses the following encoding: utf-8 +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals +from builtins import str +import json +import urllib.parse +import requests +from .storage import configStorage as config +from beem.instance import shared_steem_instance + + +class SteemConnect(object): + """ SteemConnect v2 + + :param str scope: comma seperate string with scopes + login,offline,vote,comment,delete_comment,comment_options,custom_json,claim_reward_balance + + + .. code-block:: python + + # Run the login_app in examples and login with a account + from beem import Steem + from beem.steemconnect import SteemConnect + from beem.comment import Comment + sc2 = SteemConnect(client_id="beem.app") + steem = Steem(steemconnect=sc2) + steem.wallet.unlock("supersecret-passphrase") + post = Comment("author/permlink", steem_instance=stm) + post.upvote(voter="test") # replace "test" with your account + + """ + + def __init__(self, steem_instance=None, *args, **kwargs): + self.steem = steem_instance or shared_steem_instance() + self.access_token = None + self.get_refresh_token = kwargs.get("get_refresh_token", False) + self.client_id = kwargs.get("client_id", config["sc2_client_id"]) + self.scope = kwargs.get("scope", config["sc2_scope"]) + self.oauth_base_url = kwargs.get("oauth_base_url", config["oauth_base_url"]) + self.sc2_api_url = kwargs.get("sc2_api_url", config["sc2_api_url"]) + + @property + def headers(self): + return {'Authorization': self.access_token} + + def get_login_url(self, redirect_uri): + params = { + "client_id": self.client_id, + "redirect_uri": redirect_uri, + "scope": self.scope, + } + if self.get_refresh_token: + params.update({ + "response_type": "code", + }) + + return urllib.parse.urljoin( + self.oauth_base_url, + "authorize?" + urllib.parse.urlencode(params, safe=",")) + + def get_access_token(self, code): + post_data = { + "grant_type": "authorization_code", + "code": code, + "client_id": self.client_id, + "client_secret": self.steem.wallet.getTokenForAccountName(self.client_id), + } + + r = requests.post( + urllib.parse.urljoin(self.sc2_api_url, "oauth2/token/"), + data=post_data + ) + + return r.json() + + def me(self, username=None): + if username: + self.set_username(username) + url = urllib.parse.urljoin(self.sc2_api_url, "me/") + r = requests.post(url, headers=self.headers) + return r.json() + + def set_access_token(self, access_token): + """ Is needed for broadcast() and me() + """ + self.access_token = access_token + + def set_username(self, username): + """ Set a username for the next broadcast() or me operation() + The necessary token is fetched from the wallet + """ + self.access_token = self.steem.wallet.getTokenForAccountName(username) + + def broadcast(self, operations, username=None): + """ Broadcast a operations + + Sample operations: + + .. code-block:: js + + [ + [ + 'vote', { + 'voter': 'holger.random', + 'author': 'holger80', + 'permlink': 'does-the-steem-blockchain-comply-with-the-gdpr-and-european-privacy-laws', + 'weight': 10000 + } + ] + ] + + """ + url = urllib.parse.urljoin(self.sc2_api_url, "broadcast/") + data = { + "operations": operations, + } + if username: + self.set_username(username) + headers = self.headers.copy() + headers.update({ + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json", + }) + + r = requests.post(url, headers=headers, data=json.dumps(data)) + try: + return r.json() + except ValueError: + return r.content + + def refresh_access_token(self, code, scope): + post_data = { + "grant_type": "refresh_token", + "refresh_token": code, + "client_id": self.client_id, + "client_secret": self.steem.wallet.getTokenForAccountName(self.client_id), + "scope": scope, + } + + r = requests.post( + urllib.parse.urljoin(self.sc2_api_url, "oauth2/token/"), + data=post_data, + ) + + return r.json() + + def revoke_token(self, access_token): + post_data = { + "access_token": access_token, + } + + r = requests.post( + urllib.parse.urljoin(self.sc2_api_url, "oauth2/token/revoke"), + data=post_data + ) + + return r.json() + + def update_user_metadata(self, metadata): + put_data = { + "user_metadata": metadata, + } + r = requests.put( + urllib.parse.urljoin(self.sc2_api_url, "me/"), + data=put_data, headers=self.headers) + + return r.json() + + def hot_sign(self, operation, params, redirect_uri=None): + """ Creates a link for broadcasting a operation + + :param str operation: operation name (e.g.: vote) + :param dict params: operation dict params + """ + + if not isinstance(operation, str) or not isinstance(params, dict): + raise ValueError("Invalid Request.") + + base_url = self.sc2_api_url.replace("/api", "") + + if redirect_uri: + params.update({"redirect_uri": redirect_uri}) + + params = urllib.parse.urlencode(params) + url = urllib.parse.urljoin(base_url, "sign/%s" % operation) + url += "?" + params + + return url diff --git a/beem/storage.py b/beem/storage.py index 7c33dc9a..4b14df48 100644 --- a/beem/storage.py +++ b/beem/storage.py @@ -266,6 +266,129 @@ class Key(DataDir): connection.commit() +class Token(DataDir): + """ This is the token storage that stores the public username and the + (possibly encrypted) token in the `token` table in the + SQLite3 database. + """ + __tablename__ = 'token' + + def __init__(self): + super(Token, self).__init__() + + def exists_table(self): + """ Check if the database table exists + """ + query = ("SELECT name FROM sqlite_master " + "WHERE type='table' AND name=?", (self.__tablename__, )) + try: + connection = sqlite3.connect(self.sqlDataBaseFile) + cursor = connection.cursor() + cursor.execute(*query) + return True if cursor.fetchone() else False + except sqlite3.OperationalError: + self.sqlDataBaseFile = ":memory:" + log.warning("Could not read(database: %s)" % (self.sqlDataBaseFile)) + return True + + def create_table(self): + """ Create the new table in the SQLite database + """ + query = ("CREATE TABLE {0} (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "name STRING(256)," + "token STRING(256))".format(self.__tablename__)) + connection = sqlite3.connect(self.sqlDataBaseFile) + cursor = connection.cursor() + cursor.execute(query) + connection.commit() + + def getPublicNames(self): + """ Returns the public names stored in the database + """ + query = ("SELECT name from {0} ".format(self.__tablename__)) + connection = sqlite3.connect(self.sqlDataBaseFile) + cursor = connection.cursor() + try: + cursor.execute(query) + results = cursor.fetchall() + return [x[0] for x in results] + except sqlite3.OperationalError: + return [] + + def getTokenForPublicName(self, name): + """ Returns the (possibly encrypted) private token that + corresponds to a public name + + :param str pub: Public name + + The encryption scheme is BIP38 + """ + query = ("SELECT token from {0} WHERE name=?".format(self.__tablename__), (name,)) + connection = sqlite3.connect(self.sqlDataBaseFile) + cursor = connection.cursor() + cursor.execute(*query) + token = cursor.fetchone() + if token: + return token[0] + else: + return None + + def updateToken(self, name, token): + """ Change the token to a name + + :param str name: Public name + :param str token: Private token + """ + query = ("UPDATE {0} SET token=? WHERE name=?".format(self.__tablename__), (token, name)) + connection = sqlite3.connect(self.sqlDataBaseFile) + cursor = connection.cursor() + cursor.execute(*query) + connection.commit() + + def add(self, name, token): + """ Add a new public/private token pair (correspondence has to be + checked elsewhere!) + + :param str name: Public name + :param str token: Private token + """ + if self.getTokenForPublicName(name): + raise ValueError("Key already in storage") + query = ("INSERT INTO {0} (name, token) VALUES (?, ?)".format(self.__tablename__), (name, token)) + connection = sqlite3.connect(self.sqlDataBaseFile) + cursor = connection.cursor() + cursor.execute(*query) + connection.commit() + + def delete(self, name): + """ Delete the key identified as `name` + + :param str name: Public name + """ + query = ("DELETE FROM {0} WHERE name=?".format(self.__tablename__), (name,)) + connection = sqlite3.connect(self.sqlDataBaseFile) + cursor = connection.cursor() + cursor.execute(*query) + connection.commit() + + def wipe(self, sure=False): + """Purge the entire wallet. No keys will survive this!""" + if not sure: + log.error( + "You need to confirm that you are sure " + "and understand the implications of " + "wiping your wallet!" + ) + return + else: + query = ("DELETE FROM {0} ".format(self.__tablename__)) + connection = sqlite3.connect(self.sqlDataBaseFile) + cursor = connection.cursor() + cursor.execute(query) + connection.commit() + + class Configuration(DataDir): """ This is the configuration storage that stores key/value pairs in the `config` table of the SQLite3 database. @@ -280,7 +403,11 @@ class Configuration(DataDir): "password_storage": "environment", "rpcpassword": "", "rpcuser": "", - "order-expiration": 7 * 24 * 60 * 60} + "order-expiration": 7 * 24 * 60 * 60, + "client_id": "", + "sc2_scope": "login", + "sc2_api_url": "https://v2.steemconnect.com/api/", + "oauth_base_url": "https://v2.steemconnect.com/oauth2/"} def __init__(self): super(Configuration, self).__init__() @@ -531,6 +658,7 @@ class MasterPassword(object): # Create keyStorage keyStorage = Key() +tokenStorage = Token() configStorage = Configuration() # Create Tables if database is brand new @@ -541,3 +669,8 @@ newKeyStorage = False if not keyStorage.exists_table(): newKeyStorage = True keyStorage.create_table() + +newTokenStorage = False +if not tokenStorage.exists_table(): + newTokenStorage = True + tokenStorage.create_table() diff --git a/beem/transactionbuilder.py b/beem/transactionbuilder.py index 3d8ca973..0104613e 100644 --- a/beem/transactionbuilder.py +++ b/beem/transactionbuilder.py @@ -8,6 +8,7 @@ from future.utils import python_2_unicode_compatible from beemgraphenebase.py23 import bytes_types, integer_types, string_types, text_type from .account import Account from .utils import formatTimeFromNow +from .steemconnect import SteemConnect from beembase.objects import Operation from beemgraphenebase.account import PrivateKey, PublicKey from beembase.signedtransactions import Signed_Transaction @@ -150,6 +151,9 @@ class TransactionBuilder(dict): if self.steem.wallet.locked(): raise WalletLocked() + if self.steem.steemconnect is not None: + self.steem.steemconnect.set_username(account["name"]) + return def fetchkeys(account, perm, level=0): if level > 2: @@ -260,10 +264,10 @@ class TransactionBuilder(dict): """ if not self._is_constructed() or (self._is_constructed() and reconstruct_tx): self.constructTx() - if "operations" not in self or not self["operations"]: return - + if self.steem.steemconnect is not None: + return # We need to set the default prefix, otherwise pubkeys are # presented wrongly! if self.steem.rpc is not None: @@ -364,7 +368,9 @@ class TransactionBuilder(dict): return ret # Broadcast try: - if self.steem.blocking: + if self.steem.steemconnect is not None: + ret = self.steem.steemconnect.broadcast(self["operations"]) + elif self.steem.blocking: ret = self.steem.rpc.broadcast_transaction_synchronous( args, api="network_broadcast") ret.update(**ret.get("trx")) diff --git a/beem/version.py b/beem/version.py index a689cd88..d21a60e9 100644 --- a/beem/version.py +++ b/beem/version.py @@ -1,2 +1,2 @@ """THIS FILE IS GENERATED FROM beem SETUP.PY.""" -version = '0.19.32' +version = '0.19.33' diff --git a/beem/wallet.py b/beem/wallet.py index 9e399370..f4ac5f8c 100644 --- a/beem/wallet.py +++ b/beem/wallet.py @@ -6,10 +6,12 @@ from builtins import str, bytes from builtins import object import logging import os +import hashlib from beemgraphenebase import bip38 from beemgraphenebase.account import PrivateKey from beem.instance import shared_steem_instance from .account import Account +from .aes import AESCipher from .exceptions import ( MissingKeyError, InvalidWifError, @@ -21,6 +23,7 @@ from .exceptions import ( AccountDoesNotExistsException, ) from beemapi.exceptions import NoAccessApi +from beemgraphenebase.py23 import py23_bytes from .storage import configStorage as config try: import keyring @@ -95,16 +98,17 @@ class Wallet(object): import format (wif) (starting with a ``5``). """ - keys = [] masterpassword = None # Keys from database configStorage = None MasterPassword = None keyStorage = None + tokenStorage = None # Manually provided keys keys = {} # struct with pubkey as key and wif as value + token = {} keyMap = {} # type:wif pairs to force certain keys def __init__(self, steem_instance=None, *args, **kwargs): @@ -125,6 +129,18 @@ class Wallet(object): self.MasterPassword = MasterPassword self.keyStorage = keyStorage + if "token" in kwargs: + self.setToken(kwargs["token"]) + else: + """ If no keys are provided manually we load the SQLite + keyStorage + """ + from .storage import tokenStorage + if MasterPassword is None: + from .storage import MasterPassword + self.MasterPassword = MasterPassword + self.tokenStorage = tokenStorage + @property def prefix(self): if self.steem.is_connected(): @@ -157,6 +173,18 @@ class Wallet(object): pub = self._get_pub_from_wif(wif) Wallet.keys[pub] = str(wif) + def setToken(self, loadtoken): + """ This method is strictly only for in memory token that are + passed to Wallet/Steem with the ``token`` argument + """ + log.debug( + "Force setting of private token. Not using the wallet database!") + self.clear_local_token() + if isinstance(loadtoken, dict): + Wallet.token = loadtoken + else: + raise ValueError("token must be a dict variable!") + def unlock(self, pwd=None): """ Unlock the wallet database """ @@ -253,10 +281,12 @@ class Wallet(object): else: from .storage import ( keyStorage, + tokenStorage, MasterPassword ) MasterPassword.wipe(sure) keyStorage.wipe(sure) + tokenStorage.wipe(sure) self.clear_local_keys() def clear_local_keys(self): @@ -264,6 +294,10 @@ class Wallet(object): Wallet.keys = {} Wallet.keyMap = {} + def clear_local_token(self): + """Clear all manually provided token""" + Wallet.token = {} + def encrypt_wif(self, wif): """ Encrypt a wif key """ @@ -285,6 +319,35 @@ class Wallet(object): raise AssertionError() return format(bip38.decrypt(encwif, self.masterpassword), "wif") + def deriveChecksum(self, s): + """ Derive the checksum + """ + checksum = hashlib.sha256(py23_bytes(s, "ascii")).hexdigest() + return checksum[:4] + + def encrypt_token(self, token): + """ Encrypt a token key + """ + if self.locked(): + raise AssertionError() + aes = AESCipher(self.masterpassword) + return "{}${}".format(self.deriveChecksum(token), aes.encrypt(token)) + + def decrypt_token(self, enctoken): + """ decrypt a wif key + """ + if self.locked(): + raise AssertionError() + aes = AESCipher(self.masterpassword) + checksum, encrypted_token = enctoken.split("$") + try: + decrypted_token = aes.decrypt(encrypted_token) + except: + raise WrongMasterPasswordException + if checksum != self.deriveChecksum(decrypted_token): + raise WrongMasterPasswordException + return decrypted_token + def _get_pub_from_wif(self, wif): """ Get the pubkey as string, from the wif key as string """ @@ -298,6 +361,44 @@ class Wallet(object): raise InvalidWifError( "Invalid Private Key Format. Please use WIF!") + def addToken(self, name, token): + if self.tokenStorage: + if not self.created(): + raise NoWalletException + self.tokenStorage.add(name, self.encrypt_token(token)) + + def getTokenForAccountName(self, name): + """ Obtain the private token for a given public name + + :param str name: Public name + """ + if(Wallet.token): + if name in Wallet.token: + return Wallet.token[name] + else: + raise MissingKeyError("No private token for {} found".format(name)) + else: + # Test if wallet exists + if not self.created(): + raise NoWalletException + + if not self.unlocked(): + raise WalletLocked + + enctoken = self.tokenStorage.getTokenForPublicName(name) + if not enctoken: + raise MissingKeyError("No private token for {} found".format(name)) + return self.decrypt_token(enctoken) + + def removeTokenFromPublicName(self, name): + """ Remove a token from the wallet database + """ + if self.tokenStorage: + # Test if wallet exists + if not self.created(): + raise NoWalletException + self.tokenStorage.delete(name) + def addPrivateKey(self, wif): """Add a private key to the wallet database""" pub = self._get_pub_from_wif(wif) @@ -546,3 +647,11 @@ class Wallet(object): return self.keyStorage.getPublicKeys() else: return list(Wallet.keys.keys()) + + def getPublicNames(self): + """ Return all installed public token + """ + if self.tokenStorage: + return self.tokenStorage.getPublicNames() + else: + return list(Wallet.token.keys()) diff --git a/beemapi/version.py b/beemapi/version.py index a689cd88..d21a60e9 100644 --- a/beemapi/version.py +++ b/beemapi/version.py @@ -1,2 +1,2 @@ """THIS FILE IS GENERATED FROM beem SETUP.PY.""" -version = '0.19.32' +version = '0.19.33' diff --git a/beembase/version.py b/beembase/version.py index a689cd88..d21a60e9 100644 --- a/beembase/version.py +++ b/beembase/version.py @@ -1,2 +1,2 @@ """THIS FILE IS GENERATED FROM beem SETUP.PY.""" -version = '0.19.32' +version = '0.19.33' diff --git a/beemgraphenebase/version.py b/beemgraphenebase/version.py index a689cd88..d21a60e9 100644 --- a/beemgraphenebase/version.py +++ b/beemgraphenebase/version.py @@ -1,2 +1,2 @@ """THIS FILE IS GENERATED FROM beem SETUP.PY.""" -version = '0.19.32' +version = '0.19.33' diff --git a/examples/login_app/app.py b/examples/login_app/app.py new file mode 100644 index 00000000..0284d7b1 --- /dev/null +++ b/examples/login_app/app.py @@ -0,0 +1,36 @@ +from flask import Flask, request +from beem.steemconnect import SteemConnect + +app = Flask(__name__) + + +c = SteemConnect(client_id="beem.app", scope="login,vote,custom_json", get_refresh_token=False) +# replace test with our wallet password +c.steem.wallet.unlock("test") + + +@app.route('/') +def index(): + login_url = c.get_login_url( + "http://localhost:5000/welcome", + ) + return "<a href='%s'>Login with SteemConnect</a>" % login_url + + +@app.route('/welcome') +def welcome(): + access_token = request.args.get("access_token", None) + name = request.args.get("username", None) + if c.get_refresh_token: + code = request.args.get("code") + refresh_token = c.get_access_token(code) + access_token = refresh_token["access_token"] + name = refresh_token["username"] + elif name is None: + c.set_access_token(access_token) + name = c.me()["name"] + + if name in c.steem.wallet.getPublicNames(): + c.steem.wallet.removeTokenFromPublicName(name) + c.steem.wallet.addToken(name, access_token) + return "Welcome <strong>%s</strong>!" % name diff --git a/setup.py b/setup.py index 2d7392cb..9141f9d3 100755 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ except LookupError: ascii = codecs.lookup('ascii') codecs.register(lambda name, enc=ascii: {True: enc}.get(name == 'mbcs')) -VERSION = '0.19.32' +VERSION = '0.19.33' tests_require = ['mock >= 2.0.0', 'pytest', 'pytest-mock', 'parameterized'] -- GitLab