Browse Source

Merge Joinmarket-Org/joinmarket-clientserver#1480: Decode JWT token for validation

b2822ddb95 Implement wallet RPC's JWT token authority (roshii)

Pull request description:

  Implement `jmclient.auth` module to manage JWT.

  Upon successful authentication (e.g. unlock wallet), response includes both a `token` and a `refresh_token`. The former can be used for call authentication, valid for 30 min. After expiration, user can call `/token/refresh` endpoint with his expired access token in header and refresh token in POST call payload to get both a new access and refresh token. Refresh token is valid for 4 hours.

  Anytime a new access token is issued, refresh token signature key is re-initialized, invalidating any previously issued token.
  Tokens are scoped to a specific `wallet_name` and a generic `walletrpc` category, and should allow future upgrades such as authorization granularity.

  Fixes https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1297

Top commit has no ACKs.

Tree-SHA512: 44dd4338ceace04f838e92dae35b0df16b7d01d92599af57eb2652a9dcea0a41663128eec88f3e76efa0b6729a4b202122760b9ee0920863c128c0ec0ab2c83d
master
Adam Gibson 2 years ago
parent
commit
6e6e68b1ca
No known key found for this signature in database
GPG Key ID: 141001A1AF77F20B
  1. 16
      docs/JSON-RPC-API-using-jmwalletd.md
  2. 172
      docs/api/wallet-rpc.yaml
  3. 100
      jmclient/jmclient/auth.py
  4. 217
      jmclient/jmclient/wallet_rpc.py
  5. 31
      jmclient/jmclient/websocketserver.py
  6. 97
      jmclient/test/test_auth.py
  7. 179
      jmclient/test/test_wallet_rpc.py
  8. 15
      jmclient/test/test_websocket.py

16
docs/JSON-RPC-API-using-jmwalletd.md

@ -16,11 +16,23 @@ Documentation of the websocket functionality [below](#websocket).
This HTTP server does *NOT* currently support multiple sessions; it is intended as a manager/daemon for all the Joinmarket services for a single user. Note that in particular it allows only control of *one wallet at a time*.
#### Rules about making requests
### Rules about making requests
Note that for some methods, it's particularly important to deal with the HTTP response asynchronously, since it can take some time for wallet synchronization, service startup etc. to occur; in these cases a HTTP return code of 202 is sent.
#### Authentication
Authentication is with the [JSON Web Token](https://jwt.io/) scheme, provided using the Python package [PyJWT](https://pypi.org/project/PyJWT/).
Note that for some methods, it's particularly important to deal with the HTTP response asynchronously, since it can take some time for wallet synchronization, service startup etc. to occur; in these cases a HTTP return code of 202 is sent.
On initially creating, unlocking or recovering a wallet, a new access and refresh token will be sent in response, the former is valid for only 30 minutes and must be used for any authenticated call, the former is valid for 4 hours and can be used to request a new access token, ideally before access token expiration to avoid unauthorized response from authenticated endpoint and in any case, before refresh token expiration.
Tokens are signed with HS256 (HMAC with SHA-256), a symmetric keyed hashing algorithm that uses one secret key. Signature keys (differentiated between access and refresh tokens) are generated from random bytes upon the following events, implying that any previously issued token is invalidated.
- starting Joinmarket wallet deamon
- creating, unlocking or recovering a wallet if RPC API is already serving another wallet
- locking wallet
On top of these events, refresh token signing key is also re-generated when refreshing an access token.
### API documentation

172
docs/api/wallet-rpc.yaml

@ -10,6 +10,30 @@ servers:
- url: https://none
description: This API is called locally to a jmwalletd instance, acting as server, for each wallet owner, it is not public.
paths:
/token:
post:
security:
- bearerAuth: []
summary: The token endpoint is used by the client to obtain an access token using a grant such as refresh token
operationId: token
description: >
Give a refresh token and get back both an access and refresh token.
On initially creating, unlocking or recovering a wallet, store both the refresh and access tokens, the latter is valid for only 30 minutes (must be used for any authenticated call) while the former is for 4 hours (can only be used in the refresh request parameters). Use /token endpoint on a regular basis to get new access and refresh tokens, ideally before access token expiration to avoid AuthenticationError response from authenticated endpoint and in any case, before refresh token expiration. The newly issued tokens must be used in subsequent calls since operation invalidates previously issued tokens.
responses:
'200':
$ref: '#/components/responses/RefreshToken-200-OK'
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TokenRequest'
description: token refresh parameters
/wallet/create:
post:
summary: create a new wallet
@ -99,7 +123,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
/wallet/{walletname}/display:
get:
security:
@ -120,7 +146,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
/session:
@ -135,7 +163,9 @@ paths:
'200':
$ref: "#/components/responses/Session-200-OK"
'401':
$ref: '#/components/responses/401-Unauthorized'
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
/getinfo:
@ -162,11 +192,11 @@ paths:
get:
summary: get latest report on yield generating activity
operationId: yieldgenreport
description: "Get list of coinjoins taken part in as maker (across all wallets).
Data returned as list of strings, each one in the same comma separated format as found in yigen-statement.csv.
Note that this returns all lines in the file, including the lines that are only present to represent the starting
of a bot. Those lines contain the word Connected and can be thus discarded.
The header line is also delivered and so can be ignored as per the client requirements."
description: >
Get list of coinjoins taken part in as maker (across all wallets).
Data returned as list of strings, each one in the same comma separated format as found in yigen-statement.csv.
Note that this returns all lines in the file, including the lines that are only present to represent the starting of a bot. Those lines contain the word Connected and can be thus discarded.
The header line is also delivered and so can be ignored as per the client requirements.
responses:
'200':
$ref: "#/components/responses/YieldGenReport-200-OK"
@ -198,11 +228,15 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/rescanblockchain/{blockheight}:
get:
security:
- bearerAuth: []
summary: Rescan the blockchain from a given blockheight
operationId: rescanblockchain
description: Use this operation on recovered wallets to re-sync the wallet
@ -225,7 +259,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/address/timelock/new/{lockdate}:
@ -256,7 +292,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/utxos:
@ -280,7 +318,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/taker/direct-send:
@ -309,7 +349,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
'409':
@ -343,6 +385,10 @@ paths:
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'401':
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
'409':
@ -370,6 +416,10 @@ paths:
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: "#/components/responses/401-Unauthorized"
'401':
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/taker/coinjoin:
@ -399,6 +449,10 @@ paths:
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'401':
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
'409':
@ -432,6 +486,10 @@ paths:
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'401':
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
'409':
@ -457,7 +515,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: "#/components/responses/401-Unauthorized"
$ref: "#/components/responses/401-AuthenticationError"
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/taker/stop:
@ -481,6 +541,10 @@ paths:
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: "#/components/responses/401-Unauthorized"
'401':
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/configset:
@ -509,7 +573,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
$ref: '#/components/responses/401-AuthenticationError'
'403':
$ref: '#/components/responses/403-Forbidden'
'409':
$ref: '#/components/responses/409-NoConfig'
/wallet/{walletname}/configget:
@ -537,7 +603,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: "#/components/responses/401-Unauthorized"
$ref: "#/components/responses/401-AuthenticationError"
'403':
$ref: '#/components/responses/403-Forbidden'
'409':
$ref: '#/components/responses/409-NoConfig'
/wallet/{walletname}/freeze:
@ -565,6 +633,10 @@ paths:
$ref: "#/components/responses/Freeze-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: "#/components/responses/401-AuthenticationError"
'403':
$ref: '#/components/responses/403-Forbidden'
/wallet/{walletname}/getseed:
get:
security:
@ -590,7 +662,9 @@ paths:
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: "#/components/responses/401-Unauthorized"
$ref: "#/components/responses/401-AuthenticationError"
'403':
$ref: '#/components/responses/403-Forbidden'
components:
securitySchemes:
bearerAuth:
@ -662,6 +736,35 @@ components:
destination:
type: string
example: "bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw"
TokenRequest:
type: object
required:
- grant_type
- refresh_token
properties:
grant_type:
type: string
refresh_token:
type: string
TokenResponse:
type: object
required:
- token
- token_type
- expires_in
- scope
- refresh_token
properties:
token:
type: string
token_type:
type: string
expires_in:
type: int
scope:
type: string
refresh_token:
type: string
RunScheduleRequest:
type: object
required:
@ -919,27 +1022,31 @@ components:
type: string
extradata:
type: string
CreateWalletResponse:
type: object
required:
- walletname
- token
- seedphrase
- token
- refresh_token
properties:
walletname:
type: string
example: wallet.jmdat
seedphrase:
type: string
token:
type: string
format: byte
seedphrase:
refresh_token:
type: string
format: byte
UnlockWalletResponse:
type: object
required:
- walletname
- token
- refresh_token
properties:
walletname:
type: string
@ -947,6 +1054,9 @@ components:
token:
type: string
format: byte
refresh_token:
type: string
format: byte
DirectSendResponse:
type: object
required:
@ -1087,6 +1197,8 @@ components:
properties:
message:
type: string
error_description:
type: string
responses:
# Success responses
@ -1126,6 +1238,12 @@ components:
application/json:
schema:
$ref: "#/components/schemas/ListWalletsResponse"
Token-200-OK:
description: "Access token obtained successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/TokenResponse"
Session-200-OK:
description: "successful heartbeat response"
content:
@ -1219,6 +1337,20 @@ components:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
401-AuthenticationError:
description: Bearer token authentication error.
headers:
WWW-Authenticate:
description: Defines the HTTP authentication methods.
schema:
type: string
403-Forbidden:
description: Bearer token authorization error.
headers:
WWW-Authenticate:
description: Defines the HTTP authentication methods.
schema:
type: string
409-AlreadyExists:
description: Unable to complete request because object already exists.
content:

100
jmclient/jmclient/auth.py

@ -0,0 +1,100 @@
import datetime
import os
import jwt
from jmbase.support import bintohex
class InvalidScopeError(Exception):
pass
class ExpiredSignatureError(jwt.exceptions.ExpiredSignatureError):
pass
def get_random_key(size: int = 16) -> str:
"""Create a random key has an hexadecimal string."""
return bintohex(os.urandom(size))
class JMTokenAuthority:
"""Manage authorization tokens."""
SESSION_VALIDITY = {
"access": datetime.timedelta(minutes=30),
"refresh": datetime.timedelta(hours=4),
}
SIGNATURE_ALGORITHM = "HS256"
def __init__(self, *wallet_names: str):
self.signature_key = {
"access": get_random_key(),
"refresh": get_random_key(),
}
self._scope = {"walletrpc"}
for wallet_name in wallet_names:
self.add_to_scope(wallet_name)
def verify(self, token: str, *, token_type: str = "access"):
"""Verify JWT token.
Token must have a valid signature and its scope must contain both scopes in
arguments and wallet_name property.
"""
try:
claims = jwt.decode(
token,
self.signature_key[token_type],
algorithms=self.SIGNATURE_ALGORITHM,
leeway=10,
)
except jwt.exceptions.ExpiredSignatureError:
raise ExpiredSignatureError
token_claims = set(claims.get("scope", []).split())
if not self._scope <= token_claims:
raise InvalidScopeError
def add_to_scope(self, *args: str):
for arg in args:
self._scope.add(arg)
def discard_from_scope(self, *args: str):
for arg in args:
self._scope.discard(arg)
@property
def scope(self):
return " ".join(self._scope)
def _issue(self, token_type: str) -> str:
return jwt.encode(
{
"exp": datetime.datetime.utcnow() + self.SESSION_VALIDITY[token_type],
"scope": self.scope,
},
self.signature_key[token_type],
algorithm=self.SIGNATURE_ALGORITHM,
)
def issue(self) -> dict:
"""Issue a new access and refresh token.
Previously issued refresh token is invalidated.
"""
self.signature_key["refresh"] = get_random_key()
return {
"token": self._issue("access"),
"token_type": "bearer",
"expires_in": int(self.SESSION_VALIDITY["access"].total_seconds()),
"scope": self.scope,
"refresh_token": self._issue("refresh"),
}
def reset(self):
"""Invalidate all previously issued tokens by creating new signature keys."""
self.signature_key = {
"access": get_random_key(),
"refresh": get_random_key(),
}

217
jmclient/jmclient/wallet_rpc.py

@ -1,5 +1,3 @@
from jmbitcoin import *
import datetime
import os
import json
from io import BytesIO
@ -9,7 +7,6 @@ from twisted.web.server import Site
from twisted.application.service import Service
from autobahn.twisted.websocket import listenWS
from klein import Klein
import jwt
import pprint
from jmbitcoin import human_readable_transaction
@ -26,7 +23,7 @@ from jmclient import Taker, jm_single, \
get_schedule, get_tumbler_parser, schedule_to_text, \
tumbler_filter_orders_callback, tumbler_taker_finished_update, \
validate_address, FidelityBondMixin, BaseWallet, WalletError, \
ScheduleGenerationErrorNoFunds, BIP39WalletMixin
ScheduleGenerationErrorNoFunds, BIP39WalletMixin, auth
from jmbase.support import get_log, utxostr_to_utxo, JM_CORE_VERSION
jlog = get_log()
@ -43,7 +40,16 @@ def print_req(request):
print(request.content)
print(list(request.requestHeaders.getAllRawHeaders()))
class NotAuthorized(Exception):
class AuthorizationError(Exception):
pass
class InvalidCredentials(AuthorizationError):
pass
class InvalidToken(AuthorizationError):
pass
class InsufficientScope(AuthorizationError):
pass
class NoWalletFound(Exception):
@ -124,7 +130,7 @@ def make_jmwalletd_response(request, status=200, **kwargs):
"""
request.setHeader('Content-Type', 'application/json')
request.setHeader('Access-Control-Allow-Origin', '*')
request.setHeader("Cache-Control", "no-cache, must-revalidate")
request.setHeader("Cache-Control", "no-cache, no-store, must-revalidate")
request.setHeader("Pragma", "no-cache")
request.setHeader("Expires", "Sat, 26 Jul 1997 05:00:00 GMT")
request.setResponseCode(status)
@ -134,7 +140,7 @@ CJ_TAKER_RUNNING, CJ_MAKER_RUNNING, CJ_NOT_RUNNING = range(3)
class JMWalletDaemon(Service):
""" This class functions as an HTTP/TLS server,
with acccess control, allowing a single client(user)
with access control, allowing a single client(user)
to control functioning of encapsulated Joinmarket services.
"""
@ -146,7 +152,8 @@ class JMWalletDaemon(Service):
websocket connections for clients to subscribe to updates.
"""
# cookie tracks single user's state.
self.cookie = None
self.token = auth.JMTokenAuthority()
self.active_session = False
self.port = port
self.wss_port = wss_port
self.tls = tls
@ -225,7 +232,7 @@ class JMWalletDaemon(Service):
# wallet service, since the client must actively request
# that with the appropriate credential (password).
# initialise the web socket service for subscriptions
self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url)
self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url, self.token)
self.wss_factory.protocol = JmwalletdWebSocketServerProtocol
if self.tls:
cf = get_ssl_context(os.path.join(jm_single().datadir, "ssl"))
@ -251,11 +258,9 @@ class JMWalletDaemon(Service):
- shuts down any other running sub-services, such as yieldgenerator.
- shuts down (aborts) any taker-side coinjoining happening.
"""
# Currently valid authorization tokens must be removed
# from the daemon:
self.cookie = None
if self.wss_factory:
self.wss_factory.valid_token = None
self.token.reset()
self.active_session = False
self.wss_factory.active_session = False
self.wallet_name = None
# if the wallet-daemon is shut down, all services
# it encapsulates must also be shut down.
@ -274,6 +279,13 @@ class JMWalletDaemon(Service):
self.taker.aborted = True
self.taker_finished(False)
def auth_err(self, request, error, description=None):
request.setHeader("WWW-Authenticate", "Bearer")
request.setHeader("WWW-Authenticate", f'error="{error}"')
if description is not None:
request.setHeader("WWW-Authenticate", f'error_description="{description}"')
return
def err(self, request, message):
""" Return errors in a standard format.
"""
@ -285,11 +297,26 @@ class JMWalletDaemon(Service):
request.setResponseCode(400)
return self.err(request, "Action not allowed")
@app.handle_errors(NotAuthorized)
def not_authorized(self, request, failure):
@app.handle_errors(InvalidCredentials)
def invalid_credentials(self, request, failure):
request.setResponseCode(401)
return self.err(request, "Invalid credentials.")
@app.handle_errors(InvalidToken)
def invalid_token(self, request, failure):
request.setResponseCode(401)
return self.auth_err(request, "invalid_token", str(failure))
@app.handle_errors(InsufficientScope)
def insufficient_scope(self, request, failure):
request.setResponseCode(403)
return self.auth_err(
request,
"insufficient_scope",
"The request requires higher privileges (scopes) than provided by "
"the scopes granted to the client and represented by the access token.",
)
@app.handle_errors(NoWalletFound)
def no_wallet_found(self, request, failure):
request.setResponseCode(404)
@ -361,43 +388,29 @@ class JMWalletDaemon(Service):
request.setResponseCode(404)
return self.err(request, "Yield generator report not available.")
def check_cookie(self, request):
#part after bearer is what we need
def check_cookie(self, request, *, verify_exp: bool = True):
# Token itself is stated after `Bearer ` prefix, it must be removed
access_token = request.getHeader("Authorization")[7:]
try:
auth_header=((request.getHeader('Authorization')))
request_cookie = None
if auth_header is not None:
request_cookie=auth_header[7:]
except Exception:
# deliberately catching anything
raise NotAuthorized()
if request_cookie==None or self.cookie != request_cookie:
jlog.warn("Invalid cookie: " + str(
request_cookie) + ", request rejected.")
raise NotAuthorized()
self.token.verify(access_token)
except auth.InvalidScopeError:
raise InsufficientScope()
except auth.ExpiredSignatureError:
if verify_exp:
raise InvalidToken("The access token provided is expired.")
else:
pass
except Exception as e:
jlog.debug(e)
raise InvalidToken(
"The access token provided is revoked, malformed, or invalid for other reasons."
)
def check_cookie_if_present(self, request):
auth_header = request.getHeader('Authorization')
if auth_header is not None:
self.check_cookie(request)
def set_token(self, wallet_name):
""" This function creates a new JWT token and sets it as our
'cookie' for API and WS. Note this always creates a new fresh token,
there is no option to manually set it, intentionally.
"""
# any random secret is OK, as long as it is not deducible/predictable:
secret_key = bintohex(os.urandom(16))
encoded_token = jwt.encode({"wallet": wallet_name,
"exp" :datetime.datetime.utcnow(
)+datetime.timedelta(minutes=30)},
secret_key)
self.cookie = encoded_token.strip()
# We want to make sure that any websocket clients use the correct
# token. The wss_factory should have been created on JMWalletDaemon
# startup, so any failure to exist here is a logic error:
self.wss_factory.valid_token = self.cookie
def get_POST_body(self, request, required_keys, optional_keys=None):
""" given a request object, retrieve values corresponding
to keys in a dict, assuming they were encoded using JSON.
@ -468,27 +481,31 @@ class JMWalletDaemon(Service):
def dummy_restart_callback(msg):
jlog.warn("Ignoring rescan request from backend wallet service: " + msg)
self.services["wallet"].add_restart_callback(dummy_restart_callback)
self.active_session = True
self.wss_factory.active_session = True
self.wallet_name = wallet_name
# Add wallet_name to token scope
self.token.add_to_scope(wallet_name)
self.services["wallet"].register_callbacks(
[self.wss_factory.sendTxNotification], None)
self.services["wallet"].startService()
# now that the WalletService instance is active and ready to
# respond to requests, we return the status to the client:
# First, prepare authentication for the calling client:
self.set_token(wallet_name)
# return type is different for a newly created OR recovered
# wallet, in this case we use the 'seedphrase' kwarg as trigger:
if('seedphrase' in kwargs):
return make_jmwalletd_response(request,
status=201,
walletname=self.wallet_name,
token=self.cookie,
seedphrase=kwargs.get('seedphrase'))
if "seedphrase" in kwargs:
return make_jmwalletd_response(
request,
status=201,
walletname=self.wallet_name,
seedphrase=kwargs.get("seedphrase"),
**self.token.issue(),
)
else:
return make_jmwalletd_response(request,
walletname=self.wallet_name,
token=self.cookie)
return make_jmwalletd_response(
request, walletname=self.wallet_name, **self.token.issue()
)
def taker_finished(self, res, fromtx=False, waittime=0.0, txdetails=None):
if not self.tumbler_options:
@ -582,6 +599,57 @@ class JMWalletDaemon(Service):
request.setHeader("Access-Control-Allow-Methods", "POST")
with app.subroute(api_version_string) as app:
@app.route('/token', methods=['POST'])
def refresh(self, request):
self.check_cookie(request, verify_exp=False)
def _mkerr(err, description=""):
return make_jmwalletd_response(
request, status=400, message=err, error_description=description
)
try:
assert isinstance(request.content, BytesIO)
grant_type = self.get_POST_body(request, ["grant_type",])["grant_type"]
if grant_type not in {"refresh_token"}:
return _mkerr(
"unsupported_grant_type",
"The authorization grant type is not supported by the authorization server.",
)
token = self.get_POST_body(request, [grant_type])[grant_type]
except:
return _mkerr(
"invalid_request",
"The request is missing a required parameter, "
"includes an unsupported parameter value (other than grant type), "
"repeats a parameter, includes multiple credentials, "
"or is otherwise malformed.",
)
try:
self.token.verify(token, token_type=grant_type.split("_")[0])
return make_jmwalletd_response(
request, walletname=self.wallet_name, **self.token.issue()
)
except auth.ExpiredSignatureError:
return _mkerr(
"invalid_grant",
f"The provided {grant_type} is expired.",
)
except auth.InvalidScopeError:
return _mkerr(
"invalid_scope",
"The requested scope is invalid, unknown, malformed, "
"or exceeds the scope granted by the resource owner.",
)
except auth.ExpiredSignatureError:
return _mkerr(
"invalid_grant",
f"The provided {grant_type} is invalid, revoked, "
"or was issued to another client.",
)
@app.route('/wallet/<string:walletname>/display', methods=['GET'])
def displaywallet(self, request, walletname):
print_req(request)
@ -638,9 +706,6 @@ class JMWalletDaemon(Service):
#this lets caller know if cookie is invalid or outdated
self.check_cookie_if_present(request)
#if no wallet loaded then clear frontend session info
#when no wallet status is false
session = not self.cookie==None
maker_running = self.coinjoin_state == CJ_MAKER_RUNNING
coinjoin_in_process = self.coinjoin_state == CJ_TAKER_RUNNING
@ -676,14 +741,17 @@ class JMWalletDaemon(Service):
else:
wallet_name = "None"
return make_jmwalletd_response(request,session=session,
maker_running=maker_running,
coinjoin_in_process=coinjoin_in_process,
schedule=schedule,
wallet_name=wallet_name,
offer_list=offer_list,
nickname=nickname,
rescanning=rescanning)
return make_jmwalletd_response(
request,
session=self.active_session,
maker_running=maker_running,
coinjoin_in_process=coinjoin_in_process,
schedule=schedule,
wallet_name=wallet_name,
offer_list=offer_list,
nickname=nickname,
rescanning=rescanning,
)
@app.route('/wallet/<string:walletname>/taker/direct-send', methods=['POST'])
def directsend(self, request, walletname):
@ -980,7 +1048,7 @@ class JMWalletDaemon(Service):
read_only=True)
except StoragePasswordError:
# actually effects authentication
raise NotAuthorized()
raise InvalidCredentials()
except StorageError:
# wallet is not openable, this should not happen
raise NoWalletFound()
@ -989,10 +1057,11 @@ class JMWalletDaemon(Service):
# this also shouldn't happen so raise:
raise NoWalletFound()
# no exceptions raised means we just return token:
self.set_token(self.wallet_name)
return make_jmwalletd_response(request,
walletname=self.wallet_name,
token=self.cookie)
return make_jmwalletd_response(
request,
walletname=self.wallet_name,
**self.token.issue(),
)
# This is a different wallet than the one currently open;
# try to open it, then initialize the service(s):
@ -1003,7 +1072,7 @@ class JMWalletDaemon(Service):
ask_for_password=False,
gap_limit = jm_single().config.getint("POLICY", "gaplimit"))
except StoragePasswordError:
raise NotAuthorized()
raise InvalidCredentials()
except RetryableStorageError:
# .lock file exists
raise LockExists()

31
jmclient/jmclient/websocketserver.py

@ -1,6 +1,8 @@
import json
from autobahn.twisted.websocket import WebSocketServerFactory, \
WebSocketServerProtocol
from .auth import JMTokenAuthority
from jmbitcoin import human_readable_transaction
from jmbase import get_log
@ -8,19 +10,18 @@ jlog = get_log()
class JmwalletdWebSocketServerProtocol(WebSocketServerProtocol):
def onOpen(self):
self.token = None
self.active_session = False
self.factory.register(self)
def sendNotification(self, info):
""" Passes on an object (json encoded) to the client,
if currently authenticated.
"""
if not self.token:
# gating by token means even if this client
# is erroneously in a broadcast list, it won't get
# any data if it hasn't authenticated.
if not self.active_session:
# not sending any data if the session is
# not active, i.e. client hasn't authenticated.
jlog.warn("Websocket not sending notification, "
"the connection is not authenticated.")
"the session is not active.")
return
self.sendMessage(json.dumps(info).encode())
@ -37,22 +38,22 @@ class JmwalletdWebSocketServerProtocol(WebSocketServerProtocol):
other message will drop the connection.
"""
if not isBinary:
self.token = payload.decode('utf8')
token = payload.decode('utf8')
# check that the token set for this protocol
# instance is the same as the one that the
# JMWalletDaemon instance deems is valid.
if not self.factory.check_token(self.token):
# instance is valid.
try:
self.factory.token.verify(token)
self.active_session = True
except Exception as e:
jlog.debug(e)
self.dropConnection()
class JmwalletdWebSocketServerFactory(WebSocketServerFactory):
def __init__(self, url):
def __init__(self, url, token_authority = JMTokenAuthority()):
WebSocketServerFactory.__init__(self, url)
self.valid_token = None
self.token = token_authority
self.clients = []
def check_token(self, token):
return self.valid_token == token
def register(self, client):
if client not in self.clients:
self.clients.append(client)

97
jmclient/test/test_auth.py

@ -0,0 +1,97 @@
"""test auth module."""
import copy
import datetime
import jwt
import pytest
from jmclient.auth import ExpiredSignatureError, InvalidScopeError, JMTokenAuthority
class TestJMTokenAuthority:
wallet_name = "dummywallet"
token_auth = JMTokenAuthority(wallet_name)
access_sig = copy.copy(token_auth.signature_key["access"])
refresh_sig = copy.copy(token_auth.signature_key["refresh"])
validity = datetime.timedelta(hours=1)
scope = f"walletrpc {wallet_name}"
@pytest.mark.parametrize(
"sig, token_type", [(access_sig, "access"), (refresh_sig, "refresh")]
)
def test_verify_valid(self, sig, token_type):
token = jwt.encode(
{"exp": datetime.datetime.utcnow() + self.validity, "scope": self.scope},
sig,
algorithm=self.token_auth.SIGNATURE_ALGORITHM,
)
try:
self.token_auth.verify(token, token_type=token_type)
except Exception as e:
print(e)
pytest.fail("Token verification failed, token is valid.")
def test_verify_expired(self):
token = jwt.encode(
{"exp": datetime.datetime.utcnow() - self.validity, "scope": self.scope},
self.access_sig,
algorithm=self.token_auth.SIGNATURE_ALGORITHM,
)
with pytest.raises(ExpiredSignatureError):
self.token_auth.verify(token)
def test_verify_non_scoped(self):
token = jwt.encode(
{"exp": datetime.datetime.utcnow() + self.validity, "scope": "wrong"},
self.access_sig,
algorithm=self.token_auth.SIGNATURE_ALGORITHM,
)
with pytest.raises(InvalidScopeError):
self.token_auth.verify(token)
def test_issue(self):
def scope_equals(scope):
return set(scope.split(" ")) == set(self.scope.split(" "))
token_response = self.token_auth.issue()
assert token_response.pop("expires_in") == int(
self.token_auth.SESSION_VALIDITY["access"].total_seconds()
)
assert token_response.pop("token_type") == "bearer"
assert scope_equals(token_response.pop("scope"))
try:
for k, v in token_response.items():
claims = jwt.decode(
v,
self.token_auth.signature_key["refresh"]
if k == "refresh_token"
else self.token_auth.signature_key["access"],
algorithms=self.token_auth.SIGNATURE_ALGORITHM,
)
assert scope_equals(claims.get("scope"))
assert self.token_auth.signature_key["refresh"] != self.refresh_sig
except jwt.exceptions.InvalidTokenError:
pytest.fail("An invalid token was issued.")
def test_scope_operation(self):
assert "walletrpc" in self.token_auth._scope
assert self.wallet_name in self.token_auth._scope
scope = copy.copy(self.token_auth._scope)
s = "new_wallet"
self.token_auth.add_to_scope(s)
assert scope < self.token_auth._scope
assert s in self.token_auth._scope
self.token_auth.discard_from_scope(s, "walletrpc")
assert scope > self.token_auth._scope
assert s not in self.token_auth._scope

179
jmclient/test/test_wallet_rpc.py

@ -1,11 +1,14 @@
import os, json
import base64
import datetime
import functools
import json
import os
import jwt
import pytest
from twisted.internet import reactor, defer, task
from twisted.web.client import readBody, Headers
from twisted.trial import unittest
from autobahn.twisted.websocket import WebSocketClientFactory, \
connectWS
@ -27,7 +30,7 @@ from commontest import make_wallets
from test_coinjoin import make_wallets_to_list, sync_wallets
from test_websocket import (ClientTProtocol, test_tx_hex_1,
test_tx_hex_txid, encoded_token)
test_tx_hex_txid, test_token_authority)
pytestmark = pytest.mark.usefixtures("setup_regtest_bitcoind")
@ -38,10 +41,14 @@ testfilename = "testwrpc"
jlog = get_log()
class JMWalletDaemonT(JMWalletDaemon):
def check_cookie(self, request):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.token = test_token_authority
def check_cookie(self, request, *args, **kwargs):
if self.auth_disabled:
return True
return super().check_cookie(request)
return super().check_cookie(request, *args, **kwargs)
class WalletRPCTestBase(object):
""" Base class for set up of tests of the
@ -87,6 +94,7 @@ class WalletRPCTestBase(object):
# (and don't use wallet files yet), we won't have set a wallet name,
# so we set it here:
self.daemon.wallet_name = self.get_wallet_file_name(1)
self.daemon.token.wallet_name = self.daemon.wallet_name
r, s = self.daemon.startService()
self.listener_rpc = r
self.listener_ws = s
@ -127,7 +135,7 @@ class WalletRPCTestBase(object):
@defer.inlineCallbacks
def do_request(self, agent, method, addr, body, handler, token=None):
if token:
headers = Headers({"Authorization": ["Bearer " + self.jwt_token]})
headers = Headers({"Authorization": ["Bearer " + token]})
else:
headers = None
response = yield agent.request(method, addr, headers, bodyProducer=body)
@ -201,9 +209,9 @@ class TrialTestWRPC_WS(WalletRPCTestBase, unittest.TestCase):
def test_notif(self):
# simulate the daemon already having created
# a valid token (which it usually does when
# an active session (which it usually does when
# starting the WalletService:
self.daemon.wss_factory.valid_token = encoded_token
self.daemon.wss_factory.protocol.active_session = True
# once the websocket connection is established, and auth
# is sent, our custom clientfactory will fire the tx
# notification via the callback passed as argument here;
@ -744,6 +752,159 @@ class TrialTestWRPC_DisplayWallet(WalletRPCTestBase, unittest.TestCase):
json_body = json.loads(response.decode('utf-8'))
assert json_body["seedphrase"]
class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase):
def get_token(self, grant_type: str, status: str = "valid"):
now, delta = datetime.datetime.utcnow(), datetime.timedelta(hours=1)
exp = now - delta if status == "expired" else now + delta
scope = f"walletrpc {self.daemon.wallet_name}"
if status == "invalid_scope":
scope = "walletrpc another_wallet"
alg = test_token_authority.SIGNATURE_ALGORITHM
if status == "invalid_alg":
alg = ({"HS256", "HS384", "HS512"} - {alg}).pop()
t = jwt.encode(
{"exp": exp, "scope": scope},
test_token_authority.signature_key[grant_type],
algorithm=test_token_authority.SIGNATURE_ALGORITHM,
)
if status == "invalid_sig":
# Get token string
token_parts = t.split(".")
sig = token_parts[-1]
# Pad as needed
if len(sig) % 4 != 0:
sig += "=" * (len(sig) % 4)
# Flip fist byte, unpad
sig_bytes = base64.urlsafe_b64decode(sig)
flipped_bytes = bytes([sig_bytes[0] ^ 1]) + sig_bytes[1:]
flipped_sig = base64.urlsafe_b64encode(flipped_bytes).replace(b"=", b"")
# Reconstruct JWT with invalid sig
token_parts[-1] = str(flipped_sig)
t = ".".join(token_parts)
return t
def authorized_response_handler(self, response, code):
assert code == 200
def forbidden_response_handler(self, response, code):
assert code == 403
assert "insufficient_scope" in response.headers.get("WWW-Authenticate")
def unauthorized_response_handler(self, response, code):
assert code == 401
assert "Bearer" in response.headers.get("WWW-Authenticate")
def expired_access_token_response_handler(self, response, code):
self.unauthorized_response_handler(response, code)
assert "expired" in response.headers.get("WWW-Authenticate")
async def test_jwt_authentication(self):
"""Test JWT authentication and authorization"""
agent = get_nontor_agent()
addr = (self.get_route_root() + "/session").encode()
for access_token_status, responde_handler in [
("valid", "authorized"),
("expired", "expired"),
("invalid_scope", "forbidden"),
("invalid_sig", "unauthorized"),
("invalid_alg", "unauthorized"),
]:
handler = {
"authorized": self.authorized_response_handler,
"expired": self.expired_access_token_response_handler,
"forbidden": self.forbidden_response_handler,
"unauthorized": self.unauthorized_response_handler,
}[responde_handler]
token = self.get_token("access", access_token_status)
await self.do_request(agent, b"GET", addr, None, handler, token)
def successful_refresh_response_handler(self, response, code):
self.authorized_response_handler(response, code)
json_body = json.loads(response.decode("utf-8"))
assert {"token", "refresh_token", "expires_in", "token_type", "scope"} <= set(
json_body.keys()
)
def failed_refresh_response_handler(
self, response, code, *, message=None, error_description=None
):
assert code == 400
json_body = json.loads(response.decode("utf-8"))
if message is not None:
assert json_body.get("message") == message
if error_description is not None:
assert error_description in json_body.get("error_description")
async def do_refresh_request(self, body, handler, token):
agent = get_nontor_agent()
addr = (self.get_route_root() + "/token").encode()
body = BytesProducer(json.dumps(body).encode())
await self.do_request(agent, b"POST", addr, body, handler, token)
def test_refresh_token_request(self):
"""Test token endpoint with valid refresh token"""
for access_token_status, request_status, error in [
("valid", "valid", None),
("expired", "valid", None),
("valid", "invalid_request", "invalid_request"),
("valid", "invalid_grant", "unsupported_grant_type"),
]:
if error is None:
handler = self.successful_refresh_response_handler
else:
handler = functools.partialmethod(
self.failed_refresh_response_handler, message=error
)
body = {
"grant_type": "refresh_token",
"refresh_token": self.get_token("refresh"),
}
if request_status == "invalid_request":
body["refresh"] = body.pop("refresh_token")
if request_status == "unsupported_grant_type":
body["grant_type"] = "joinmarket"
self.do_refresh_request(
body, handler, self.get_token("access", access_token_status)
)
async def test_refresh_token(self):
"""Test refresh token endpoint"""
for refresh_token_status, error in [
("expired", "expired"),
("invalid_scope", "invalid_scope"),
("invalid_sig", "invalid_grant"),
]:
if error == "expired":
handler = functools.partialmethod(
self.failed_refresh_response_handler, error_description=error
)
else:
handler = functools.partialmethod(
self.failed_refresh_response_handler, message=error
)
body = {
"grant_type": "refresh_token",
"refresh_token": self.get_token("refresh", refresh_token_status),
}
self.do_refresh_request(body, handler, self.get_token("access"))
"""
Sample listutxos response for reference:

15
jmclient/test/test_websocket.py

@ -1,17 +1,16 @@
import os
import json
import datetime
from twisted.internet import reactor, task
from twisted.trial import unittest
from autobahn.twisted.websocket import WebSocketClientFactory, \
WebSocketClientProtocol, connectWS, listenWS
import jwt
from jmbase import get_log, hextobin
from jmbase.support import get_free_tcp_ports
from jmclient import (JmwalletdWebSocketServerFactory,
JmwalletdWebSocketServerProtocol)
from jmclient.auth import JMTokenAuthority
from jmbitcoin import CTransaction
testdir = os.path.dirname(os.path.realpath(__file__))
@ -21,11 +20,8 @@ jlog = get_log()
test_tx_hex_1 = "02000000000102578770b2732aed421ffe62d54fd695cf281ca336e4f686d2adbb2e8c3bedb2570000000000ffffffff4719a259786b4237f92460629181edcc3424419592529103143090f07d85ec330100000000ffffffff0324fd9b0100000000160014d38fa4a6ac8db7495e5e2b5d219dccd412dd9bae24fd9b0100000000160014564aead56de8f4d445fc5b74a61793b5c8a819667af6c208000000001600146ec55c2e1d1a7a868b5ec91822bf40bba842bac502473044022078f8106a5645cc4afeef36d4addec391a5b058cc51053b42c89fcedf92f4db1002200cdf1b66a922863fba8dc1b1b1a0dce043d952fa14dcbe86c427fda25e930a53012102f1f750bfb73dbe4c7faec2c9c301ad0e02176cd47bcc909ff0a117e95b2aad7b02483045022100b9a6c2295a1b0f7605381d416f6ed8da763bd7c20f2402dd36b62dd9dd07375002207d40eaff4fc6ee219a7498abfab6bdc54b7ce006ac4b978b64bff960fbf5f31e012103c2a7d6e44acdbd503c578ec7d1741a44864780be0186e555e853eee86e06f11f00000000"
test_tx_hex_txid = "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc"
# example (valid) JWT token for test:
encoded_token = jwt.encode({"wallet": "dummywallet",
"exp" :datetime.datetime.utcnow(
)+datetime.timedelta(minutes=30)}, "secret")
encoded_token = encoded_token.strip()
# Shared JWT token authority for test:
test_token_authority = JMTokenAuthority("dummywallet")
class ClientTProtocol(WebSocketClientProtocol):
"""
@ -37,7 +33,7 @@ class ClientTProtocol(WebSocketClientProtocol):
""" Our server will not broadcast
to us unless we authenticate.
"""
self.sendMessage(encoded_token.encode('utf8'))
self.sendMessage(test_token_authority.issue()["token"].encode('utf8'))
def onOpen(self):
# auth on startup
@ -69,9 +65,8 @@ class WebsocketTestBase(object):
free_ports = get_free_tcp_ports(1)
self.wss_port = free_ports[0]
self.wss_url = "ws://127.0.0.1:" + str(self.wss_port)
self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url)
self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url, test_token_authority)
self.wss_factory.protocol = JmwalletdWebSocketServerProtocol
self.wss_factory.valid_token = encoded_token
self.listeningport = listenWS(self.wss_factory, contextFactory=None)
self.test_tx = CTransaction.deserialize(hextobin(test_tx_hex_1))

Loading…
Cancel
Save