diff --git a/docs/faq.md b/docs/faq.md index dbbaa3333..ee817b85c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -254,7 +254,10 @@ Simply fill in https://blockstream.info/ to use that block explorer, but you wil Check the .specter-folder in your Homefolder (or on your mynode/raspiblitz/...). There is a file called `config.json` in there which has a line like this: ``` -"auth":"somethingInHere" +"auth": { + "method": "somethingInHere", + ... +}, ``` Depending on "what's written in `somethingInHere`: * If it's `rpcpasswordaspin`, you can lookup the password in your `bitcoin.conf`-file in a line like `rpcpassword=YourPasswordHere` diff --git a/src/cryptoadvance/specter/helpers.py b/src/cryptoadvance/specter/helpers.py index ce25ac8ea..2c3bbcf35 100644 --- a/src/cryptoadvance/specter/helpers.py +++ b/src/cryptoadvance/specter/helpers.py @@ -64,6 +64,13 @@ def alias(name): return "".join(x for x in name if x.isalnum() or x == "_").lower() +def migrate_config(config): + # migrate old "auth" string into new "auth" json subtree + if "auth" in config: + if isinstance(config["auth"], str): + config["auth"] = dict(method=config["auth"]) + + def deep_update(d, u): for k, v in six.iteritems(u): dv = d.get(k, {}) diff --git a/src/cryptoadvance/specter/server.py b/src/cryptoadvance/specter/server.py index ce98e7b29..4cfdac2b5 100644 --- a/src/cryptoadvance/specter/server.py +++ b/src/cryptoadvance/specter/server.py @@ -93,7 +93,7 @@ def init_app(app, hwibridge=False, specter=None): app.login = login # Attach specter instance so child views (e.g. hwi) can access it app.specter = specter - if specter.config.get("auth") == "none": + if specter.config["auth"].get("method") == "none": app.logger.info("Login disabled") app.config["LOGIN_DISABLED"] = True else: diff --git a/src/cryptoadvance/specter/server_endpoints/auth.py b/src/cryptoadvance/specter/server_endpoints/auth.py index fd4075267..244b3e92e 100644 --- a/src/cryptoadvance/specter/server_endpoints/auth.py +++ b/src/cryptoadvance/specter/server_endpoints/auth.py @@ -1,4 +1,4 @@ -import random +import random, time from flask import ( Flask, Blueprint, @@ -16,6 +16,7 @@ from ..user import User, hash_password, verify_password rand = random.randint(0, 1e32) # to force style refresh +last_sensitive_request = 0 # to rate limit sensitive requests # Setup endpoint blueprint auth_endpoint = Blueprint("auth_endpoint", __name__) @@ -25,11 +26,13 @@ auth_endpoint = Blueprint("auth_endpoint", __name__) def login(): """ login """ if request.method == "POST": - if app.specter.config["auth"] == "none": + rate_limit() + auth = app.specter.config["auth"] + if auth["method"] == "none": app.login("admin") app.logger.info("AUDIT: Successfull Login no credentials") return redirect_login(request) - if app.specter.config["auth"] == "rpcpasswordaspin": + if auth["method"] == "rpcpasswordaspin": # TODO: check the password via RPC-call if app.specter.rpc is None: flash( @@ -51,7 +54,7 @@ def login(): app.login("admin") app.logger.info("AUDIT: Successfull Login via RPC-credentials") return redirect_login(request) - elif app.specter.config["auth"] == "usernamepassword": + elif auth["method"] == "usernamepassword": # TODO: This way both "User" and "user" will pass as usernames, should there be strict check on that here? Or should we keep it like this? username = request.form["username"] password = request.form["password"] @@ -84,23 +87,39 @@ def login(): def register(): """ register """ if request.method == "POST": + rate_limit() username = request.form["username"] - password = hash_password(request.form["password"]) + password = request.form["password"] otp = request.form["otp"] - user_id = alias(username) - i = 1 - while app.specter.user_manager.get_user(user_id): - i += 1 - user_id = "{}{}".format(alias(username), i) - if app.specter.user_manager.get_user_by_username(username): - flash("Username is already taken, please choose another one", "error") + if not username: + flash( + "Please enter a username.", + "error", + ) return redirect("register?otp={}".format(otp)) - if app.specter.burn_new_user_otp(otp): + min_chars = int(app.specter.config["auth"]["password_min_chars"]) + if not password or len(password) < min_chars: + flash( + "Please enter a password of a least {} characters.".format(min_chars), + "error", + ) + return redirect("register?otp={}".format(otp)) + if app.specter.validate_new_user_otp(otp): + user_id = alias(username) + i = 1 + while app.specter.user_manager.get_user(user_id): + i += 1 + user_id = "{}{}".format(alias(username), i) + if app.specter.user_manager.get_user_by_username(username): + flash("Username is already taken, please choose another one", "error") + return redirect("register?otp={}".format(otp)) + app.specter.remove_new_user_otp(otp) config = { "explorers": {"main": "", "test": "", "regtest": "", "signet": ""}, "hwi_bridge_url": "/hwi/api/", } - user = User(user_id, username, password, config) + password_hash = hash_password(password) + user = User(user_id, username, password_hash, config) app.specter.add_user(user) flash( "You have registered successfully, \ @@ -132,3 +151,19 @@ def redirect_login(request): else: response = redirect(url_for("index")) return response + + +def rate_limit(): + global last_sensitive_request + limit = int(app.specter.config["auth"]["rate_limit"]) + if limit < 0: + limit = 0 + now = time.time() + if ( + last_sensitive_request != 0 + and limit > 0 + and last_sensitive_request + limit > now + ): + remaining_time = last_sensitive_request + limit - now + time.sleep(remaining_time) + last_sensitive_request = time.time() diff --git a/src/cryptoadvance/specter/server_endpoints/settings.py b/src/cryptoadvance/specter/server_endpoints/settings.py index 63fb71361..1e5067dea 100644 --- a/src/cryptoadvance/specter/server_endpoints/settings.py +++ b/src/cryptoadvance/specter/server_endpoints/settings.py @@ -1,4 +1,4 @@ -import json, os, time, random, requests +import json, os, time, random, requests, secrets from flask import ( Flask, @@ -280,7 +280,7 @@ def tor(): tor_connectable = False elif action == "toggle_hidden_service": if not app.config["DEBUG"]: - if app.specter.config.get("auth", "none") == "none": + if app.specter.config["auth"].get("method", "none") == "none": flash( "Enabling Tor hidden service will expose your Specter for remote access.
It is therefore required that you set up authentication tab for Specter first to prevent unauthorized access.

Please go to Settings -> Authentication and set up an authentication method and retry.", "error", @@ -332,9 +332,11 @@ def tor(): def auth(): current_version = notify_upgrade(app, flash) auth = app.specter.config["auth"] - new_otp = -1 + method = auth["method"] + rate_limit = auth["rate_limit"] + registration_link_timeout = auth["registration_link_timeout"] users = None - if current_user.is_admin and auth == "usernamepassword": + if current_user.is_admin and method == "usernamepassword": users = [user for user in app.specter.user_manager.users if not user.is_admin] if request.method == "POST": action = request.form["action"] @@ -347,7 +349,9 @@ def auth(): specter_username = None specter_password = None if current_user.is_admin: - auth = request.form["auth"] + method = request.form["method"] + rate_limit = request.form["rate_limit"] + registration_link_timeout = request.form["registration_link_timeout"] if specter_username: if current_user.username != specter_username: if app.specter.user_manager.get_user_by_username(specter_username): @@ -357,8 +361,9 @@ def auth(): ) return render_template( "settings/auth_settings.jinja", - auth=auth, - new_otp=new_otp, + method=method, + rate_limit=rate_limit, + registration_link_timeout=registration_link_timeout, users=users, specter=app.specter, current_version=current_version, @@ -366,12 +371,30 @@ def auth(): ) current_user.username = specter_username if specter_password: + min_chars = int(auth["password_min_chars"]) + if len(specter_password) < min_chars: + flash( + "Please enter a password of a least {} characters.".format( + min_chars + ), + "error", + ) + return render_template( + "settings/auth_settings.jinja", + method=method, + rate_limit=rate_limit, + registration_link_timeout=registration_link_timeout, + users=users, + specter=app.specter, + current_version=current_version, + rand=rand, + ) current_user.password = hash_password(specter_password) current_user.save_info(app.specter) if current_user.is_admin: - app.specter.update_auth(auth) - if auth == "rpcpasswordaspin" or auth == "usernamepassword": - if auth == "usernamepassword": + app.specter.update_auth(method, rate_limit, registration_link_timeout) + if method == "rpcpasswordaspin" or method == "usernamepassword": + if method == "usernamepassword": users = [ user for user in app.specter.user_manager.users @@ -387,13 +410,25 @@ def auth(): app.specter.check() elif action == "adduser": if current_user.is_admin: - new_otp = random.randint(100000, 999999) + new_otp = secrets.token_urlsafe(16) + now = time.time() + timeout = int(registration_link_timeout) + timeout = 0 if timeout < 0 else timeout + if timeout > 0: + expiry = now + timeout * 60 * 60 + if timeout > 1: + expiry_desc = " (expires in {} hours)".format(timeout) + else: + expiry_desc = " (expires in 1 hour)" + else: + expiry = 0 + expiry_desc = "" app.specter.add_new_user_otp( - {"otp": new_otp, "created_at": time.time()} + {"otp": new_otp, "created_at": now, "expiry": expiry} ) flash( - "New user link generated successfully: {}auth/register?otp={}".format( - request.url_root, new_otp + "New user link generated{}: {}auth/register?otp={}".format( + expiry_desc, request.url_root, new_otp ), "info", ) @@ -413,8 +448,9 @@ def auth(): flash("Error: Only the admin account can delete users", "error") return render_template( "settings/auth_settings.jinja", - auth=auth, - new_otp=new_otp, + method=method, + rate_limit=rate_limit, + registration_link_timeout=registration_link_timeout, users=users, specter=app.specter, current_version=current_version, diff --git a/src/cryptoadvance/specter/specter.py b/src/cryptoadvance/specter/specter.py index 86cfe3877..748ef2d50 100644 --- a/src/cryptoadvance/specter/specter.py +++ b/src/cryptoadvance/specter/specter.py @@ -8,7 +8,7 @@ import time import zipfile import requests from io import BytesIO -from .helpers import deep_update, clean_psbt, is_testnet +from .helpers import migrate_config, deep_update, clean_psbt, is_testnet from .util.checker import Checker from .rpc import autodetect_rpc_confs, get_default_datadir, RpcError from urllib3.exceptions import NewConnectionError @@ -114,7 +114,12 @@ class Specter: "host": "localhost", # localhost "protocol": "http", # https for the future }, - "auth": "none", + "auth": { + "method": "none", + "password_min_chars": 6, + "rate_limit": 10, + "registration_link_timeout": 1, + }, "explorers": {"main": "", "test": "", "regtest": "", "signet": ""}, "proxy_url": "socks5h://localhost:9050", # Tor proxy URL "only_tor": False, @@ -297,6 +302,7 @@ class Specter: if os.path.isfile(self.config_fname): with self.lock: self.file_config = read_json_file(self.config_fname) + migrate_config(self.file_config) deep_update(self.config, self.file_config) # otherwise - create one and assign unique id else: @@ -431,10 +437,15 @@ class Specter: self.check(check_all=True) return self.rpc is not None - def update_auth(self, auth): + def update_auth(self, method, rate_limit, registration_link_timeout): """ simply persisting the current auth-choice """ - if self.config["auth"] != auth: - self.config["auth"] = auth + auth = self.config["auth"] + if auth["method"] != method: + auth["method"] = method + if auth["rate_limit"] != rate_limit: + auth["rate_limit"] = rate_limit + if auth["registration_link_timeout"] != registration_link_timeout: + auth["registration_link_timeout"] = registration_link_timeout self._save() def update_explorer(self, explorer, user): @@ -555,13 +566,30 @@ class Specter: self.config["new_user_otps"].append(otp_dict) self._save() - def burn_new_user_otp(self, otp): - """ validates an OTP for user registration and removes it if valid""" + def validate_new_user_otp(self, otp): + """ validates an OTP for user registration and removes it if expired""" + if "new_user_otps" not in self.config: + return False + now = time.time() + for i, otp_dict in enumerate(self.config["new_user_otps"]): + if otp_dict["otp"] == otp: + if ( + "expiry" in otp_dict + and otp_dict["expiry"] < now + and otp_dict["expiry"] > 0 + ): + del self.config["new_user_otps"][i] + self._save() + return False + return True + return False + + def remove_new_user_otp(self, otp): + """ removes an OTP for user registration""" if "new_user_otps" not in self.config: return False for i, otp_dict in enumerate(self.config["new_user_otps"]): - # TODO: Validate OTP did not expire based on created_at - if otp_dict["otp"] == int(otp): + if otp_dict["otp"] == otp: del self.config["new_user_otps"][i] self._save() return True diff --git a/src/cryptoadvance/specter/templates/base.jinja b/src/cryptoadvance/specter/templates/base.jinja index 4ca931e5d..fc7442229 100644 --- a/src/cryptoadvance/specter/templates/base.jinja +++ b/src/cryptoadvance/specter/templates/base.jinja @@ -18,7 +18,7 @@
- {% if specter.config.auth == "none" or current_user.is_authenticated %} + {% if specter.config.auth.method == "none" or current_user.is_authenticated %} {% include "includes/overlay/overlay.html" %} {% include "includes/qr-code.html" %} {% endif %} @@ -35,7 +35,7 @@ - {% if specter.config.auth != "none" %} + {% if specter.config.auth.method != "none" %} diff --git a/src/cryptoadvance/specter/templates/login.jinja b/src/cryptoadvance/specter/templates/login.jinja index 40881f7ba..dd864b33f 100644 --- a/src/cryptoadvance/specter/templates/login.jinja +++ b/src/cryptoadvance/specter/templates/login.jinja @@ -7,7 +7,7 @@

Login to Specter

- {% if specter.config['auth'] == 'usernamepassword' %} + {% if specter.config['auth']['method'] == 'usernamepassword' %}

{% endif %} @@ -19,4 +19,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/cryptoadvance/specter/templates/register.jinja b/src/cryptoadvance/specter/templates/register.jinja index bedef255b..8031c5931 100644 --- a/src/cryptoadvance/specter/templates/register.jinja +++ b/src/cryptoadvance/specter/templates/register.jinja @@ -7,7 +7,7 @@

Register to the Specter Node

- {% if specter.config['auth'] == 'usernamepassword' %} + {% if specter.config['auth']['method'] == 'usernamepassword' %}

{% endif %} diff --git a/src/cryptoadvance/specter/templates/settings/auth_settings.jinja b/src/cryptoadvance/specter/templates/settings/auth_settings.jinja index c88b583ad..b863bcd0c 100644 --- a/src/cryptoadvance/specter/templates/settings/auth_settings.jinja +++ b/src/cryptoadvance/specter/templates/settings/auth_settings.jinja @@ -5,19 +5,21 @@ {% from 'settings/components/settings_menu.jinja' import settings_menu %} {{ settings_menu('auth', current_user) }}
- {% if specter.config.auth == "none" or current_user.is_admin %} + {% if method == "none" or current_user.is_admin %} Authentication:
- + + +

{% endif %} -
- {% if specter.config.auth == "usernamepassword" and current_user.is_admin %} - -
+
+ Rate Limiting (seconds between login/register attempts):


+
+
+ {% if current_user.is_admin %} + Registration Link Timeout (hours):


Default is username: admin, password: admin.
{% endif %} Specter Username:


@@ -42,6 +44,12 @@
+ {% if method == "usernamepassword" and current_user.is_admin %} +
+ + {% endif %}
{% endblock %} @@ -49,16 +57,27 @@ {% block scripts %} {% endblock %} diff --git a/src/cryptoadvance/specter/templates/settings/tor_settings.jinja b/src/cryptoadvance/specter/templates/settings/tor_settings.jinja index 3345df22a..87d0d5212 100644 --- a/src/cryptoadvance/specter/templates/settings/tor_settings.jinja +++ b/src/cryptoadvance/specter/templates/settings/tor_settings.jinja @@ -46,7 +46,7 @@

Running Specter behind a Tor hidden service allows you to acccess Specter over Tor from anywhere.

{% if tor_service_id %} - {% if specter.config.auth == "none" %} + {% if specter.config.auth.method == "none" %}

⚠️ Warning!
Your are running Specter over Tor with no authentication settings configured. diff --git a/src/cryptoadvance/specter/util/tor.py b/src/cryptoadvance/specter/util/tor.py index 5a532865d..0197cb9d5 100644 --- a/src/cryptoadvance/specter/util/tor.py +++ b/src/cryptoadvance/specter/util/tor.py @@ -40,7 +40,7 @@ def start_hidden_service(app): f.write("%s.onion" % app.tor_service_id) app.tor_service_id = app.tor_service_id app.tor_enabled = True - if app.specter.config.get("auth", "none") == "none": + if app.specter.config["auth"].get("method", "none") == "none": print(" * ############################# Warning! #############################") print( " * Your are running Specter over Tor with no authentication settings configured." diff --git a/tests/conftest.py b/tests/conftest.py index c0e51ef1c..17dd49aff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -258,7 +258,9 @@ def specter_regtest_configured(bitcoin_regtest, devices_filled_data_folder): "host": bitcoin_regtest.rpcconn.ipaddress, "protocol": "http", }, - "auth": "rpcpasswordaspin", + "auth": { + "method": "rpcpasswordaspin", + }, } specter = Specter(data_folder=devices_filled_data_folder, config=config) specter.check()