mirror of
https://github.com/basicswap/basicswap.git
synced 2025-04-06 14:27:30 +00:00
Merge e059533618
into 5a202e447c
This commit is contained in:
commit
83c2a7a221
5 changed files with 459 additions and 61 deletions
|
@ -1732,6 +1732,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():
|
||||
|
@ -2164,6 +2166,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:
|
||||
|
@ -2296,7 +2300,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:
|
||||
|
@ -2326,6 +2338,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")
|
||||
|
||||
|
@ -2967,6 +3007,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,14 +36,14 @@ from .basicswap_util import (
|
|||
strTxState,
|
||||
strBidState,
|
||||
)
|
||||
from .util.rfc2440 import verify_rfc2440_password
|
||||
|
||||
from .js_server import (
|
||||
js_error,
|
||||
js_url_to_function,
|
||||
)
|
||||
from .ui.util import (
|
||||
getCoinName,
|
||||
get_data_entry,
|
||||
get_data_entry_or,
|
||||
listAvailableCoins,
|
||||
)
|
||||
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_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 +127,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)
|
||||
|
||||
|
@ -131,18 +189,32 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||
return os.urandom(8).hex()
|
||||
|
||||
def checkForm(self, post_string, name, messages):
|
||||
if post_string == "":
|
||||
if not post_string:
|
||||
return None
|
||||
form_data = parse.parse_qs(post_string)
|
||||
form_id = form_data[b"formid"][0].decode("utf-8")
|
||||
if self.server.last_form_id.get(name, None) == form_id:
|
||||
messages.append("Prevented double submit for form {}.".format(form_id))
|
||||
post_data_str = (
|
||||
post_string.decode("utf-8")
|
||||
if isinstance(post_string, bytes)
|
||||
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
|
||||
self.server.last_form_id[name] = form_id
|
||||
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 +225,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 +273,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 +285,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 +294,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 +305,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 +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):
|
||||
swap_client = self.server.swap_client
|
||||
swap_client.checkSystemStatus()
|
||||
|
@ -259,16 +419,12 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||
messages = []
|
||||
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")
|
||||
action = form_data[b"action"][0].decode("utf-8")
|
||||
explorer = form_data.get("explorer", ["-1"])[0]
|
||||
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:
|
||||
c, e = explorer.split("_")
|
||||
exp = swap_client.coin_clients[Coins(int(c))]["explorers"][int(e)]
|
||||
|
@ -316,12 +472,12 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||
messages = []
|
||||
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:
|
||||
call_type = get_data_entry_or(form_data, "call_type", "cli")
|
||||
type_map = get_data_entry_or(form_data, "type_map", "")
|
||||
call_type = form_data.get("call_type", ["cli"])[0]
|
||||
type_map = form_data.get("type_map", [""])[0]
|
||||
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 = Coins(int(coin_type_split[0]))
|
||||
coin_variant = int(coin_type_split[1])
|
||||
|
@ -332,7 +488,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||
call_type = "http"
|
||||
|
||||
try:
|
||||
cmd = get_data_entry(form_data, "cmd")
|
||||
cmd = form_data.get("cmd", [""])[0]
|
||||
except Exception:
|
||||
raise ValueError("Invalid command")
|
||||
if coin_type in (Coins.XMR, Coins.WOW):
|
||||
|
@ -457,6 +613,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 +621,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,41 +650,103 @@ 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):
|
||||
def handle_http(self, status_code, path, post_string=b"", 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.encode("utf-8")
|
||||
|
||||
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:
|
||||
if swap_client.debug is True:
|
||||
swap_client.log.error(traceback.format_exc())
|
||||
self.putHeaders(500, "json")
|
||||
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 +755,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 +785,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 +859,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 +892,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 +902,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:
|
||||
|
|
Loading…
Reference in a new issue