Merge pull request #3278 from freqtrade/api/jwt
API server - support JWT
This commit is contained in:
commit
bbb609c927
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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"}
|
||||||
|
```
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
2
setup.py
2
setup.py
@ -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',
|
||||||
|
@ -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())
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user