mirror of
https://github.com/basicswap/basicswap.git
synced 2025-11-05 18:38:09 +01:00
Merge pull request #290 from cryptoguard/client-auth-webui
Added client authentication.
This commit is contained in:
@@ -1734,6 +1734,8 @@ def printHelp():
|
||||
print(
|
||||
"--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 = []
|
||||
for coin_name in known_coins.keys():
|
||||
@@ -2166,6 +2168,8 @@ def main():
|
||||
disable_tor = False
|
||||
initwalletsonly = False
|
||||
tor_control_password = None
|
||||
client_auth_pwd_value = None
|
||||
disable_client_auth_flag = False
|
||||
extra_opts = {}
|
||||
|
||||
if os.getenv("SSL_CERT_DIR", "") == "" and GUIX_SSL_CERT_DIR is not None:
|
||||
@@ -2298,7 +2302,15 @@ def main():
|
||||
if name == "trustremotenode":
|
||||
extra_opts["trust_remote_node"] = toBool(s[1])
|
||||
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))
|
||||
|
||||
if print_versions:
|
||||
@@ -2328,6 +2340,34 @@ def main():
|
||||
os.makedirs(data_dir)
|
||||
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):
|
||||
exitWithError("Can't use --usetorproxy and --notorproxy together")
|
||||
|
||||
@@ -2969,6 +3009,10 @@ def main():
|
||||
tor_control_password = generate_salt(24)
|
||||
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:
|
||||
for c in with_coins:
|
||||
prepareCore(c, known_coins[c], settings, data_dir, extra_opts)
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# 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
|
||||
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
|
||||
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import secrets
|
||||
import traceback
|
||||
import threading
|
||||
import http.client
|
||||
import base64
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from jinja2 import Environment, PackageLoader
|
||||
from socket import error as SocketError
|
||||
from urllib import parse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
from . import __version__
|
||||
from .util import (
|
||||
@@ -32,6 +36,8 @@ from .basicswap_util import (
|
||||
strTxState,
|
||||
strBidState,
|
||||
)
|
||||
from .util.rfc2440 import verify_rfc2440_password
|
||||
|
||||
from .js_server import (
|
||||
js_error,
|
||||
js_url_to_function,
|
||||
@@ -58,6 +64,9 @@ from .ui.page_identity import page_identity
|
||||
from .ui.page_smsgaddresses import page_smsgaddresses
|
||||
from .ui.page_debug import page_debug
|
||||
|
||||
SESSION_COOKIE_NAME = "basicswap_session_id"
|
||||
SESSION_DURATION_MINUTES = 60
|
||||
|
||||
env = Environment(loader=PackageLoader("basicswap", "templates"))
|
||||
env.filters["formatts"] = format_timestamp
|
||||
|
||||
@@ -120,6 +129,57 @@ def parse_cmd(cmd: str, type_map: str):
|
||||
|
||||
|
||||
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):
|
||||
super().log_message(format, *args)
|
||||
|
||||
@@ -142,7 +202,12 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
return form_data
|
||||
|
||||
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
|
||||
if swap_client.ws_server:
|
||||
@@ -153,7 +218,6 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
args_dict["debug_ui_mode"] = True
|
||||
if swap_client.use_tor_proxy:
|
||||
args_dict["use_tor_proxy"] = True
|
||||
# TODO: Cache value?
|
||||
try:
|
||||
tor_state = get_tor_established_state(swap_client)
|
||||
args_dict["tor_established"] = True if tor_state == "1" else False
|
||||
@@ -202,7 +266,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
|
||||
args_dict["version"] = version
|
||||
|
||||
self.putHeaders(status_code, "text/html")
|
||||
self.putHeaders(status_code, "text/html", extra_headers=extra_headers)
|
||||
return bytes(
|
||||
template.render(
|
||||
title=self.server.title,
|
||||
@@ -214,6 +278,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
|
||||
def render_simple_template(self, template, args_dict):
|
||||
self.putHeaders(200, "text/html")
|
||||
return bytes(
|
||||
template.render(
|
||||
title=self.server.title,
|
||||
@@ -222,7 +287,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
"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")
|
||||
swap_client = self.server.swap_client
|
||||
summary = swap_client.getSummary()
|
||||
@@ -233,6 +298,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
"message_str": info_str,
|
||||
"summary": summary,
|
||||
},
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
def page_error(self, error_str, post_string=None):
|
||||
@@ -248,6 +314,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):
|
||||
swap_client = self.server.swap_client
|
||||
swap_client.checkSystemStatus()
|
||||
@@ -261,14 +414,10 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
form_data = self.checkForm(post_string, "explorers", err_messages)
|
||||
if form_data:
|
||||
|
||||
explorer = form_data[b"explorer"][0].decode("utf-8")
|
||||
action = form_data[b"action"][0].decode("utf-8")
|
||||
explorer = get_data_entry(form_data, "explorer")
|
||||
action = get_data_entry(form_data, "action")
|
||||
args = get_data_entry_or(form_data, "args", "")
|
||||
|
||||
args = (
|
||||
""
|
||||
if b"args" not in form_data
|
||||
else form_data[b"args"][0].decode("utf-8")
|
||||
)
|
||||
try:
|
||||
c, e = explorer.split("_")
|
||||
exp = swap_client.coin_clients[Coins(int(c))]["explorers"][int(e)]
|
||||
@@ -457,6 +606,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def page_shutdown(self, url_split, post_string):
|
||||
swap_client = self.server.swap_client
|
||||
extra_headers = []
|
||||
|
||||
if len(url_split) > 2:
|
||||
token = url_split[2]
|
||||
@@ -464,9 +614,15 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
if token != expect_token:
|
||||
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()
|
||||
|
||||
return self.page_info("Shutting down")
|
||||
return self.page_info("Shutting down", extra_headers=extra_headers)
|
||||
|
||||
def page_index(self, url_split):
|
||||
swap_client = self.server.swap_client
|
||||
@@ -487,22 +643,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)
|
||||
if self.server.allow_cors:
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
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()
|
||||
|
||||
def handle_http(self, status_code, path, post_string="", is_json=False):
|
||||
swap_client = self.server.swap_client
|
||||
parsed = parse.urlparse(self.path)
|
||||
url_split = parsed.path.split("/")
|
||||
if post_string == "" and len(parsed.query) > 0:
|
||||
post_string = parsed.query
|
||||
if len(url_split) > 1 and url_split[1] == "json":
|
||||
page = url_split[1] if len(url_split) > 1 else ""
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
if page == "json":
|
||||
try:
|
||||
self.putHeaders(status_code, "json")
|
||||
func = js_url_to_function(url_split)
|
||||
return func(self, url_split, post_string, is_json)
|
||||
except Exception as ex:
|
||||
@@ -510,18 +725,20 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
swap_client.log.error(traceback.format_exc())
|
||||
return js_error(self, str(ex))
|
||||
|
||||
if len(url_split) > 1 and url_split[1] == "static":
|
||||
if page == "static":
|
||||
try:
|
||||
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":
|
||||
with open(
|
||||
os.path.join(static_path, "sequence_diagrams", url_split[3]),
|
||||
"rb",
|
||||
) as fp:
|
||||
self.putHeaders(status_code, "image/svg+xml")
|
||||
return fp.read()
|
||||
filepath = os.path.join(
|
||||
static_path, "sequence_diagrams", url_split[3]
|
||||
)
|
||||
mime_type = "image/svg+xml"
|
||||
elif len(url_split) > 3 and url_split[2] == "images":
|
||||
filename = os.path.join(*url_split[3:])
|
||||
filepath = os.path.join(static_path, "images", filename)
|
||||
_, extension = os.path.splitext(filename)
|
||||
mime_type = {
|
||||
".svg": "image/svg+xml",
|
||||
@@ -530,25 +747,25 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
}.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":
|
||||
filename = os.path.join(*url_split[3:])
|
||||
with open(os.path.join(static_path, "css", filename), "rb") as fp:
|
||||
self.putHeaders(status_code, "text/css; charset=utf-8")
|
||||
return fp.read()
|
||||
filepath = os.path.join(static_path, "css", filename)
|
||||
mime_type = "text/css; charset=utf-8"
|
||||
elif len(url_split) > 3 and url_split[2] == "js":
|
||||
filename = os.path.join(*url_split[3:])
|
||||
with open(os.path.join(static_path, "js", filename), "rb") as fp:
|
||||
self.putHeaders(status_code, "application/javascript")
|
||||
return fp.read()
|
||||
filepath = os.path.join(static_path, "js", filename)
|
||||
mime_type = "application/javascript"
|
||||
else:
|
||||
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:
|
||||
return self.page_404(url_split)
|
||||
except Exception as ex:
|
||||
@@ -560,6 +777,8 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
if len(url_split) > 1:
|
||||
page = url_split[1]
|
||||
|
||||
if page == "login":
|
||||
return self.page_login(url_split, post_string)
|
||||
if page == "active":
|
||||
return self.page_active(url_split, post_string)
|
||||
if page == "wallets":
|
||||
@@ -632,7 +851,8 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
self.server.swap_client.log.debug(f"do_GET SocketError {e}")
|
||||
|
||||
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
|
||||
response = self.handle_http(200, self.path, post_string, is_json)
|
||||
@@ -664,6 +884,7 @@ class HttpThread(threading.Thread, HTTPServer):
|
||||
self.title = "BasicSwap - " + __version__
|
||||
self.last_form_id = dict()
|
||||
self.session_tokens = dict()
|
||||
self.active_sessions = {}
|
||||
self.env = env
|
||||
self.msg_id_counter = 0
|
||||
|
||||
@@ -673,18 +894,19 @@ class HttpThread(threading.Thread, HTTPServer):
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
|
||||
# Send fake request
|
||||
conn = http.client.HTTPConnection(self.host_name, self.port_no)
|
||||
conn.connect()
|
||||
conn.request("GET", "/none")
|
||||
response = conn.getresponse()
|
||||
_ = response.read()
|
||||
conn.close()
|
||||
try:
|
||||
conn = http.client.HTTPConnection(self.host_name, self.port_no, timeout=0.5)
|
||||
conn.request("GET", "/shutdown_ping")
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def serve_forever(self):
|
||||
self.timeout = 1
|
||||
while not self.stop_event.is_set():
|
||||
self.handle_request()
|
||||
self.socket.close()
|
||||
self.swap_client.log.info("HTTP server stopped.")
|
||||
|
||||
def run(self):
|
||||
self.serve_forever()
|
||||
|
||||
72
basicswap/templates/login.html
Normal file
72
basicswap/templates/login.html
Normal 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>
|
||||
@@ -29,3 +29,51 @@ def rfc2440_hash_password(password, salt=None):
|
||||
break
|
||||
rv = "16:" + salt.hex() + "60" + h.hexdigest()
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
[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):
|
||||
|
||||
@@ -200,7 +204,7 @@ Prepare the datadir:
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Start Basicswap:
|
||||
|
||||
@@ -18,6 +18,7 @@ Create offers
|
||||
"prune_state_delay": Seconds between pruning old state data, set to 0 to disable pruning.
|
||||
"main_loop_delay": Seconds between main loop iterations.
|
||||
"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": [
|
||||
{
|
||||
"name": Offer template name, eg "Offer 0", will be automatically renamed if not unique.
|
||||
@@ -74,6 +75,8 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import urllib
|
||||
import urllib.error
|
||||
import base64
|
||||
from urllib.request import urlopen
|
||||
|
||||
delay_event = threading.Event()
|
||||
@@ -85,29 +88,113 @@ DEFAULT_CONFIG_FILE: str = "createoffers.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"})
|
||||
if auth_header_val:
|
||||
req.add_header("Authorization", auth_header_val)
|
||||
if json_data:
|
||||
req.add_header("Content-Type", "application/json; charset=utf-8")
|
||||
post_bytes = json.dumps(json_data).encode("utf-8")
|
||||
req.add_header("Content-Length", len(post_bytes))
|
||||
else:
|
||||
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
|
||||
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):
|
||||
nonlocal _auth_required_confirmed
|
||||
url = f"http://{host}:{port}/json"
|
||||
if path is not None:
|
||||
url += "/" + path
|
||||
if json_data is not None:
|
||||
return json.loads(post_req(url, json_data))
|
||||
response = urlopen(url, timeout=300).read()
|
||||
return json.loads(response)
|
||||
|
||||
current_auth_header = _auth_header_val if _auth_required_confirmed else None
|
||||
|
||||
try:
|
||||
if json_data is not None:
|
||||
response_obj = post_req(
|
||||
url, json_data, auth_header_val=current_auth_header
|
||||
)
|
||||
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
|
||||
|
||||
@@ -820,14 +907,50 @@ def main():
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
read_json_api = make_json_api_func(args.host, args.port)
|
||||
|
||||
if not os.path.exists(args.configfile):
|
||||
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)
|
||||
|
||||
known_coins = read_json_api("coins")
|
||||
for known_coin in known_coins:
|
||||
coins_map[known_coin["name"]] = known_coin
|
||||
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")
|
||||
for known_coin in known_coins:
|
||||
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 = {}
|
||||
if os.path.exists(args.statefile):
|
||||
@@ -843,7 +966,7 @@ def main():
|
||||
if "wallet_port_override" in config:
|
||||
wallet_api_port = int(config["wallet_port_override"])
|
||||
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:
|
||||
read_json_api_wallet = read_json_api
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"auth": "admin:password",
|
||||
"offers": [
|
||||
{
|
||||
"enabled": true,
|
||||
|
||||
Reference in New Issue
Block a user