diff --git a/docs/api/README.md b/docs/api/README.md index 1f35110b26..5562157e53 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -5,21 +5,91 @@ Specter provides a Rest-API which is, by default, in production deactivated. In export SPECTER_API_ACTIVE=True ``` -The Authentication is also necessary if you don't activate any Authentication mechanism. +The Authentication is also necessary even if you don't activate any Authentication mechanism. In order to make reasonable assumptions about how stable a specific endpoint is, we're versioning them via the URL. Currently, all endpoints are preset with `v1alpha` which pretty much don't give you any guarantee. -## Basic Usage -Curl: +The Specter API is using JWT tokens for Authentication. In order to use the API, you need to obtain such a token. Currently, obtaining a token is not possible via the UI but only via a special endpoint, which accepts BasicAuth (as the only endpoint). +## Curl: + +Create the token like this: +```bash +curl -u admin:password --location --request POST 'http://127.0.0.1:25441/api/v1alpha/token' \ +--header 'Content-Type: application/json' \ +-d '{ + "jwt_token_description": "A free description here to know for what the token is used", + "jwt_token_life": "30 days" +}' +``` +As a result, you get a json like this: +```json +{ + "message": "Token generated", + "jwt_token_id": "4969e9fb-2097-41e7-af53-5e2082a3e4d3", + "jwt_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiand0X3Rva2VuX2lkIjoiNDk2OWU5ZmItMjA5Ny00MWU3LWFmNTMtNWUyMDgyYTNlNGQzIiwiand0X3Rva2VuX2Rlc2NyaXB0aW9uIjoiQSBmcmVlIGRlc2NyaXB0aW9uIGhlcmUgdG8ga25vdyBmb3Igd2hhdCB0aGUgdG9rZW4gaXMgdXNlZCIsImV4cCI6MTY5NjU4NDQ0MiwiaWF0IjoxNjY1MDQ4NDQyfQ.S2NIQknkNqoe-u0xA-W8ZxxkDM-I5B8eDCUwLrG-98E", + "jwt_token_description": "A free description here to know for what the token is used", + "jwt_token_life": 31536000 +} +``` + +The token will only be shown once. However, apart from the token itself, you can still get the details of a specific token like this: + +```bash +curl -s -u admin:secret --location --request GET 'http://127.0.0.1:25441/api/v1alpha/token/4969e9fb-2097-41e7-af53-5e2082a3e4d3' | jq . +``` + +```json +{ + "message": "Token exists", + "jwt_token_description": "A free description here to know for what the token is used", + "jwt_token_life": 2592000, + "jwt_token_life_remaining": 2591960.19173622, + "expiry_status": "Valid" +} +``` + +The `jwt_token_life` value and the other one are expressed in seconds. + +In order to use that token, you can e.g. call the specter-endpoint like this: ```bash -curl -u admin:secret -X GET http://127.0.0.1:25441/api/v1alpha/specter | jq . +curl -s --location --request GET 'http://127.0.0.1:25441/api/v1alpha/specter' \ +--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiand0X3Rva2VuX2lkIjoiNDk2OWU5ZmItMjA5Ny00MWU3LWFmNTMtNWUyMDgyYTNlNGQzIiwiand0X3Rva2VuX2Rlc2NyaXB0aW9uIjoiQSBmcmVlIGRlc2NyaXB0aW9uIGhlcmUgdG8ga25vdyBmb3Igd2hhdCB0aGUgdG9rZW4gaXMgdXNlZCIsImV4cCI6MTY5NjU4NDQ0MiwiaWF0IjoxNjY1MDQ4NDQyfQ.S2NIQknkNqoe-u0xA-W8ZxxkDM-I5B8eDCUwLrG-98E' | jq . +``` +The result would be something like this: + +```json +{ + "data_folder": "/home/someuser/.specter", + "config": { + "auth": { + "method": "usernamepassword", + "password_min_chars": 6, + "rate_limit": "10", + "registration_link_timeout": "1" + }, + [...] + "wallets_names": [], + "last_update": "10/06/2022, 11:35:21", + "alias_name": {}, + "name_alias": {}, + "wallets_alias": [] +} +``` +## Python + +Here is an example of using the API with python. We don't assume that you use BasicAuth via python. Instead of an example of a real token, we use `` and ``. + +```python +import requests +response = requests.get('http://127.0.0.1:25441/api/v1alpha/token/', auth=('admin', 'secret')) +json.loads(response.text) ``` -Python: ```python +### Pass the token to get authorized import requests -response = requests.get('http://127.0.0.1:25441/api/v1alpha/specter', auth=('admin', 'secret')) +response = requests.get('http://127.0.0.1:25441/api/v1alpha/specter', headers={'Authorization': 'Bearer '}) json.loads(response.text) ``` @@ -31,6 +101,7 @@ json.loads(response.text) * [Specter Full Tx List](./ep_specter_fulltxlist.md): Gives a full tx_list of all transactions. * [Wallet](./ep_wallets_wallet.md): Details about a specific Wallet * [Wallet PSBT](./ep_wallets_psbt.md): Listing and creating PSBTs +* [JWT Tokens](./ep_jwt_tokens.md): Listing, creating and managing JWT Tokens ] diff --git a/docs/api/ep_jwt_tokens.md b/docs/api/ep_jwt_tokens.md new file mode 100644 index 0000000000..a936d73899 --- /dev/null +++ b/docs/api/ep_jwt_tokens.md @@ -0,0 +1,135 @@ +# Token Endpoint + +Creates a new token for the user and Gives all the tokens created by the user. + +**URL** : `/v1alpha/token` + +## GET + +**Method** : `GET` + +**Auth required** : Yes + +**Permissions required** : None + +### Success Response + +**Code** : `200 OK` + +**Content examples** + +### Get Result + +```json +{ + "message": "Tokens exists", + "jwt_tokens": { + "94f10f9b-2139-4f31-ab57-52ac175b9acc": { + "jwt_token_description": "Token beta", + "jwt_token_life": 5400, + "jwt_token_remaining_life": 5395.147431612015 + }, + "2bc0160d-edf4-4ab6-9801-52d185f65b59": { + "jwt_token_description": "Token alpha", + "jwt_token_life": 360, + "jwt_token_remaining_life": 232.19542360305786 + } + } +} +``` + +## POST + +**Method** : `POST` + +**Auth required** : YES + +**Permissions required** : None + +``` +curl -u admin:password --location --request POST 'http://127.0.0.1:25441/api/v1alpha/token' \ +--header 'Content-Type: application/json' \ +-d '{ + "jwt_token_description": "Token specter", + "jwt_token_life": "6 hours" +}' +``` + +As a result, you get all the created tokens. + +### Success Response + +**Code** : `201 Created` + +**Content examples** + +### Post Result + +```json +{ + "message": "Token generated", + "jwt_token_id": "b56929f3-54f1-4dc2-9984-9bba615e26e6", + "jwt_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiand0X3Rva2VuX2lkIjoiYjU2OTI5ZjMtNTRmMS00ZGMyLTk5ODQtOWJiYTYxNWUyNmU2Iiwiand0X3Rva2VuX2Rlc2NyaXB0aW9uIjoiVG9rZW4gc3BlY3RlciIsImV4cCI6MTY2Mjc0MTczNSwiaWF0IjoxNjYyNzIwMTM1fQ.gBE7S4lJfpPQctt2Dk_581-6v1YOzn4UPHYO18LZpF8", + "jwt_token_description": "Token specter", + "jwt_token_life": 21600 +} +``` + +Gives the token details of which the id is passed in the URL and deletes the same token. + +**URL** : `/v1alpha/token/` + +## GET + +**Method** : `GET` + +**Auth required** : Yes + +**Permissions required** : None + +``` +curl -u admin:secret --location --request GET 'http://127.0.0.1:25441/api/v1alpha/token/' | jq . +``` + +### Success Response + +**Code** : `200 OK` + +**Content examples** + +### Get Result + +```json +{ + "message": "Tokens exists", + "jwt_token_description": "Token alpha", + "jwt_token_life": 360, + "jwt_token_life_remaining": 232.19542360305786, + "expiry_status": "Valid" +} +``` +## DELETE + +**Method** : `DELETE` + +**Auth required** : Yes + +**Permissions required** : None + +``` +curl -u admin:secret --location --request DELETE 'http://127.0.0.1:25441/api/v1alpha/token/' | jq . +``` + +### Success Response + +**Code** : `200 OK` + +**Content examples** + +### Delete Result + +```json +{ + "message": "Token deleted" +} +``` diff --git a/docs/release-notes.md b/docs/release-notes.md index f08c6bfd01..5441b67eb4 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,6 @@ # Release Notes -## v1.13.1 Oktober 17, 2022 +## v1.13.1 October 17, 2022 - Bugfix: Hover effect in balance display #1904 (Manolis Mandrapilias) - Bugfix: Remove black empty bar in tx-table after search #1912 (relativisticelectron) - Bugfix: upgrade hwi to 2.1.1 to fix #1840 #1909 (k9ert) diff --git a/requirements.in b/requirements.in index 19eb199041..7dc0e72de7 100644 --- a/requirements.in +++ b/requirements.in @@ -27,6 +27,8 @@ Flask-APScheduler==1.12.3 backports.zoneinfo==0.2.1 ; python_version < '3.10' gunicorn==20.1.0 protobuf==3.20.1 +PyJWT==2.4.0 +pytimeparse==1.1.8 # Extensions cryptoadvance-liquidissuer==0.2.4 specterext-exfund==0.1.7 diff --git a/requirements.txt b/requirements.txt index f47cde25d6..e7ec00643f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -410,6 +410,10 @@ pycparser==2.21 \ --hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \ --hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206 # via cffi +pyjwt==2.4.0 \ + --hash=sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf \ + --hash=sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba + # via -r requirements.in pyopenssl==20.0.1 \ --hash=sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51 \ --hash=sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b @@ -431,6 +435,10 @@ python-dotenv==0.13.0 \ --hash=sha256:25c0ff1a3e12f4bde8d592cc254ab075cfe734fc5dd989036716fd17ee7e5ec7 \ --hash=sha256:3b9909bc96b0edc6b01586e1eed05e71174ef4e04c71da5786370cebea53ad74 # via -r requirements.in +pytimeparse==1.1.8 \ + --hash=sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd \ + --hash=sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a + # via -r requirements.in pytz-deprecation-shim==0.1.0.post0 \ --hash=sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6 \ --hash=sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d diff --git a/src/cryptoadvance/specter/api/__init__.py b/src/cryptoadvance/specter/api/__init__.py index 6f63072226..f46aba830e 100644 --- a/src/cryptoadvance/specter/api/__init__.py +++ b/src/cryptoadvance/specter/api/__init__.py @@ -3,7 +3,7 @@ import os from flask import Flask, Blueprint, session from flask_restful import Api -from flask_httpauth import HTTPBasicAuth +from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth from flask import current_app as app api_bp = Blueprint("api_bp", __name__, template_folder="templates", url_prefix="/api") @@ -12,5 +12,7 @@ auth = HTTPBasicAuth() +token_auth = HTTPTokenAuth() + from . import views from .rest import api diff --git a/src/cryptoadvance/specter/api/rest/api.py b/src/cryptoadvance/specter/api/rest/api.py index e91bfcac1b..dd8aa8deea 100644 --- a/src/cryptoadvance/specter/api/rest/api.py +++ b/src/cryptoadvance/specter/api/rest/api.py @@ -15,7 +15,8 @@ from ...util.fee_estimation import get_fees -from .. import auth +from .. import token_auth +from .resource_jwt import JWTResource, JWTResourceById from .resource_healthz import ResourceLiveness, ResourceReadyness from .resource_psbt import ResourcePsbt from .resource_specter import ResourceSpecter @@ -30,7 +31,7 @@ class ResourceWallet(SecureResource): endpoints = ["/v1alpha/wallets//"] def get(self, wallet_alias): - user = auth.current_user() + user = token_auth.current_user() try: wallet: Wallet = app.specter.user_manager.get_user( user diff --git a/src/cryptoadvance/specter/api/rest/base.py b/src/cryptoadvance/specter/api/rest/base.py index da421ae8e5..68c769c10b 100644 --- a/src/cryptoadvance/specter/api/rest/base.py +++ b/src/cryptoadvance/specter/api/rest/base.py @@ -7,7 +7,7 @@ from cryptoadvance.specter.api import api_rest from cryptoadvance.specter.api.security import require_admin -from cryptoadvance.specter.api import auth +from cryptoadvance.specter.api import auth, token_auth from cryptoadvance.specter.specter_error import SpecterError logger = logging.getLogger(__name__) @@ -72,13 +72,19 @@ def delete(self, *args, **kwargs): class SecureResource(BaseResource): """A REST-resource which makes sure that the user is Authenticated""" + method_decorators = [error_handling, token_auth.login_required] + + +class BasicAuthResource(BaseResource): + """A REST-resource which makes sure that the user is Authenticated""" + method_decorators = [error_handling, auth.login_required] class AdminResource(BaseResource): """A REST-resource which makes sure that the user is an admin""" - method_decorators = [error_handling, require_admin, auth.login_required] + method_decorators = [error_handling, require_admin, token_auth.login_required] def rest_resource(resource_cls): diff --git a/src/cryptoadvance/specter/api/rest/resource_jwt.py b/src/cryptoadvance/specter/api/rest/resource_jwt.py new file mode 100644 index 0000000000..c41b21e663 --- /dev/null +++ b/src/cryptoadvance/specter/api/rest/resource_jwt.py @@ -0,0 +1,135 @@ +import jwt +from flask import current_app as app +from flask_restful import Api, abort, reqparse +from cryptoadvance.specter.api.rest.base import ( + BaseResource, + BasicAuthResource, + SecureResource, + rest_resource, + AdminResource, +) +import uuid +import datetime +import logging +from ...user import * +from .base import * +from pytimeparse import parse + +from .. import auth + +logger = logging.getLogger(__name__) + +# Initialize the parser +parser = reqparse.RequestParser() +parser.add_argument( + "jwt_token_description", type=str, help="JWT token description", required=True +) +parser.add_argument("jwt_token_life", type=str, help="JWT token life", required=True) + + +@rest_resource +class JWTResource(BasicAuthResource): + """ + A Resource to manage JWT tokens in order to authenticate against the REST-API + Other then the other Resources, this endpoint uses BasicAuth to avoid the chicken egg problem + This one is only to create a token. The other Resource for getting and deleting. + This violates the REST principles but only on the implementation-side. Happy for improvements here. + """ + + endpoints = ["/v1alpha/token/"] + + def get(self): + # An endpoint to get all JWT tokens' information created by the user + user = auth.current_user() + user_details = app.specter.user_manager.get_user(user) + jwt_tokens = user_details.get_all_jwt_tokens_info() + if len(jwt_tokens) == 0: + return {"message": "Tokens does not exist"}, 404 + return {"message": "Tokens exist", "jwt_tokens": jwt_tokens}, 200 + + def post(self): + # An endpoint to create a JWT token + user = auth.current_user() + data = parser.parse_args() + user_details = app.specter.user_manager.get_user(user) + jwt_token_id = user_details.generate_token_id() + jwt_token_description = data["jwt_token_description"] + + # pytimeparse has been used to parse different time units to seconds + # For eg: "jwt_token_life_unit": "1 hour" will be parsed to 3600 seconds + # For more information visit: https://pypi.org/project/pytimeparse/ + jwt_token_life = parse(data["jwt_token_life"]) + jwt_token = user_details.generate_jwt_token( + user_details.username, + jwt_token_id, + jwt_token_description, + jwt_token_life, + ) + if user_details.validate_jwt_token_description(jwt_token_description): + user_details.add_jwt_token( + jwt_token_id, + jwt_token, + jwt_token_description, + jwt_token_life, + ) + return { + "message": "Token generated", + "jwt_token_id": jwt_token_id, + "jwt_token": jwt_token, + "jwt_token_description": jwt_token_description, + "jwt_token_life": jwt_token_life, + }, 201 + else: + return {"message": "Token description already exists or is blank"}, 400 + + +@rest_resource +class JWTResourceById(BasicAuthResource): + """ + A Resource to manage individual JWT token + """ + + endpoints = ["/v1alpha/token//"] + + def get(self, jwt_token_id): + # An endpoint to get a JWT token by id + user = auth.current_user() + user_details = app.specter.user_manager.get_user(user) + jwt_tokens = user_details.jwt_tokens + jwt_token = user_details.get_jwt_token(jwt_token_id) + jwt_token_life_remaining = user_details.jwt_token_life_remaining(jwt_token_id) + expiry_status = f"Valid" + if jwt_token_life_remaining == 0: + expiry_status = f"Expired" + if ( + not user_details.verify_jwt_token_id_and_jwt_token(jwt_token_id, jwt_token) + and jwt_tokens[jwt_token_id] is None + ): + return { + "message": "Token does not exist, make sure to enter correct token id" + }, 404 + return { + "message": "Token exists", + "jwt_token_description": jwt_token["jwt_token_description"], + "jwt_token_life": jwt_token["jwt_token_life"], + "jwt_token_life_remaining": jwt_token_life_remaining, + "expiry_status": expiry_status, + }, 200 + + def delete(self, jwt_token_id): + # An endpoint to delete a JWT token by id + user = auth.current_user() + user_details = app.specter.user_manager.get_user(user) + jwt_tokens = user_details.jwt_tokens + jwt_token = user_details.get_jwt_token(jwt_token_id) + + if ( + not user_details.verify_jwt_token_id_and_jwt_token(jwt_token_id, jwt_token) + and jwt_tokens[jwt_token_id] is None + ): + return { + "message": "Token does not exist, make sure to enter correct token id" + }, 404 + + user_details.delete_jwt_token(jwt_token_id) + return {"message": "Token deleted"}, 200 diff --git a/src/cryptoadvance/specter/api/rest/resource_psbt.py b/src/cryptoadvance/specter/api/rest/resource_psbt.py index 0a6974a95c..0f5e393983 100644 --- a/src/cryptoadvance/specter/api/rest/resource_psbt.py +++ b/src/cryptoadvance/specter/api/rest/resource_psbt.py @@ -11,7 +11,7 @@ from ...wallet import Wallet from ...commands.psbt_creator import PsbtCreator from ...specter_error import SpecterError -from .. import auth +from .. import token_auth logger = logging.getLogger(__name__) @@ -23,12 +23,16 @@ class ResourcePsbt(SecureResource): endpoints = ["/v1alpha/wallets//psbt"] def get(self, wallet_alias): - user = auth.current_user() + user = token_auth.current_user() wallet_manager = app.specter.user_manager.get_user(user).wallet_manager # Check that the wallet belongs to the user from Basic Auth try: wallet = wallet_manager.get_by_alias(wallet_alias) - except SpecterError: + except SpecterError as se: + # ToDo: Be more specific here. How do we know that this SpecterError is a fit to that message? + logger.warning( + f"User user {user} denied access to {wallet_alias} because of {se}" + ) error_message = dumps( {"message": "The wallet does not belong to the user in the request."} ) @@ -37,7 +41,7 @@ def get(self, wallet_alias): return {"result": pending_psbts or {}} def post(self, wallet_alias): - user = auth.current_user() + user = token_auth.current_user() wallet: Wallet = app.specter.user_manager.get_user( user ).wallet_manager.get_by_alias(wallet_alias) diff --git a/src/cryptoadvance/specter/api/rest/resource_txlist.py b/src/cryptoadvance/specter/api/rest/resource_txlist.py index 81a76a360f..4b38c891db 100644 --- a/src/cryptoadvance/specter/api/rest/resource_txlist.py +++ b/src/cryptoadvance/specter/api/rest/resource_txlist.py @@ -4,7 +4,7 @@ from cryptoadvance.specter.api.rest.base import AdminResource, rest_resource from flask import current_app as app -from .. import auth +from .. import token_auth logger = logging.getLogger(__name__) @@ -16,7 +16,7 @@ class ResourceTXlist(AdminResource): endpoints = ["/v1alpha/specter/full_txlist/"] def get(self): - user = auth.current_user() + user = token_auth.current_user() wallet_manager = app.specter.user_manager.get_user(user).wallet_manager validate_merkle_proofs = app.specter.config.get("validate_merkle_proofs") idx = 0 diff --git a/src/cryptoadvance/specter/api/security.py b/src/cryptoadvance/specter/api/security.py index d7626fca3f..ea03e1c00d 100644 --- a/src/cryptoadvance/specter/api/security.py +++ b/src/cryptoadvance/specter/api/security.py @@ -1,20 +1,18 @@ """ Security Related things for the REST-API """ -import logging +import logging, jwt from functools import wraps from cryptoadvance.specter.user import User, verify_password as user_verify_password from flask import current_app as app from flask import g from flask_restful import abort - -# from flask_httpauth import HTTPBasicAuth - +from . import auth, token_auth +from ..user import * from . import auth +from flask import current_app as app logger = logging.getLogger(__name__) -# auth = HTTPBasicAuth() - @auth.verify_password def verify_password(username, password): @@ -35,6 +33,29 @@ def verify_password(username, password): return g.user is not None and verify_password(g.user.password_hash, password) +@token_auth.verify_token +def verify_token(jwt_token): + """Validate JWT token and store user in the 'g' object""" + if not jwt_token: + return abort(401) + try: + payload = jwt.decode(jwt_token, app.config["SECRET_KEY"], algorithms=["HS256"]) + username = payload["username"] + the_user = app.specter.user_manager.get_user_by_username(username) + if not the_user: + return abort(401) + g.user = app.specter.user_manager.get_user_by_username(username) + logger.info({"payload": payload}) + logger.info(f"Rest-Request for user {username} PASSED JWT-test") + return username + except jwt.ExpiredSignatureError: + logger.info(f"Token expired. Please create a new one") + return abort(401) + except jwt.InvalidTokenError: + logger.info(f"Invalid token. Please create a new one") + return abort(401) + + def require_admin(func): """User needs Admin-rights method decorator""" diff --git a/src/cryptoadvance/specter/user.py b/src/cryptoadvance/specter/user.py index 656a715cb5..59159e1b29 100644 --- a/src/cryptoadvance/specter/user.py +++ b/src/cryptoadvance/specter/user.py @@ -1,17 +1,23 @@ import base64 import binascii +from datetime import date, datetime import cryptography import hashlib import json import logging import os import shutil +import time +import jwt +import datetime +import uuid from cryptography.fernet import Fernet from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from flask_login import UserMixin +from flask import current_app as app from .specter_error import SpecterError, handle_exception from .persistence import read_json_file, write_json_file, delete_folder @@ -70,6 +76,7 @@ def __init__( password_hash, config, specter, + jwt_tokens={}, encrypted_user_secret=None, is_admin=False, services=[], @@ -77,6 +84,7 @@ def __init__( self.id = id self.username = username self.password_hash = password_hash + self.jwt_tokens = jwt_tokens self.config = config self.encrypted_user_secret = encrypted_user_secret self.plaintext_user_secret = None @@ -98,6 +106,7 @@ def from_json(cls, user_dict, specter): user_args = { "id": user_dict["id"], "username": user_dict["username"], + "jwt_tokens": user_dict.get("jwt_tokens", {}), "password_hash": user_dict[ "password" ], # TODO: Migrate attr name to "password_hash"? @@ -242,6 +251,7 @@ def json(self): "username": self.username, "password": self.password_hash, # TODO: Migrate attr name to "password_hash"? "is_admin": self.is_admin, + "jwt_tokens": self.jwt_tokens, "encrypted_user_secret": self.encrypted_user_secret, "services": self.services, } @@ -415,6 +425,112 @@ def delete(self): # we delete wallet manager and device manager in save_info self.save_info(delete=True) + def add_jwt_token( + self, jwt_token_id, jwt_token, jwt_token_description, jwt_token_life + ): + # Adding a newly created JWT to the hashmap + self.jwt_tokens[jwt_token_id] = {} + self.jwt_tokens[jwt_token_id]["jwt_token"] = jwt_token + self.jwt_tokens[jwt_token_id]["jwt_token_description"] = jwt_token_description + self.jwt_tokens[jwt_token_id]["jwt_token_life"] = jwt_token_life + self.save_info() + + def delete_jwt_token(self, jwt_token_id): + # Deleting a JWT from the hashmap + if jwt_token_id in self.jwt_tokens: + del self.jwt_tokens[jwt_token_id] + self.save_info() + + def get_all_jwt_tokens_info(self): + # Getting all the JWT token IDs, descriptions and expiry times from the hashmap + return { + jwt_token_id: { + "jwt_token_description": self.jwt_tokens[jwt_token_id][ + "jwt_token_description" + ], + "jwt_token_life": self.jwt_tokens[jwt_token_id]["jwt_token_life"], + "jwt_token_remaining_life": self.jwt_token_life_remaining(jwt_token_id), + } + for jwt_token_id in self.jwt_tokens + } + + def verify_jwt_token_id_and_jwt_token(self, jwt_token_id, jwt_token): + # Verifying the JWT token ID and JWT token + if jwt_token_id in self.jwt_tokens: + if self.jwt_tokens[jwt_token_id]["jwt_token"] == jwt_token: + return True + return False + + def get_jwt_token(self, jwt_token_id): + # Getting a JWT token from the hashmap by ID + if jwt_token_id in self.jwt_tokens: + return { + "jwt_token_description": self.jwt_tokens[jwt_token_id][ + "jwt_token_description" + ], + "jwt_token_life": self.jwt_tokens[jwt_token_id]["jwt_token_life"], + } + return None + + def get_jwt_token_by_token_id(self, jwt_token_id): + # Getting a JWT token from the hashmap by ID + if jwt_token_id in self.jwt_tokens: + return self.jwt_tokens[jwt_token_id]["jwt_token"] + return None + + def get_jwt_token_life_by_token_id(self, jwt_token_id): + # Getting a JWT token life from the hashmap by ID + if jwt_token_id in self.jwt_tokens: + return self.jwt_tokens[jwt_token_id]["jwt_token_life"] + return None + + def validate_jwt_token_description(self, jwt_token_description): + # Checking if the JWT token description is unique and not null + return ( + jwt_token_description + not in [ + self.jwt_tokens[jwt_token_id]["jwt_token_description"] + for jwt_token_id in self.jwt_tokens + ] + and jwt_token_description != "" + ) + + def jwt_token_life_remaining(self, jwt_token_id): + # Calculates the remaining life of a JWT token + jwt_token = self.get_jwt_token_by_token_id(jwt_token_id) + payload = jwt.decode( + jwt_token, + app.config["SECRET_KEY"], + algorithms=["HS256"], + options={"verify_signature": False}, + ) + + if (payload["exp"] - time.time()) > 0: + return payload["exp"] - time.time() + return 0 + + @staticmethod + def generate_jwt_token( + username, jwt_token_id, jwt_token_description, jwt_token_life + ): + # Generates a JWT token for the user + + # payload which will be encoded in the JWT token + payload = { + "username": username, + "jwt_token_id": jwt_token_id, + "jwt_token_description": jwt_token_description, + "exp": datetime.datetime.utcnow() + + datetime.timedelta(seconds=jwt_token_life), + "iat": datetime.datetime.utcnow(), + } + return jwt.encode(payload, app.config["SECRET_KEY"], algorithm="HS256") + + @staticmethod + def generate_token_id(): + # Generates a unique token id + return str(uuid.uuid4()) + def __eq__(self, other): if other == None: return False diff --git a/tests/test_jwt.py b/tests/test_jwt.py new file mode 100644 index 0000000000..d21f6f1eba --- /dev/null +++ b/tests/test_jwt.py @@ -0,0 +1,145 @@ +import base64 +from importlib.abc import ResourceLoader +import jwt, logging +import uuid +import datetime + +# test for jwt enpoints +from cryptoadvance.specter.specter import Specter +from cryptoadvance.specter.user import * + + +def test_token_endpoints(client, empty_data_folder, caplog): + specter = Specter(data_folder=empty_data_folder) + user = User.from_json( + user_dict={ + "id": "someuser", + "username": "someuser", + "password": hash_password("somepassword"), + "config": {}, + "is_admin": False, + "services": None, + "jwt_tokens": {}, + }, + specter=specter, + ) + caplog.set_level(logging.DEBUG) + + # unauthorized + # username and password not entered + headers = { + "Authorization": "Basic " + "", + "Content-type": "application/json", + } + + # user shouldn't be able to acces the endpoints + response = client.get("/api/v1alpha/token", follow_redirects=True, headers=headers) + assert response.status_code == 401 + assert json.loads(response.data)["message"].startswith( + "The server could not verify that you are authorized to access the URL requested." + ) + + response = client.post( + "/api/v1alpha/token", + data="""{"jwt_token_description": "somedescription", "jwt_token_life": "6 minutes"}""", + follow_redirects=True, + headers=headers, + ) + assert response.status_code == 401 + assert json.loads(response.data)["message"].startswith( + "The server could not verify that you are authorized to access the URL requested." + ) + + # authorized + headers = { + "Authorization": "Basic " + + base64.b64encode(bytes("someuser" + ":" + "somepassword", "ascii")).decode( + "ascii" + ), + "Content-type": "application/json", + } + + # if token is not created throw an error + response = client.get("/api/v1alpha/token", follow_redirects=True, headers=headers) + assert response.status_code == 404 + data = json.loads(response.data) + assert json.loads(response.data)["message"] == "Tokens does not exist" + + # user creates a token first + response = client.post( + "/api/v1alpha/token", + data="""{"jwt_token_description": "somedescription", "jwt_token_life": "6 minutes"}""", + follow_redirects=True, + headers=headers, + ) + assert response.status_code == 201 + data = json.loads(response.data) + print(data) + assert data["message"] == "Token generated" + assert data["jwt_token_id"] + assert data["jwt_token"] + assert data["jwt_token_description"] == "somedescription" + assert data["jwt_token_life"] == 360 + + jwt_token_id = data["jwt_token_id"] + + # testing GET request + response = client.get("/api/v1alpha/token", follow_redirects=True, headers=headers) + assert response.status_code == 200 + data = json.loads(response.data) + assert json.loads(response.data)["message"] == "Tokens exist" + assert data + + # unauthorized + # username and password not entered + headers = { + "Authorization": "Basic " + "", + "Content-type": "application/json", + } + + # user shouldn't be able to acces the endpoints + response = client.get( + "/api/v1alpha/token/some_token_id", follow_redirects=True, headers=headers + ) + assert response.status_code == 401 + assert json.loads(response.data)["message"].startswith( + "The server could not verify that you are authorized to access the URL requested." + ) + + # authorized + headers = { + "Authorization": "Basic " + + base64.b64encode(bytes("someuser" + ":" + "somepassword", "ascii")).decode( + "ascii" + ), + "Content-type": "application/json", + } + + # testing GET request to fetch a token by token_id + response = client.get( + "/api/v1alpha/token/" + jwt_token_id, follow_redirects=True, headers=headers + ) + assert response.status_code == 200 + data = json.loads(response.data) + assert data["message"] == "Token exists" + assert data["jwt_token_description"] == "somedescription" + assert data["jwt_token_life"] == 360 + + # testing DELETE request to delete a token by token_id + response = client.delete( + "/api/v1alpha/token/" + jwt_token_id, follow_redirects=True, headers=headers + ) + assert response.status_code == 200 + data = json.loads(response.data) + assert data["message"] == "Token deleted" + + # retry accessing a deleted token + response = client.get( + "/api/v1alpha/token/" + jwt_token_id, follow_redirects=True, headers=headers + ) + assert response.status_code == 500 + data = json.loads(response.data) + assert ( + data["message"] + == "Can't tell you the reason of the issue. Please check the logs" + ) diff --git a/tests/test_rest.py b/tests/test_rest.py index f241448eb2..45e6a4e3ae 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -2,13 +2,17 @@ import json import logging from numbers import Number - +import jwt import pytest +import datetime +import random + +from flask import current_app as app from cryptoadvance.specter.managers.device_manager import DeviceManager from cryptoadvance.specter.specter import Specter from cryptoadvance.specter.specter_error import SpecterError from cryptoadvance.specter.util.wallet_importer import WalletImporter - +from cryptoadvance.specter.user import User from fix_devices_and_wallets import create_hot_wallet_with_ID logger = logging.getLogger(__name__) @@ -36,13 +40,8 @@ def test_rr_psbt_get(client, specter_regtest_configured, bitcoin_regtest, caplog "The server could not verify that you are authorized to access the URL requested." ) - # Wrong password - headers = { - "Authorization": "Basic " - + base64.b64encode(bytes("admin" + ":" + "wrongPassword", "ascii")).decode( - "ascii" - ) - } + # Wrong token + headers = {"Authorization": "Bearer " + "sometoken"} result = client.get( "/api/v1alpha/wallets/a_simple_wallet/psbt", follow_redirects=True, @@ -55,8 +54,10 @@ def test_rr_psbt_get(client, specter_regtest_configured, bitcoin_regtest, caplog # Admin but not authorized (admin is NOT allowed to read everything) headers = { - "Authorization": "Basic " - + base64.b64encode(bytes("admin" + ":" + "admin", "ascii")).decode("ascii") + "Authorization": "Bearer " + + User.generate_jwt_token( + "admin", "tokenid", "tokendescription", random.randrange(100, 200) + ) } result = client.get( "/api/v1alpha/wallets/a_simple_wallet/psbt", @@ -71,9 +72,9 @@ def test_rr_psbt_get(client, specter_regtest_configured, bitcoin_regtest, caplog # Proper authorized (the wallet is owned by someuser) headers = { - "Authorization": "Basic " - + base64.b64encode(bytes("someuser" + ":" + "somepassword", "ascii")).decode( - "ascii" + "Authorization": "Bearer " + + User.generate_jwt_token( + "someuser", "tokenid", "tokendescription", random.randrange(100, 200) ) } result = client.get( @@ -92,9 +93,9 @@ def test_rr_psbt_post(specter_regtest_configured, bitcoin_regtest, client, caplo """ testing the registration """ headers = { - "Authorization": "Basic " - + base64.b64encode(bytes("someuser" + ":" + "somepassword", "ascii")).decode( - "ascii" + "Authorization": "Bearer " + + User.generate_jwt_token( + "someuser", "tokenid", "tokendescription", random.randrange(100, 200) ), "Content-type": "application/json", } @@ -162,8 +163,16 @@ def test_rr_psbt_post(specter_regtest_configured, bitcoin_regtest, client, caplo def create_a_simple_wallet(specter: Specter, bitcoin_regtest): - """ToDo: Could potentially do this with a a fixture but this is only relevant for this file only""" - someuser = specter.user_manager.get_user_by_username("someuser") + """ToDo: Could potentially do this with a fixture but this is only relevant for this file only""" + payload = jwt.decode( + User.generate_jwt_token( + "someuser", "tokenid", "tokendescription", random.randrange(100, 200) + ), + app.config["SECRET_KEY"], + algorithms=["HS256"], + ) + username = payload["username"] + someuser = specter.user_manager.get_user_by_username(username) assert not someuser.wallet_manager.working_folder is None # Create a Wallet wallet_json = '{"label": "a_simple_wallet", "blockheight": 0, "descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr", "devices": [{"type": "trezor", "label": "trezor"}]} '