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 b68364f39..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. @@ -202,3 +206,28 @@ whitelist Show the current whitelist :returns: json object ``` + +## Advanced API usage using JWT tokens + +!!! 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 (JSON Web 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 access_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 `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 +{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk5NzQsIm5iZiI6MTU4OTExOTk3NCwianRpIjoiMDBjNTlhMWUtMjBmYS00ZTk0LTliZjAtNWQwNTg2MTdiZDIyIiwiZXhwIjoxNTg5MTIwODc0LCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.1seHlII3WprjjclY6DpRhen0rqdF4j6jbvxIhUFaSbs"} +``` diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index 0335bb151..68f4b1ca9 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,9 @@ 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): + 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 @@ -70,8 +75,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 +88,12 @@ class ApiServer(RPC): self._config = freqtrade.config self.app = Flask(__name__) + + # Setup the Flask-JWT-Extended extension + 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 # 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._token_login, methods=['POST']) + self.app.add_url_rule(f'{BASE_URI}/token/refresh', 'token_refresh', + 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']) @@ -199,6 +214,37 @@ class ApiServer(RPC): 'code': 404 }), 404 + @require_login + @rpc_catch_errors + def _token_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 _token_refresh(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): 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', diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 9b2f893e3..5d8f79920 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 @@ -123,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())