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 @@