Merge pull request #3278 from freqtrade/api/jwt

API server - support JWT
This commit is contained in:
hroff-1902 2020-05-10 21:33:41 +03:00 committed by GitHub
commit bbb609c927
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 117 additions and 7 deletions

View File

@ -120,6 +120,7 @@
"enabled": false, "enabled": false,
"listen_ip_address": "127.0.0.1", "listen_ip_address": "127.0.0.1",
"listen_port": 8080, "listen_port": 8080,
"jwt_secret_key": "somethingrandom",
"username": "freqtrader", "username": "freqtrader",
"password": "SuperSecurePassword" "password": "SuperSecurePassword"
}, },

View File

@ -11,6 +11,7 @@ Sample configuration:
"enabled": true, "enabled": true,
"listen_ip_address": "127.0.0.1", "listen_ip_address": "127.0.0.1",
"listen_port": 8080, "listen_port": 8080,
"jwt_secret_key": "somethingrandom",
"username": "Freqtrader", "username": "Freqtrader",
"password": "SuperSecret1!" "password": "SuperSecret1!"
}, },
@ -29,7 +30,7 @@ This should return the response:
{"status":"pong"} {"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. 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() secrets.token_hex()
``` ```
!!! Hint
Use the same method to also generate a JWT secret key (`jwt_secret_key`).
### Configuration with docker ### 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. 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 Show the current whitelist
:returns: json object :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"}
```

View File

@ -2,11 +2,16 @@ import logging
import threading import threading
from datetime import date, datetime from datetime import date, datetime
from ipaddress import IPv4Address from ipaddress import IPv4Address
from typing import Dict, Callable, Any from typing import Any, Callable, Dict
from arrow import Arrow from arrow import Arrow
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
from flask.json import JSONEncoder 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 werkzeug.serving import make_server
from freqtrade.__init__ import __version__ from freqtrade.__init__ import __version__
@ -38,9 +43,9 @@ class ArrowJSONEncoder(JSONEncoder):
def require_login(func: Callable[[Any, Any], Any]): def require_login(func: Callable[[Any, Any], Any]):
def func_wrapper(obj, *args, **kwargs): def func_wrapper(obj, *args, **kwargs):
verify_jwt_in_request_optional()
auth = request.authorization 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) return func(obj, *args, **kwargs)
else: else:
return jsonify({"error": "Unauthorized"}), 401 return jsonify({"error": "Unauthorized"}), 401
@ -70,8 +75,8 @@ class ApiServer(RPC):
""" """
def check_auth(self, username, password): def check_auth(self, username, password):
return (username == self._config['api_server'].get('username') and return (safe_str_cmp(username, self._config['api_server'].get('username')) and
password == self._config['api_server'].get('password')) safe_str_cmp(password, self._config['api_server'].get('password')))
def __init__(self, freqtrade) -> None: def __init__(self, freqtrade) -> None:
""" """
@ -83,6 +88,12 @@ class ApiServer(RPC):
self._config = freqtrade.config self._config = freqtrade.config
self.app = Flask(__name__) 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 self.app.json_encoder = ArrowJSONEncoder
# Register application handling # Register application handling
@ -148,6 +159,10 @@ class ApiServer(RPC):
self.app.register_error_handler(404, self.page_not_found) self.app.register_error_handler(404, self.page_not_found)
# Actions to control the bot # 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', self.app.add_url_rule(f'{BASE_URI}/start', 'start',
view_func=self._start, methods=['POST']) view_func=self._start, methods=['POST'])
self.app.add_url_rule(f'{BASE_URI}/stop', 'stop', view_func=self._stop, 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 'code': 404
}), 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 @require_login
@rpc_catch_errors @rpc_catch_errors
def _start(self): def _start(self):

View File

@ -25,6 +25,7 @@ sdnotify==0.3.2
# Api server # Api server
flask==1.1.2 flask==1.1.2
flask-jwt-extended==3.24.1
# Support for colorized terminal output # Support for colorized terminal output
colorama==0.4.3 colorama==0.4.3

View File

@ -16,7 +16,7 @@ if readme_file.is_file():
readme_long = (Path(__file__).parent / "README.md").read_text() readme_long = (Path(__file__).parent / "README.md").read_text()
# Requirements used for submodules # Requirements used for submodules
api = ['flask'] api = ['flask', 'flask-jwt-extended']
plot = ['plotly>=4.0'] plot = ['plotly>=4.0']
hyperopt = [ hyperopt = [
'scipy', 'scipy',

View File

@ -94,6 +94,33 @@ def test_api_unauthorized(botclient):
assert rc.json == {'error': 'Unauthorized'} 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): def test_api_stop_workflow(botclient):
ftbot, client = botclient ftbot, client = botclient
assert ftbot.state == State.RUNNING assert ftbot.state == State.RUNNING
@ -123,6 +150,12 @@ def test_api__init__(default_conf, mocker):
""" """
Test __init__() method 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.telegram.Updater', MagicMock())
mocker.patch('freqtrade.rpc.api_server.ApiServer.run', MagicMock()) mocker.patch('freqtrade.rpc.api_server.ApiServer.run', MagicMock())