fix: validate active API tokens (#2685)

This commit is contained in:
k9ert 2026-08-08 14:57:14 +02:00 committed by GitHub
parent 0b027f49fd
commit 48fea77b8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 137 additions and 17 deletions

4
.gitignore vendored
View file

@ -59,3 +59,7 @@ _bmad-output/
# Claude Code local workspace
.claude/
# Local agent workspaces
/.agents/
/.history/

View file

@ -40,11 +40,21 @@ def verify_token(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:
username = payload.get("username")
jwt_token_id = payload.get("jwt_token_id")
if (
not isinstance(username, str)
or not username
or not isinstance(jwt_token_id, str)
or not jwt_token_id
):
return abort(401)
g.user = app.specter.user_manager.get_user_by_username(username)
the_user = app.specter.user_manager.get_user_by_username(username)
if not the_user or not the_user.verify_jwt_token_id_and_jwt_token(
jwt_token_id, jwt_token
):
return abort(401)
g.user = the_user
logger.info({"payload": payload})
logger.info(f"Rest-Request for user {username} PASSED JWT-test")
return username

View file

@ -463,10 +463,10 @@ class User(UserMixin):
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
if not isinstance(self.jwt_tokens, dict) or not isinstance(jwt_token_id, str):
return False
token_info = self.jwt_tokens.get(jwt_token_id)
return isinstance(token_info, dict) and token_info.get("jwt_token") == jwt_token
def get_jwt_token(self, jwt_token_id):
# Getting a JWT token from the hashmap by ID

View file

@ -82,6 +82,99 @@ def test_token_endpoints(client, empty_data_folder, caplog):
assert data["jwt_token_life"] == 360
jwt_token_id = data["jwt_token_id"]
jwt_token = data["jwt_token"]
# API-created tokens remain registered after the user store is reloaded.
client.application.specter.user_manager.update()
# An active, registered token authenticates successfully. The missing wallet
# is rejected by authorization after authentication has completed.
token_headers = {"Authorization": "Bearer " + jwt_token}
response = client.get(
"/api/v1alpha/wallets/missing/psbt",
follow_redirects=True,
headers=token_headers,
)
assert response.status_code == 403
# A signed token must still be registered in the user's active token store.
unregistered_token = User.generate_jwt_token(
"someuser", User.generate_token_id(), "unregistered", 360
)
response = client.get(
"/api/v1alpha/wallets/missing/psbt",
follow_redirects=True,
headers={"Authorization": "Bearer " + unregistered_token},
)
assert response.status_code == 401
# Missing and non-string token identifiers fail closed.
expiry = datetime.datetime.utcnow() + datetime.timedelta(seconds=360)
invalid_payloads = [
{"username": "someuser", "exp": expiry},
{"username": "someuser", "jwt_token_id": ["invalid"], "exp": expiry},
]
for invalid_payload in invalid_payloads:
invalid_token = jwt.encode(
invalid_payload,
client.application.config["SECRET_KEY"],
algorithm="HS256",
)
response = client.get(
"/api/v1alpha/wallets/missing/psbt",
follow_redirects=True,
headers={"Authorization": "Bearer " + invalid_token},
)
assert response.status_code == 401
# A different signed token cannot borrow an active token's identifier.
mismatched_token = User.generate_jwt_token(
"someuser", jwt_token_id, "mismatched", 360
)
response = client.get(
"/api/v1alpha/wallets/missing/psbt",
follow_redirects=True,
headers={"Authorization": "Bearer " + mismatched_token},
)
assert response.status_code == 401
# Malformed persisted records fail closed instead of raising an error.
user_details = client.application.specter.user_manager.get_user_by_username(
"someuser"
)
stored_token_info = user_details.jwt_tokens[jwt_token_id]
user_details.jwt_tokens[jwt_token_id] = {}
user_details.save_info()
client.application.specter.user_manager.update()
response = client.get(
"/api/v1alpha/wallets/missing/psbt",
follow_redirects=True,
headers=token_headers,
)
assert response.status_code == 401
user_details = client.application.specter.user_manager.get_user_by_username(
"someuser"
)
user_details.jwt_tokens[jwt_token_id] = stored_token_info
user_details.save_info()
# A malformed persisted token container also fails closed after reload.
stored_tokens = user_details.jwt_tokens
user_details.jwt_tokens = []
user_details.save_info()
client.application.specter.user_manager.update()
response = client.get(
"/api/v1alpha/wallets/missing/psbt",
follow_redirects=True,
headers=token_headers,
)
assert response.status_code == 401
user_details = client.application.specter.user_manager.get_user_by_username(
"someuser"
)
user_details.jwt_tokens = stored_tokens
user_details.save_info()
client.application.specter.user_manager.update()
# testing GET request
response = client.get("/api/v1alpha/token", follow_redirects=True, headers=headers)
@ -133,6 +226,14 @@ def test_token_endpoints(client, empty_data_folder, caplog):
data = json.loads(response.data)
assert data["message"] == "Token deleted"
# Deletion immediately revokes the bearer token.
response = client.get(
"/api/v1alpha/wallets/missing/psbt",
follow_redirects=True,
headers=token_headers,
)
assert response.status_code == 401
# retry accessing a deleted token
response = client.get(
"/api/v1alpha/token/" + jwt_token_id, follow_redirects=True, headers=headers

View file

@ -17,6 +17,17 @@ from cryptoadvance.specter.user import User
logger = logging.getLogger(__name__)
def create_registered_jwt_token(specter, username):
user = specter.user_manager.get_user_by_username(username)
jwt_token_id = User.generate_token_id()
jwt_token_life = 3600
jwt_token = User.generate_jwt_token(
username, jwt_token_id, "test token", jwt_token_life
)
user.add_jwt_token(jwt_token_id, jwt_token, "test token", jwt_token_life)
return jwt_token
def almost_equal(a: Number, b: Number, precision: float = 0.01) -> bool:
"""
Checks if a and b are not very different.
@ -58,9 +69,7 @@ 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": "Bearer "
+ User.generate_jwt_token(
"admin", "tokenid", "tokendescription", random.randrange(100, 200)
)
+ create_registered_jwt_token(specter_regtest_configured, "admin")
}
result = client.get(
"/api/v1alpha/wallets/a_simple_wallet/psbt",
@ -76,9 +85,7 @@ def test_rr_psbt_get(client, specter_regtest_configured, bitcoin_regtest, caplog
# Proper authorized (the wallet is owned by someuser)
headers = {
"Authorization": "Bearer "
+ User.generate_jwt_token(
"someuser", "tokenid", "tokendescription", random.randrange(100, 200)
)
+ create_registered_jwt_token(specter_regtest_configured, "someuser")
}
result = client.get(
"/api/v1alpha/wallets/a_simple_wallet/psbt",
@ -97,9 +104,7 @@ def test_rr_psbt_post(specter_regtest_configured, bitcoin_regtest, client, caplo
headers = {
"Authorization": "Bearer "
+ User.generate_jwt_token(
"someuser", "tokenid", "tokendescription", random.randrange(100, 200)
),
+ create_registered_jwt_token(specter_regtest_configured, "someuser"),
"Content-type": "application/json",
}