From b72997fc2bbb1d685a27605026bf3fa269973ca5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 9 May 2020 20:16:04 +0200 Subject: [PATCH 01/11] Add flask-jwt-extended dependency --- requirements-common.txt | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements-common.txt b/requirements-common.txt index cd302e348..9fb4e209b 100644 --- a/requirements-common.txt +++ b/requirements-common.txt @@ -25,6 +25,7 @@ sdnotify==0.3.2 # Api server flask==1.1.2 +flask-jwt-extended==3.24.1 # Support for colorized terminal output colorama==0.4.3 diff --git a/setup.py b/setup.py index 9c253ea4e..8c0de095e 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ if readme_file.is_file(): readme_long = (Path(__file__).parent / "README.md").read_text() # Requirements used for submodules -api = ['flask'] +api = ['flask', 'flask-jwt-extended'] plot = ['plotly>=4.0'] hyperopt = [ 'scipy', From 8139058fcc5c471e762f182f1608fbff3c6bbed9 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 10:35:38 +0200 Subject: [PATCH 02/11] Implement token/login and token/refresh endpoints --- freqtrade/rpc/api_server.py | 56 +++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index 0335bb151..1e20bf8f8 100644 --- a/freqtrade/rpc/api_server.py +++ b/freqtrade/rpc/api_server.py @@ -2,11 +2,16 @@ import logging import threading from datetime import date, datetime from ipaddress import IPv4Address -from typing import Dict, Callable, Any +from typing import Any, Callable, Dict from arrow import Arrow from flask import Flask, jsonify, request from flask.json import JSONEncoder +from flask_jwt_extended import (JWTManager, create_access_token, + create_refresh_token, get_jwt_identity, + jwt_refresh_token_required, + verify_jwt_in_request_optional) +from werkzeug.security import safe_str_cmp from werkzeug.serving import make_server from freqtrade.__init__ import __version__ @@ -38,9 +43,10 @@ class ArrowJSONEncoder(JSONEncoder): def require_login(func: Callable[[Any, Any], Any]): def func_wrapper(obj, *args, **kwargs): - + verify_jwt_in_request_optional() auth = request.authorization - if auth and obj.check_auth(auth.username, auth.password): + i = get_jwt_identity() + if i or auth and obj.check_auth(auth.username, auth.password): return func(obj, *args, **kwargs) else: return jsonify({"error": "Unauthorized"}), 401 @@ -70,8 +76,8 @@ class ApiServer(RPC): """ def check_auth(self, username, password): - return (username == self._config['api_server'].get('username') and - password == self._config['api_server'].get('password')) + return (safe_str_cmp(username, self._config['api_server'].get('username')) and + safe_str_cmp(password, self._config['api_server'].get('password'))) def __init__(self, freqtrade) -> None: """ @@ -83,6 +89,11 @@ class ApiServer(RPC): self._config = freqtrade.config self.app = Flask(__name__) + + # Setup the Flask-JWT-Extended extension + self.app.config['JWT_SECRET_KEY'] = 'super-secret' # Change this! + + self.jwt = JWTManager(self.app) self.app.json_encoder = ArrowJSONEncoder # Register application handling @@ -148,6 +159,10 @@ class ApiServer(RPC): self.app.register_error_handler(404, self.page_not_found) # Actions to control the bot + self.app.add_url_rule(f'{BASE_URI}/token/login', 'login', + view_func=self._login, methods=['POST']) + self.app.add_url_rule(f'{BASE_URI}/token/refresh', 'token_refresh', + view_func=self._refresh_token, methods=['POST']) self.app.add_url_rule(f'{BASE_URI}/start', 'start', view_func=self._start, methods=['POST']) self.app.add_url_rule(f'{BASE_URI}/stop', 'stop', view_func=self._stop, methods=['POST']) @@ -199,6 +214,37 @@ class ApiServer(RPC): 'code': 404 }), 404 + @require_login + @rpc_catch_errors + def _login(self): + """ + Handler for /token/login + Returns a JWT token + """ + auth = request.authorization + if auth and self.check_auth(auth.username, auth.password): + keystuff = {'u': auth.username} + ret = { + 'access_token': create_access_token(identity=keystuff), + 'refresh_token': create_refresh_token(identity=keystuff), + } + return self.rest_dump(ret) + + return jsonify({"error": "Unauthorized"}), 401 + + @jwt_refresh_token_required + @rpc_catch_errors + def _refresh_token(self): + """ + Handler for /token/refresh + Returns a JWT token based on a JWT refresh token + """ + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + + ret = {'access_token': new_token} + return self.rest_dump(ret) + @require_login @rpc_catch_errors def _start(self): From bc64619f301fb870b91fe4682e255b975f1cd5f7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 10:43:13 +0200 Subject: [PATCH 03/11] Tests for JWT implementation --- tests/rpc/test_rpc_apiserver.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 9b2f893e3..0d4cdeb2f 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -94,6 +94,33 @@ def test_api_unauthorized(botclient): assert rc.json == {'error': 'Unauthorized'} +def test_api_token_login(botclient): + ftbot, client = botclient + rc = client_post(client, f"{BASE_URI}/token/login") + assert_response(rc) + assert 'access_token' in rc.json + assert 'refresh_token' in rc.json + + # test Authentication is working with JWT tokens too + rc = client.get(f"{BASE_URI}/count", + content_type="application/json", + headers={'Authorization': f'Bearer {rc.json["access_token"]}'}) + assert_response(rc) + + +def test_api_token_refresh(botclient): + ftbot, client = botclient + rc = client_post(client, f"{BASE_URI}/token/login") + assert_response(rc) + rc = client.post(f"{BASE_URI}/token/refresh", + content_type="application/json", + data=None, + headers={'Authorization': f'Bearer {rc.json["refresh_token"]}'}) + assert_response(rc) + assert 'access_token' in rc.json + assert 'refresh_token' not in rc.json + + def test_api_stop_workflow(botclient): ftbot, client = botclient assert ftbot.state == State.RUNNING From d9e4f41a359647bb50f1cc7e00571c7758aba40f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 16:14:35 +0200 Subject: [PATCH 04/11] Add documentation for JWt --- docs/rest-api.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/rest-api.md b/docs/rest-api.md index b68364f39..d750f74af 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -202,3 +202,28 @@ whitelist Show the current whitelist :returns: json object ``` + +## Advanced API usage using JWT tokens + +!!! Note + The below should be done in an application, and is not intended to be used on a regular basis. + +Freqtrade's REST API also offers JWT tokens. +You can login using the following command, and subsequently use the resulting access_token. + +``` bash +> curl -X POST --user Freqtrader http://localhost:8080/api/v1/token/login +{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk2ODEsIm5iZiI6MTU4OTExOTY4MSwianRpIjoiMmEwYmY0NWUtMjhmOS00YTUzLTlmNzItMmM5ZWVlYThkNzc2IiwiZXhwIjoxNTg5MTIwNTgxLCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.qt6MAXYIa-l556OM7arBvYJ0SDI9J8bIk3_glDujF5g","refresh_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk2ODEsIm5iZiI6MTU4OTExOTY4MSwianRpIjoiZWQ1ZWI3YjAtYjMwMy00YzAyLTg2N2MtNWViMjIxNWQ2YTMxIiwiZXhwIjoxNTkxNzExNjgxLCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJ0eXBlIjoicmVmcmVzaCJ9.d1AT_jYICyTAjD0fiQAr52rkRqtxCjUGEMwlNuuzgNQ"} + +> access_token="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk2ODEsIm5iZiI6MTU4OTExOTY4MSwianRpIjoiMmEwYmY0NWUtMjhmOS00YTUzLTlmNzItMmM5ZWVlYThkNzc2IiwiZXhwIjoxNTg5MTIwNTgxLCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.qt6MAXYIa-l556OM7arBvYJ0SDI9J8bIk3_glDujF5g" +# Use tccess_token for authentication +> curl -X GET --header "Authorization: Bearer ${access_token}" http://localhost:8080/api/v1/count + +``` + +Since the access-token has a short timeout (15 min) - the refresh-token should be used to get a fresh access token: + +``` bash +> curl -X POST --header "Authorization: Bearer ${refresh_token}"http://localhost:8080/api/v1/token/refresh +{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk5NzQsIm5iZiI6MTU4OTExOTk3NCwianRpIjoiMDBjNTlhMWUtMjBmYS00ZTk0LTliZjAtNWQwNTg2MTdiZDIyIiwiZXhwIjoxNTg5MTIwODc0LCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.1seHlII3WprjjclY6DpRhen0rqdF4j6jbvxIhUFaSbs"} +``` From 2406e20be2a8af33c33ca06eeb01a3e77cef5f88 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 17:36:43 +0200 Subject: [PATCH 05/11] Update docs/rest-api.md Co-authored-by: hroff-1902 <47309513+hroff-1902@users.noreply.github.com> --- docs/rest-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rest-api.md b/docs/rest-api.md index d750f74af..599ad870f 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -206,7 +206,7 @@ whitelist ## Advanced API usage using JWT tokens !!! Note - The below should be done in an application, and is not intended to be used on a regular basis. + The below should be done in an application (a Freqtrade REST API client, which fetches info via API), and is not intended to be used on a regular basis. Freqtrade's REST API also offers JWT tokens. You can login using the following command, and subsequently use the resulting access_token. From c2224ed6b7496d043853f84c9d5e1762372c102c Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 17:47:43 +0200 Subject: [PATCH 06/11] Fix doc typos Co-authored-by: hroff-1902 <47309513+hroff-1902@users.noreply.github.com> --- docs/rest-api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rest-api.md b/docs/rest-api.md index 599ad870f..42671f0c3 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -208,7 +208,7 @@ whitelist !!! Note The below should be done in an application (a Freqtrade REST API client, which fetches info via API), and is not intended to be used on a regular basis. -Freqtrade's REST API also offers JWT tokens. +Freqtrade's REST API also offers JWT (JSON Web Tokens). You can login using the following command, and subsequently use the resulting access_token. ``` bash @@ -216,7 +216,7 @@ You can login using the following command, and subsequently use the resulting ac {"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk2ODEsIm5iZiI6MTU4OTExOTY4MSwianRpIjoiMmEwYmY0NWUtMjhmOS00YTUzLTlmNzItMmM5ZWVlYThkNzc2IiwiZXhwIjoxNTg5MTIwNTgxLCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.qt6MAXYIa-l556OM7arBvYJ0SDI9J8bIk3_glDujF5g","refresh_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk2ODEsIm5iZiI6MTU4OTExOTY4MSwianRpIjoiZWQ1ZWI3YjAtYjMwMy00YzAyLTg2N2MtNWViMjIxNWQ2YTMxIiwiZXhwIjoxNTkxNzExNjgxLCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJ0eXBlIjoicmVmcmVzaCJ9.d1AT_jYICyTAjD0fiQAr52rkRqtxCjUGEMwlNuuzgNQ"} > access_token="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk2ODEsIm5iZiI6MTU4OTExOTY4MSwianRpIjoiMmEwYmY0NWUtMjhmOS00YTUzLTlmNzItMmM5ZWVlYThkNzc2IiwiZXhwIjoxNTg5MTIwNTgxLCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.qt6MAXYIa-l556OM7arBvYJ0SDI9J8bIk3_glDujF5g" -# Use tccess_token for authentication +# Use access_token for authentication > curl -X GET --header "Authorization: Bearer ${access_token}" http://localhost:8080/api/v1/count ``` From b163bb76503041798ecf2898f0eabc53771a9052 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 17:48:29 +0200 Subject: [PATCH 07/11] Apply suggestions from code review Co-authored-by: hroff-1902 <47309513+hroff-1902@users.noreply.github.com> --- docs/rest-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rest-api.md b/docs/rest-api.md index 42671f0c3..337a00b4f 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -221,7 +221,7 @@ You can login using the following command, and subsequently use the resulting ac ``` -Since the access-token has a short timeout (15 min) - the refresh-token should be used to get a fresh access token: +Since the access token has a short timeout (15 min) - the `token/refresh` request should be used periodically to get a fresh access token: ``` bash > curl -X POST --header "Authorization: Bearer ${refresh_token}"http://localhost:8080/api/v1/token/refresh From c3f0b5d4eb89d0f71bfca021e89625395457c01d Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 19:37:41 +0200 Subject: [PATCH 08/11] Rename methods to match endpoints --- freqtrade/rpc/api_server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index 1e20bf8f8..3a46d2c88 100644 --- a/freqtrade/rpc/api_server.py +++ b/freqtrade/rpc/api_server.py @@ -160,9 +160,9 @@ class ApiServer(RPC): # Actions to control the bot self.app.add_url_rule(f'{BASE_URI}/token/login', 'login', - view_func=self._login, methods=['POST']) + view_func=self._token_login, methods=['POST']) self.app.add_url_rule(f'{BASE_URI}/token/refresh', 'token_refresh', - view_func=self._refresh_token, methods=['POST']) + view_func=self._token_refresh, methods=['POST']) self.app.add_url_rule(f'{BASE_URI}/start', 'start', view_func=self._start, methods=['POST']) self.app.add_url_rule(f'{BASE_URI}/stop', 'stop', view_func=self._stop, methods=['POST']) @@ -216,7 +216,7 @@ class ApiServer(RPC): @require_login @rpc_catch_errors - def _login(self): + def _token_login(self): """ Handler for /token/login Returns a JWT token @@ -234,7 +234,7 @@ class ApiServer(RPC): @jwt_refresh_token_required @rpc_catch_errors - def _refresh_token(self): + def _token_refresh(self): """ Handler for /token/refresh Returns a JWT token based on a JWT refresh token From 21c2af2b92208c8ee811a91fba7046f99d556db6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 19:42:06 +0200 Subject: [PATCH 09/11] Load jwt_key from config --- config_full.json.example | 1 + docs/rest-api.md | 6 +++++- freqtrade/rpc/api_server.py | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/config_full.json.example b/config_full.json.example index 181740b9a..ee1c14d27 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -120,6 +120,7 @@ "enabled": false, "listen_ip_address": "127.0.0.1", "listen_port": 8080, + "jwt_secret_key": "somethingrandom", "username": "freqtrader", "password": "SuperSecurePassword" }, diff --git a/docs/rest-api.md b/docs/rest-api.md index 337a00b4f..7f1a95b12 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -11,6 +11,7 @@ Sample configuration: "enabled": true, "listen_ip_address": "127.0.0.1", "listen_port": 8080, + "jwt_secret_key": "somethingrandom", "username": "Freqtrader", "password": "SuperSecret1!" }, @@ -29,7 +30,7 @@ This should return the response: {"status":"pong"} ``` -All other endpoints return sensitive info and require authentication, so are not available through a web browser. +All other endpoints return sensitive info and require authentication and are therefore not available through a web browser. To generate a secure password, either use a password manager, or use the below code snipped. @@ -38,6 +39,9 @@ import secrets secrets.token_hex() ``` +!!! Hint + Use the same method to also generate a JWT secret key (`jwt_secret_key`). + ### Configuration with docker If you run your bot using docker, you'll need to have the bot listen to incomming connections. The security is then handled by docker. diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index 3a46d2c88..21f28f601 100644 --- a/freqtrade/rpc/api_server.py +++ b/freqtrade/rpc/api_server.py @@ -91,7 +91,8 @@ class ApiServer(RPC): self.app = Flask(__name__) # Setup the Flask-JWT-Extended extension - self.app.config['JWT_SECRET_KEY'] = 'super-secret' # Change this! + self.app.config['JWT_SECRET_KEY'] = self._config['api_server'].get( + 'jwt_secret_key', 'super-secret') self.jwt = JWTManager(self.app) self.app.json_encoder = ArrowJSONEncoder From d291ca0071b2e73dc0750bedced1257c37d73032 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 19:43:16 +0200 Subject: [PATCH 10/11] Simplify code section --- freqtrade/rpc/api_server.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index 21f28f601..68f4b1ca9 100644 --- a/freqtrade/rpc/api_server.py +++ b/freqtrade/rpc/api_server.py @@ -45,8 +45,7 @@ def require_login(func: Callable[[Any, Any], Any]): def func_wrapper(obj, *args, **kwargs): verify_jwt_in_request_optional() auth = request.authorization - i = get_jwt_identity() - if i or auth and obj.check_auth(auth.username, auth.password): + if get_jwt_identity() or auth and obj.check_auth(auth.username, auth.password): return func(obj, *args, **kwargs) else: return jsonify({"error": "Unauthorized"}), 401 From 9eca268a498ed129299dd9910f01f5bf1ea81363 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 10 May 2020 20:00:19 +0200 Subject: [PATCH 11/11] Fix test --- tests/rpc/test_rpc_apiserver.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 0d4cdeb2f..5d8f79920 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -150,6 +150,12 @@ def test_api__init__(default_conf, mocker): """ Test __init__() method """ + default_conf.update({"api_server": {"enabled": True, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "username": "TestUser", + "password": "testPass", + }}) mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) mocker.patch('freqtrade.rpc.api_server.ApiServer.run', MagicMock())