Added client authentication

This commit is contained in:
cryptoguard
2025-04-01 00:24:32 -04:00
parent 9c252323be
commit c5908d5e0f
7 changed files with 595 additions and 74 deletions

View File

@@ -1711,6 +1711,8 @@ def printHelp():
print( print(
"--dashv20compatible Generate the same DASH wallet seed as for DASH v20 - Use only when importing an existing seed." "--dashv20compatible Generate the same DASH wallet seed as for DASH v20 - Use only when importing an existing seed."
) )
print("--client-auth-password= Set or update the password to protect the web UI.")
print("--disable-client-auth Remove password protection from the web UI.")
active_coins = [] active_coins = []
for coin_name in known_coins.keys(): for coin_name in known_coins.keys():
@@ -2114,6 +2116,8 @@ def main():
disable_tor = False disable_tor = False
initwalletsonly = False initwalletsonly = False
tor_control_password = None tor_control_password = None
client_auth_pwd_value = None
disable_client_auth_flag = False
extra_opts = {} extra_opts = {}
if os.getenv("SSL_CERT_DIR", "") == "" and GUIX_SSL_CERT_DIR is not None: if os.getenv("SSL_CERT_DIR", "") == "" and GUIX_SSL_CERT_DIR is not None:
@@ -2246,7 +2250,15 @@ def main():
if name == "trustremotenode": if name == "trustremotenode":
extra_opts["trust_remote_node"] = toBool(s[1]) extra_opts["trust_remote_node"] = toBool(s[1])
continue continue
if name == "client-auth-password":
client_auth_pwd_value = s[1].strip('"')
continue
if name == "disable-client-auth":
disable_client_auth_flag = True
continue
if len(s) != 2:
exitWithError("Unknown argument {}".format(v))
exitWithError("Unknown argument {}".format(v)) exitWithError("Unknown argument {}".format(v))
if print_versions: if print_versions:
@@ -2276,6 +2288,34 @@ def main():
os.makedirs(data_dir) os.makedirs(data_dir)
config_path = os.path.join(data_dir, cfg.CONFIG_FILENAME) config_path = os.path.join(data_dir, cfg.CONFIG_FILENAME)
config_exists = os.path.exists(config_path)
if config_exists and (
client_auth_pwd_value is not None or disable_client_auth_flag
):
try:
settings = load_config(config_path)
modified = False
if client_auth_pwd_value is not None:
settings["client_auth_hash"] = rfc2440_hash_password(
client_auth_pwd_value
)
logger.info("Client authentication password updated.")
modified = True
elif disable_client_auth_flag:
if "client_auth_hash" in settings:
del settings["client_auth_hash"]
logger.info("Client authentication disabled.")
modified = True
else:
logger.info("Client authentication is already disabled.")
if modified:
with open(config_path, "w") as fp:
json.dump(settings, fp, indent=4)
return 0
except Exception as e:
exitWithError(f"Failed to update client auth settings: {e}")
if use_tor_proxy and extra_opts.get("no_tor_proxy", False): if use_tor_proxy and extra_opts.get("no_tor_proxy", False):
exitWithError("Can't use --usetorproxy and --notorproxy together") exitWithError("Can't use --usetorproxy and --notorproxy together")
@@ -2916,6 +2956,10 @@ def main():
tor_control_password = generate_salt(24) tor_control_password = generate_salt(24)
addTorSettings(settings, tor_control_password) addTorSettings(settings, tor_control_password)
if client_auth_pwd_value is not None:
settings["client_auth_hash"] = rfc2440_hash_password(client_auth_pwd_value)
logger.info("Client authentication password set.")
if not no_cores: if not no_cores:
for c in with_coins: for c in with_coins:
prepareCore(c, known_coins[c], settings, data_dir, extra_opts) prepareCore(c, known_coins[c], settings, data_dir, extra_opts)

View File

@@ -1,21 +1,25 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright (c) 2019-2024 tecnovert # Copyright (c) 2019-2024 tecnovert
# Copyright (c) 2024 The Basicswap developers # Copyright (c) 2024-2025 The Basicswap developers
# Distributed under the MIT software license, see the accompanying # Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php. # file LICENSE or http://www.opensource.org/licenses/mit-license.php.
import os import os
import json import json
import shlex import shlex
import secrets
import traceback import traceback
import threading import threading
import http.client import http.client
import base64
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from jinja2 import Environment, PackageLoader from jinja2 import Environment, PackageLoader
from socket import error as SocketError from socket import error as SocketError
from urllib import parse from urllib import parse
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
from . import __version__ from . import __version__
from .util import ( from .util import (
@@ -32,14 +36,14 @@ from .basicswap_util import (
strTxState, strTxState,
strBidState, strBidState,
) )
from .util.rfc2440 import verify_rfc2440_password
from .js_server import ( from .js_server import (
js_error, js_error,
js_url_to_function, js_url_to_function,
) )
from .ui.util import ( from .ui.util import (
getCoinName, getCoinName,
get_data_entry,
get_data_entry_or,
listAvailableCoins, listAvailableCoins,
) )
from .ui.page_automation import ( from .ui.page_automation import (
@@ -58,6 +62,9 @@ from .ui.page_identity import page_identity
from .ui.page_smsgaddresses import page_smsgaddresses from .ui.page_smsgaddresses import page_smsgaddresses
from .ui.page_debug import page_debug from .ui.page_debug import page_debug
SESSION_COOKIE_NAME = "basicswap_session_id"
SESSION_DURATION_MINUTES = 60
env = Environment(loader=PackageLoader("basicswap", "templates")) env = Environment(loader=PackageLoader("basicswap", "templates"))
env.filters["formatts"] = format_timestamp env.filters["formatts"] = format_timestamp
@@ -120,6 +127,57 @@ def parse_cmd(cmd: str, type_map: str):
class HttpHandler(BaseHTTPRequestHandler): class HttpHandler(BaseHTTPRequestHandler):
def _get_session_cookie(self):
if "Cookie" in self.headers:
cookie = SimpleCookie(self.headers["Cookie"])
if SESSION_COOKIE_NAME in cookie:
return cookie[SESSION_COOKIE_NAME].value
return None
def _set_session_cookie(self, session_id):
cookie = SimpleCookie()
cookie[SESSION_COOKIE_NAME] = session_id
cookie[SESSION_COOKIE_NAME]["path"] = "/"
cookie[SESSION_COOKIE_NAME]["httponly"] = True
cookie[SESSION_COOKIE_NAME]["samesite"] = "Lax"
expires = datetime.now(timezone.utc) + timedelta(
minutes=SESSION_DURATION_MINUTES
)
cookie[SESSION_COOKIE_NAME]["expires"] = expires.strftime(
"%a, %d %b %Y %H:%M:%S GMT"
)
return ("Set-Cookie", cookie.output(header="").strip())
def _clear_session_cookie(self):
cookie = SimpleCookie()
cookie[SESSION_COOKIE_NAME] = ""
cookie[SESSION_COOKIE_NAME]["path"] = "/"
cookie[SESSION_COOKIE_NAME]["httponly"] = True
cookie[SESSION_COOKIE_NAME]["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
return ("Set-Cookie", cookie.output(header="").strip())
def is_authenticated(self):
swap_client = self.server.swap_client
client_auth_hash = swap_client.settings.get("client_auth_hash")
if not client_auth_hash:
return True
session_id = self._get_session_cookie()
if not session_id:
return False
session_data = self.server.active_sessions.get(session_id)
if session_data and session_data["expires"] > datetime.now(timezone.utc):
session_data["expires"] = datetime.now(timezone.utc) + timedelta(
minutes=SESSION_DURATION_MINUTES
)
return True
if session_id in self.server.active_sessions:
del self.server.active_sessions[session_id]
return False
def log_error(self, format, *args): def log_error(self, format, *args):
super().log_message(format, *args) super().log_message(format, *args)
@@ -131,18 +189,32 @@ class HttpHandler(BaseHTTPRequestHandler):
return os.urandom(8).hex() return os.urandom(8).hex()
def checkForm(self, post_string, name, messages): def checkForm(self, post_string, name, messages):
if post_string == "": if not post_string:
return None return None
form_data = parse.parse_qs(post_string) post_data_str = (
form_id = form_data[b"formid"][0].decode("utf-8") post_string.decode("utf-8")
if self.server.last_form_id.get(name, None) == form_id: if isinstance(post_string, bytes)
messages.append("Prevented double submit for form {}.".format(form_id)) else post_string
)
form_data = parse.parse_qs(post_data_str)
form_id_list = form_data.get("formid", [])
if not form_id_list:
return form_data
form_id = form_id_list[0]
if form_id == self.server.last_form_id.get(name):
messages.append("Form already submitted.")
return None return None
self.server.last_form_id[name] = form_id self.server.last_form_id[name] = form_id
return form_data return form_data
def render_template( def render_template(
self, template, args_dict, status_code=200, version=__version__ self,
template,
args_dict,
status_code=200,
version=__version__,
extra_headers=None,
): ):
swap_client = self.server.swap_client swap_client = self.server.swap_client
if swap_client.ws_server: if swap_client.ws_server:
@@ -153,7 +225,6 @@ class HttpHandler(BaseHTTPRequestHandler):
args_dict["debug_ui_mode"] = True args_dict["debug_ui_mode"] = True
if swap_client.use_tor_proxy: if swap_client.use_tor_proxy:
args_dict["use_tor_proxy"] = True args_dict["use_tor_proxy"] = True
# TODO: Cache value?
try: try:
tor_state = get_tor_established_state(swap_client) tor_state = get_tor_established_state(swap_client)
args_dict["tor_established"] = True if tor_state == "1" else False args_dict["tor_established"] = True if tor_state == "1" else False
@@ -202,7 +273,7 @@ class HttpHandler(BaseHTTPRequestHandler):
args_dict["version"] = version args_dict["version"] = version
self.putHeaders(status_code, "text/html") self.putHeaders(status_code, "text/html", extra_headers=extra_headers)
return bytes( return bytes(
template.render( template.render(
title=self.server.title, title=self.server.title,
@@ -214,6 +285,7 @@ class HttpHandler(BaseHTTPRequestHandler):
) )
def render_simple_template(self, template, args_dict): def render_simple_template(self, template, args_dict):
self.putHeaders(200, "text/html")
return bytes( return bytes(
template.render( template.render(
title=self.server.title, title=self.server.title,
@@ -222,7 +294,7 @@ class HttpHandler(BaseHTTPRequestHandler):
"UTF-8", "UTF-8",
) )
def page_info(self, info_str, post_string=None): def page_info(self, info_str, post_string=None, extra_headers=None):
template = env.get_template("info.html") template = env.get_template("info.html")
swap_client = self.server.swap_client swap_client = self.server.swap_client
summary = swap_client.getSummary() summary = swap_client.getSummary()
@@ -233,6 +305,7 @@ class HttpHandler(BaseHTTPRequestHandler):
"message_str": info_str, "message_str": info_str,
"summary": summary, "summary": summary,
}, },
extra_headers=extra_headers,
) )
def page_error(self, error_str, post_string=None): def page_error(self, error_str, post_string=None):
@@ -248,6 +321,93 @@ class HttpHandler(BaseHTTPRequestHandler):
}, },
) )
def page_login(self, url_split, post_string):
swap_client = self.server.swap_client
template = env.get_template("login.html")
err_messages = []
extra_headers = []
is_json_request = "application/json" in self.headers.get("Content-Type", "")
security_warning = None
if self.server.host_name not in ("127.0.0.1", "localhost"):
security_warning = "WARNING: Server is accessible on the network. Sending password over plain HTTP is insecure. Use HTTPS (e.g., via reverse proxy) for non-local access."
if not is_json_request:
err_messages.append(security_warning)
if post_string:
password = None
if is_json_request:
try:
json_data = json.loads(post_string.decode("utf-8"))
password = json_data.get("password")
except Exception as e:
swap_client.log.error(f"Error parsing JSON login data: {e}")
else:
try:
form_data = parse.parse_qs(post_string.decode("utf-8"))
password = form_data.get("password", [None])[0]
except Exception as e:
swap_client.log.error(f"Error parsing form login data: {e}")
client_auth_hash = swap_client.settings.get("client_auth_hash")
if (
client_auth_hash
and password is not None
and verify_rfc2440_password(client_auth_hash, password)
):
session_id = secrets.token_urlsafe(32)
expires = datetime.now(timezone.utc) + timedelta(
minutes=SESSION_DURATION_MINUTES
)
self.server.active_sessions[session_id] = {"expires": expires}
cookie_header = self._set_session_cookie(session_id)
if is_json_request:
response_data = {"success": True, "session_id": session_id}
if security_warning:
response_data["warning"] = security_warning
self.putHeaders(
200, "application/json", extra_headers=[cookie_header]
)
return json.dumps(response_data).encode("utf-8")
else:
self.send_response(302)
self.send_header("Location", "/offers")
self.send_header(cookie_header[0], cookie_header[1])
self.end_headers()
return b""
else:
if is_json_request:
self.putHeaders(401, "application/json")
return json.dumps({"error": "Invalid password"}).encode("utf-8")
else:
err_messages.append("Invalid password.")
clear_cookie_header = self._clear_session_cookie()
extra_headers.append(clear_cookie_header)
if (
not is_json_request
and swap_client.settings.get("client_auth_hash")
and self.is_authenticated()
):
self.send_response(302)
self.send_header("Location", "/offers")
self.end_headers()
return b""
return self.render_template(
template,
{
"title_str": "Login",
"err_messages": err_messages,
"summary": {},
"encrypted": False,
"locked": False,
},
status_code=401 if post_string and not is_json_request else 200,
extra_headers=extra_headers,
)
def page_explorers(self, url_split, post_string): def page_explorers(self, url_split, post_string):
swap_client = self.server.swap_client swap_client = self.server.swap_client
swap_client.checkSystemStatus() swap_client.checkSystemStatus()
@@ -259,16 +419,12 @@ class HttpHandler(BaseHTTPRequestHandler):
messages = [] messages = []
err_messages = [] err_messages = []
form_data = self.checkForm(post_string, "explorers", err_messages) form_data = self.checkForm(post_string, "explorers", err_messages)
if form_data: if form_data and form_data is not None:
explorer = form_data[b"explorer"][0].decode("utf-8") explorer = form_data.get("explorer", ["-1"])[0]
action = form_data[b"action"][0].decode("utf-8") action = form_data.get("action", ["-1"])[0]
args = form_data.get("args", [""])[0]
args = (
""
if b"args" not in form_data
else form_data[b"args"][0].decode("utf-8")
)
try: try:
c, e = explorer.split("_") c, e = explorer.split("_")
exp = swap_client.coin_clients[Coins(int(c))]["explorers"][int(e)] exp = swap_client.coin_clients[Coins(int(c))]["explorers"][int(e)]
@@ -316,12 +472,12 @@ class HttpHandler(BaseHTTPRequestHandler):
messages = [] messages = []
err_messages = [] err_messages = []
form_data = self.checkForm(post_string, "rpc", err_messages) form_data = self.checkForm(post_string, "rpc", err_messages)
if form_data: if form_data and form_data is not None: # Check if not None (double submit)
try: try:
call_type = get_data_entry_or(form_data, "call_type", "cli") call_type = form_data.get("call_type", ["cli"])[0]
type_map = get_data_entry_or(form_data, "type_map", "") type_map = form_data.get("type_map", [""])[0]
try: try:
coin_type_selected = get_data_entry(form_data, "coin_type") coin_type_selected = form_data.get("coin_type", ["-1"])[0]
coin_type_split = coin_type_selected.split(",") coin_type_split = coin_type_selected.split(",")
coin_type = Coins(int(coin_type_split[0])) coin_type = Coins(int(coin_type_split[0]))
coin_variant = int(coin_type_split[1]) coin_variant = int(coin_type_split[1])
@@ -332,7 +488,7 @@ class HttpHandler(BaseHTTPRequestHandler):
call_type = "http" call_type = "http"
try: try:
cmd = get_data_entry(form_data, "cmd") cmd = form_data.get("cmd", [""])[0]
except Exception: except Exception:
raise ValueError("Invalid command") raise ValueError("Invalid command")
if coin_type in (Coins.XMR, Coins.WOW): if coin_type in (Coins.XMR, Coins.WOW):
@@ -457,6 +613,7 @@ class HttpHandler(BaseHTTPRequestHandler):
def page_shutdown(self, url_split, post_string): def page_shutdown(self, url_split, post_string):
swap_client = self.server.swap_client swap_client = self.server.swap_client
extra_headers = []
if len(url_split) > 2: if len(url_split) > 2:
token = url_split[2] token = url_split[2]
@@ -464,9 +621,15 @@ class HttpHandler(BaseHTTPRequestHandler):
if token != expect_token: if token != expect_token:
return self.page_info("Unexpected token, still running.") return self.page_info("Unexpected token, still running.")
session_id = self._get_session_cookie()
if session_id and session_id in self.server.active_sessions:
del self.server.active_sessions[session_id]
clear_cookie_header = self._clear_session_cookie()
extra_headers.append(clear_cookie_header)
swap_client.stopRunning() swap_client.stopRunning()
return self.page_info("Shutting down") return self.page_info("Shutting down", extra_headers=extra_headers)
def page_index(self, url_split): def page_index(self, url_split):
swap_client = self.server.swap_client swap_client = self.server.swap_client
@@ -487,22 +650,81 @@ class HttpHandler(BaseHTTPRequestHandler):
}, },
) )
def putHeaders(self, status_code, content_type): def putHeaders(self, status_code, content_type, extra_headers=None):
self.send_response(status_code) self.send_response(status_code)
if self.server.allow_cors: if self.server.allow_cors:
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
if extra_headers:
for header_tuple in extra_headers:
self.send_header(header_tuple[0], header_tuple[1])
self.end_headers() self.end_headers()
def handle_http(self, status_code, path, post_string="", is_json=False): def handle_http(self, status_code, path, post_string=b"", is_json=False):
swap_client = self.server.swap_client swap_client = self.server.swap_client
parsed = parse.urlparse(self.path) parsed = parse.urlparse(self.path)
url_split = parsed.path.split("/") url_split = parsed.path.split("/")
if post_string == "" and len(parsed.query) > 0: page = url_split[1] if len(url_split) > 1 else ""
post_string = parsed.query
if len(url_split) > 1 and url_split[1] == "json": exempt_pages = ["login", "static", "error", "info"]
auth_header = self.headers.get("Authorization")
basic_auth_ok = False
if auth_header and auth_header.startswith("Basic "):
try: try:
self.putHeaders(status_code, "text/plain") encoded_creds = auth_header.split(" ", 1)[1]
decoded_creds = base64.b64decode(encoded_creds).decode("utf-8")
_, password = decoded_creds.split(":", 1)
client_auth_hash = swap_client.settings.get("client_auth_hash")
if client_auth_hash and verify_rfc2440_password(
client_auth_hash, password
):
basic_auth_ok = True
else:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="Basicswap"')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
json.dumps({"error": "Invalid Basic Auth credentials"}).encode(
"utf-8"
)
)
return b""
except Exception as e:
swap_client.log.error(f"Error processing Basic Auth header: {e}")
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="Basicswap"')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
json.dumps({"error": "Malformed Basic Auth header"}).encode("utf-8")
)
return b""
if not basic_auth_ok and page not in exempt_pages:
if not self.is_authenticated():
if page == "json":
self.putHeaders(401, "application/json")
self.wfile.write(
json.dumps({"error": "Unauthorized"}).encode("utf-8")
)
return b""
else:
self.send_response(302)
self.send_header("Location", "/login")
clear_cookie_header = self._clear_session_cookie()
self.send_header(clear_cookie_header[0], clear_cookie_header[1])
self.end_headers()
return b""
if not post_string and len(parsed.query) > 0:
post_string = parsed.query.encode("utf-8")
if page == "json":
try:
self.putHeaders(status_code, "json")
func = js_url_to_function(url_split) func = js_url_to_function(url_split)
return func(self, url_split, post_string, is_json) return func(self, url_split, post_string, is_json)
except Exception as ex: except Exception as ex:
@@ -510,18 +732,20 @@ class HttpHandler(BaseHTTPRequestHandler):
swap_client.log.error(traceback.format_exc()) swap_client.log.error(traceback.format_exc())
return js_error(self, str(ex)) return js_error(self, str(ex))
if len(url_split) > 1 and url_split[1] == "static": if page == "static":
try: try:
static_path = os.path.join(os.path.dirname(__file__), "static") static_path = os.path.join(os.path.dirname(__file__), "static")
content = None
mime_type = ""
filepath = ""
if len(url_split) > 3 and url_split[2] == "sequence_diagrams": if len(url_split) > 3 and url_split[2] == "sequence_diagrams":
with open( filepath = os.path.join(
os.path.join(static_path, "sequence_diagrams", url_split[3]), static_path, "sequence_diagrams", url_split[3]
"rb", )
) as fp: mime_type = "image/svg+xml"
self.putHeaders(status_code, "image/svg+xml")
return fp.read()
elif len(url_split) > 3 and url_split[2] == "images": elif len(url_split) > 3 and url_split[2] == "images":
filename = os.path.join(*url_split[3:]) filename = os.path.join(*url_split[3:])
filepath = os.path.join(static_path, "images", filename)
_, extension = os.path.splitext(filename) _, extension = os.path.splitext(filename)
mime_type = { mime_type = {
".svg": "image/svg+xml", ".svg": "image/svg+xml",
@@ -530,25 +754,25 @@ class HttpHandler(BaseHTTPRequestHandler):
".gif": "image/gif", ".gif": "image/gif",
".ico": "image/x-icon", ".ico": "image/x-icon",
}.get(extension, "") }.get(extension, "")
if mime_type == "":
raise ValueError("Unknown file type " + filename)
with open(
os.path.join(static_path, "images", filename), "rb"
) as fp:
self.putHeaders(status_code, mime_type)
return fp.read()
elif len(url_split) > 3 and url_split[2] == "css": elif len(url_split) > 3 and url_split[2] == "css":
filename = os.path.join(*url_split[3:]) filename = os.path.join(*url_split[3:])
with open(os.path.join(static_path, "css", filename), "rb") as fp: filepath = os.path.join(static_path, "css", filename)
self.putHeaders(status_code, "text/css; charset=utf-8") mime_type = "text/css; charset=utf-8"
return fp.read()
elif len(url_split) > 3 and url_split[2] == "js": elif len(url_split) > 3 and url_split[2] == "js":
filename = os.path.join(*url_split[3:]) filename = os.path.join(*url_split[3:])
with open(os.path.join(static_path, "js", filename), "rb") as fp: filepath = os.path.join(static_path, "js", filename)
self.putHeaders(status_code, "application/javascript") mime_type = "application/javascript"
return fp.read()
else: else:
return self.page_404(url_split) return self.page_404(url_split)
if mime_type == "" or not filepath:
raise ValueError("Unknown file type or path")
with open(filepath, "rb") as fp:
content = fp.read()
self.putHeaders(status_code, mime_type)
return content
except FileNotFoundError: except FileNotFoundError:
return self.page_404(url_split) return self.page_404(url_split)
except Exception as ex: except Exception as ex:
@@ -560,6 +784,8 @@ class HttpHandler(BaseHTTPRequestHandler):
if len(url_split) > 1: if len(url_split) > 1:
page = url_split[1] page = url_split[1]
if page == "login":
return self.page_login(url_split, post_string)
if page == "active": if page == "active":
return self.page_active(url_split, post_string) return self.page_active(url_split, post_string)
if page == "wallets": if page == "wallets":
@@ -632,7 +858,8 @@ class HttpHandler(BaseHTTPRequestHandler):
self.server.swap_client.log.debug(f"do_GET SocketError {e}") self.server.swap_client.log.debug(f"do_GET SocketError {e}")
def do_POST(self): def do_POST(self):
post_string = self.rfile.read(int(self.headers.get("Content-Length"))) content_length = int(self.headers.get("Content-Length", 0))
post_string = self.rfile.read(content_length)
is_json = True if "json" in self.headers.get("Content-Type", "") else False is_json = True if "json" in self.headers.get("Content-Type", "") else False
response = self.handle_http(200, self.path, post_string, is_json) response = self.handle_http(200, self.path, post_string, is_json)
@@ -664,6 +891,7 @@ class HttpThread(threading.Thread, HTTPServer):
self.title = "BasicSwap - " + __version__ self.title = "BasicSwap - " + __version__
self.last_form_id = dict() self.last_form_id = dict()
self.session_tokens = dict() self.session_tokens = dict()
self.active_sessions = {}
self.env = env self.env = env
self.msg_id_counter = 0 self.msg_id_counter = 0
@@ -673,18 +901,19 @@ class HttpThread(threading.Thread, HTTPServer):
def stop(self): def stop(self):
self.stop_event.set() self.stop_event.set()
# Send fake request try:
conn = http.client.HTTPConnection(self.host_name, self.port_no) conn = http.client.HTTPConnection(self.host_name, self.port_no, timeout=0.5)
conn.connect() conn.request("GET", "/shutdown_ping")
conn.request("GET", "/none")
response = conn.getresponse()
_ = response.read()
conn.close() conn.close()
except Exception:
pass
def serve_forever(self): def serve_forever(self):
self.timeout = 1
while not self.stop_event.is_set(): while not self.stop_event.is_set():
self.handle_request() self.handle_request()
self.socket.close() self.socket.close()
self.swap_client.log.info("HTTP server stopped.")
def run(self): def run(self):
self.serve_forever() self.serve_forever()

View File

@@ -0,0 +1,72 @@
{% from 'style.html' import circular_error_messages_svg %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link type="text/css" media="all" href="/static/css/libs/flowbite.min.css" rel="stylesheet" />
<link type="text/css" media="all" href="/static/css/libs/tailwind.min.css" rel="stylesheet">
<link type="text/css" media="all" href="/static/css/style.css" rel="stylesheet">
<script>
const isDarkMode = localStorage.getItem('color-theme') === 'dark' || (!localStorage.getItem('color-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDarkMode) {
document.documentElement.classList.add('dark');
}
</script>
<link rel=icon sizes="32x32" type="image/png" href="/static/images/favicon/favicon-32.png">
<title>(BSX) BasicSwap - Login - v{{ version }}</title>
</head>
<body class="dark:bg-gray-700">
<section class="py-24 md:py-32">
<div class="container px-4 mx-auto">
<div class="max-w-sm mx-auto">
<div class="mb-6 text-center">
<a class="inline-block mb-6" href="#">
<img src="/static/images/logos/basicswap-logo.svg" class="h-20 imageshow dark-image" style="display: none;">
<img src="/static/images/logos/basicswap-logo-dark.svg" class="h-20 imageshow light-image" style="display: block;">
</a>
<h3 class="mb-4 text-2xl md:text-3xl font-bold dark:text-white">Login Required</h3>
<p class="text-lg text-coolGray-500 font-medium dark:text-gray-300">Please enter the password to access BasicSwap.</p>
</div>
{% for m in err_messages %}
<section class="py-4" id="err_messages_{{ m[0] }}" role="alert">
<div class="container px-4 mx-auto">
<div class="p-4 text-red-800 rounded-lg bg-red-50 border border-red-400 dark:bg-gray-600 dark:text-red-300 rounded-md">
<div class="flex flex-wrap items-center -m-1">
<div class="w-auto p-1"> {{ circular_error_messages_svg | safe }} </div>
<p class="ml-2 font-medium text-sm">{{ m[1] }}</p>
</div>
</div>
</div>
</section>
{% endfor %}
<form method="post" action="/login" autocomplete="off">
<div class="mb-6">
<label class="block mb-2 text-coolGray-800 font-medium dark:text-white" for="password">Password</label>
<input class="appearance-none block w-full p-3 leading-5 text-coolGray-900 border border-coolGray-200 rounded-lg shadow-md placeholder-coolGray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
type="password" name="password" id="password" required autocomplete="current-password">
</div>
<button class="inline-block py-3 px-7 mb-6 w-full text-base text-blue-50 font-medium text-center leading-6 bg-blue-500 hover:bg-blue-600 rounded-md shadow-sm"
type="submit">Login</button>
<p class="text-center">
<span class="text-xs font-medium text-coolGray-500 dark:text-gray-500">{{ title }}</span>
</p>
</form>
</div>
</div>
</section>
<script>
document.addEventListener('DOMContentLoaded', () => {
function toggleImages() {
const isDark = document.documentElement.classList.contains('dark');
const darkImages = document.querySelectorAll('.dark-image');
const lightImages = document.querySelectorAll('.light-image');
darkImages.forEach(img => img.style.display = isDark ? 'block' : 'none');
lightImages.forEach(img => img.style.display = isDark ? 'none' : 'block');
}
toggleImages();
});
</script>
</body>
</html>

View File

@@ -29,3 +29,51 @@ def rfc2440_hash_password(password, salt=None):
break break
rv = "16:" + salt.hex() + "60" + h.hexdigest() rv = "16:" + salt.hex() + "60" + h.hexdigest()
return rv.upper() return rv.upper()
def verify_rfc2440_password(stored_hash, provided_password):
"""
Verifies a password against a hash generated by rfc2440_hash_password.
Args:
stored_hash (str): The hash string stored (e.g., "16:<salt>60<hash>").
provided_password (str): The password attempt to verify.
Returns:
bool: True if the password matches the hash, False otherwise.
"""
try:
parts = stored_hash.upper().split(":")
if len(parts) != 2 or parts[0] != "16":
return False
salt_hex_plus_hash_hex = parts[1]
separator_index = salt_hex_plus_hash_hex.find("60")
if separator_index != 16:
return False
salt_hex = salt_hex_plus_hash_hex[:separator_index]
expected_hash_hex = salt_hex_plus_hash_hex[separator_index + 2 :]
salt = bytes.fromhex(salt_hex)
except (ValueError, IndexError):
return False
EXPBIAS = 6
c = 96
count = (16 + (c & 15)) << ((c >> 4) + EXPBIAS)
hashbytes = salt + provided_password.encode("utf-8")
len_hashbytes = len(hashbytes)
h = hashlib.sha1()
while count > 0:
if count >= len_hashbytes:
h.update(hashbytes)
count -= len_hashbytes
continue
h.update(hashbytes[:count])
break
calculated_hash_hex = h.hexdigest().upper()
return secrets.compare_digest(calculated_hash_hex, expected_hash_hex)

View File

@@ -66,6 +66,10 @@ Adjust `--withcoins` and `--withoutcoins` as desired, eg: `--withcoins=monero,bi
Append `--usebtcfastsync` to the below command to optionally initialise the Bitcoin datadir with a chain snapshot from btcpayserver FastSync.<br> Append `--usebtcfastsync` to the below command to optionally initialise the Bitcoin datadir with a chain snapshot from btcpayserver FastSync.<br>
[FastSync README.md](https://github.com/btcpayserver/btcpayserver-docker/blob/master/contrib/FastSync/README.md) [FastSync README.md](https://github.com/btcpayserver/btcpayserver-docker/blob/master/contrib/FastSync/README.md)
##### FastSync
Append `--client-auth-password=<YOUR_PASSWORD>` to the below command to optionally enable client authentication to protect your web UI from unauthorized access.<br>
Setup with a local Monero daemon (recommended): Setup with a local Monero daemon (recommended):
@@ -200,7 +204,7 @@ Prepare the datadir:
OR using a remote/public XMR daemon (not recommended): OR using a remote/public XMR daemon (not recommended):
XMR_RPC_HOST="node.xmr.to" XMR_RPC_PORT=18081 basicswap-prepare --datadir=$SWAP_DATADIR --withcoins=monero --xmrrestoreheight=$CURRENT_XMR_HEIGHT XMR_RPC_HOST="node.xmr.to" XMR_RPC_PORT=18081 basicswap-prepare --datadir=$SWAP_DATADIR --withcoins=monero --xmrrestoreheight=$CURRENT_XMR_HEIGHT
Append `--client-auth-password=<YOUR_PASSWORD>` to the above command to optionally enable client authentication to protect your web UI from unauthorized access.<br>
Record the mnemonic from the output of the above command. Record the mnemonic from the output of the above command.
Start Basicswap: Start Basicswap:

View File

@@ -18,6 +18,7 @@ Create offers
"prune_state_delay": Seconds between pruning old state data, set to 0 to disable pruning. "prune_state_delay": Seconds between pruning old state data, set to 0 to disable pruning.
"main_loop_delay": Seconds between main loop iterations. "main_loop_delay": Seconds between main loop iterations.
"prune_state_after_seconds": Seconds to keep old state data for. "prune_state_after_seconds": Seconds to keep old state data for.
"auth": Basicswap API auth string, e.g., "admin:password". Ignored if client auth is not enabled.
"offers": [ "offers": [
{ {
"name": Offer template name, eg "Offer 0", will be automatically renamed if not unique. "name": Offer template name, eg "Offer 0", will be automatically renamed if not unique.
@@ -74,6 +75,8 @@ import threading
import time import time
import traceback import traceback
import urllib import urllib
import urllib.error
import base64
from urllib.request import urlopen from urllib.request import urlopen
delay_event = threading.Event() delay_event = threading.Event()
@@ -85,29 +88,113 @@ DEFAULT_CONFIG_FILE: str = "createoffers.json"
DEFAULT_STATE_FILE: str = "createoffers_state.json" DEFAULT_STATE_FILE: str = "createoffers_state.json"
def post_req(url: str, json_data=None): def post_req(url: str, json_data=None, auth_header_val=None):
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
if auth_header_val:
req.add_header("Authorization", auth_header_val)
if json_data: if json_data:
req.add_header("Content-Type", "application/json; charset=utf-8") req.add_header("Content-Type", "application/json; charset=utf-8")
post_bytes = json.dumps(json_data).encode("utf-8") post_bytes = json.dumps(json_data).encode("utf-8")
req.add_header("Content-Length", len(post_bytes)) req.add_header("Content-Length", len(post_bytes))
else: else:
post_bytes = None post_bytes = None
return urlopen(req, data=post_bytes, timeout=300).read() return urlopen(req, data=post_bytes, timeout=300)
def make_json_api_func(host: str, port: int): def make_json_api_func(host: str, port: int, auth_string: str = None):
host = host host = host
port = port port = port
_auth_header_val = None
_auth_required_confirmed = False
if auth_string:
try:
if auth_string and ":" in auth_string:
try:
auth_bytes = auth_string.encode("utf-8")
_auth_header_val = "Basic " + base64.b64encode(auth_bytes).decode(
"ascii"
)
except Exception as e:
print(
f"Warning: Could not process auth string '{auth_string}': {e}"
)
_auth_header_val = None
elif auth_string:
print(
"Warning: Auth string is not in 'username:password' format. Ignoring."
)
except Exception as e:
print(f"Error processing authentication: {e}")
def api_func(path=None, json_data=None, timeout=300): def api_func(path=None, json_data=None, timeout=300):
nonlocal _auth_required_confirmed
url = f"http://{host}:{port}/json" url = f"http://{host}:{port}/json"
if path is not None: if path is not None:
url += "/" + path url += "/" + path
current_auth_header = _auth_header_val if _auth_required_confirmed else None
try:
if json_data is not None: if json_data is not None:
return json.loads(post_req(url, json_data)) response_obj = post_req(
response = urlopen(url, timeout=300).read() url, json_data, auth_header_val=current_auth_header
return json.loads(response) )
else:
headers = {"User-Agent": "Mozilla/5.0"}
if current_auth_header:
headers["Authorization"] = current_auth_header
req = urllib.request.Request(url, headers=headers)
response_obj = urlopen(req, timeout=timeout)
response_bytes = response_obj.read()
return json.loads(response_bytes)
except urllib.error.HTTPError as e:
if e.code == 401 and not _auth_required_confirmed:
if _auth_header_val:
print(
"Server requires authentication, retrying with credentials..."
)
_auth_required_confirmed = True
try:
if json_data is not None:
response_obj = post_req(
url, json_data, auth_header_val=_auth_header_val
)
else:
headers = {
"User-Agent": "Mozilla/5.0",
"Authorization": _auth_header_val,
}
req = urllib.request.Request(url, headers=headers)
response_obj = urlopen(req, timeout=timeout)
response_bytes = response_obj.read()
return json.loads(response_bytes)
except urllib.error.HTTPError as retry_e:
if retry_e.code == 401:
raise ValueError(
"Authentication failed: Invalid credentials provided in 'auth' key."
)
else:
print(f"Error during authenticated API request: {retry_e}")
raise retry_e
except Exception as retry_e:
print(f"Error during authenticated API request: {retry_e}")
raise retry_e
else:
raise ValueError(
"Server requires authentication (401), but no 'auth' key found or properly formatted in config file."
)
else:
if e.code == 401 and _auth_required_confirmed:
raise ValueError(
"Authentication failed: Invalid credentials provided in 'auth' key."
)
else:
raise e
except Exception as e:
print(f"Error during API connection: {e}")
raise e
return api_func return api_func
@@ -820,14 +907,50 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
read_json_api = make_json_api_func(args.host, args.port)
if not os.path.exists(args.configfile): if not os.path.exists(args.configfile):
raise ValueError(f'Config file "{args.configfile}" not found.') raise ValueError(f'Config file "{args.configfile}" not found.')
try:
with open(args.configfile) as fs:
initial_config = json.load(fs)
except Exception as e:
print(f"Error reading config file {args.configfile}: {e}")
sys.exit(1)
auth_info = initial_config.get("auth")
read_json_api = make_json_api_func(args.host, args.port, auth_info)
wallet_api_port_override = initial_config.get("wallet_port_override")
if wallet_api_port_override:
read_json_api_wallet_auth = make_json_api_func(
args.host, int(wallet_api_port_override), auth_info
)
else:
read_json_api_wallet_auth = read_json_api_wallet
try:
print("Checking API connection...")
known_coins = read_json_api("coins") known_coins = read_json_api("coins")
for known_coin in known_coins: for known_coin in known_coins:
coins_map[known_coin["name"]] = known_coin coins_map[known_coin["name"]] = known_coin
print("API connection successful.")
except ValueError as e:
print(f"\nError: {e}")
print(
'Please ensure the \'auth\' key in your config file is correct (e.g., "auth": "username:password")'
)
sys.exit(1)
except urllib.error.URLError as e:
print(
f"\nError: Could not connect to Basicswap API at http://{args.host}:{args.port}"
)
print(f"Reason: {e.reason}")
print("Please ensure Basicswap is running and accessible.")
sys.exit(1)
except Exception as e:
print(f"\nError during initial API connection: {e}")
if args.debug:
traceback.print_exc()
sys.exit(1)
script_state = {} script_state = {}
if os.path.exists(args.statefile): if os.path.exists(args.statefile):
@@ -843,7 +966,7 @@ def main():
if "wallet_port_override" in config: if "wallet_port_override" in config:
wallet_api_port = int(config["wallet_port_override"]) wallet_api_port = int(config["wallet_port_override"])
print(f"Overriding wallet api port: {wallet_api_port}") print(f"Overriding wallet api port: {wallet_api_port}")
read_json_api_wallet = make_json_api_func(args.host, wallet_api_port) read_json_api_wallet = read_json_api_wallet_auth
else: else:
read_json_api_wallet = read_json_api read_json_api_wallet = read_json_api

View File

@@ -1,4 +1,5 @@
{ {
"auth": "admin:password",
"offers": [ "offers": [
{ {
"enabled": true, "enabled": true,