Create web UI wizard functionality (backside)

This commit is contained in:
Théophile Diot 2023-11-17 13:11:54 +00:00
parent 2964669d90
commit b4d790aad5
No known key found for this signature in database
GPG key ID: 248FEA4BAE400D06
5 changed files with 172 additions and 34 deletions

View file

@ -28,6 +28,7 @@ from model import (
Jobs_cache,
Custom_configs,
Selects,
Users,
Metadata,
)
@ -1505,3 +1506,26 @@ class Database:
return None
return page.template_file
def get_ui_user(self) -> Optional[dict]:
"""Get ui user."""
with self.__db_session() as session:
user = session.query(Users).with_entities(Users.username, Users.password).filter_by(id=1).first()
if not user:
return None
return {"username": user.username, "password_hash": user.password.encode("utf-8")}
def create_ui_user(self, username: str, password: bytes) -> str:
"""Create ui user."""
with self.__db_session() as session:
if self.get_ui_user():
return "User already exists"
session.add(Users(id=1, username=username, password=password.decode("utf-8")))
try:
session.commit()
except BaseException:
return format_exc()
return ""

View file

@ -251,6 +251,14 @@ class Instances(Base):
server_name = Column(String(256), nullable=False)
class Users(Base):
__tablename__ = "bw_ui_users"
id = Column(Integer, primary_key=True, default=1)
username = Column(String(256), nullable=False, unique=True)
password = Column(String(60), nullable=False)
class Metadata(Base):
__tablename__ = "bw_metadata"

View file

@ -52,8 +52,7 @@ from jinja2 import Template
from kubernetes import client as kube_client
from kubernetes import config as kube_config
from kubernetes.client.exceptions import ApiException as kube_ApiException
from re import compile as re_compile
from regex import match as regex_match
from regex import compile as re_compile, match as regex_match
from requests import get
from shutil import move, rmtree
from signal import SIGINT, signal, SIGTERM
@ -122,24 +121,10 @@ gunicorn_logger = getLogger("gunicorn.error")
app.logger.handlers = gunicorn_logger.handlers
app.logger.setLevel(gunicorn_logger.level)
if not getenv("ADMIN_USERNAME"):
app.logger.error("ADMIN_USERNAME is not set")
stop(1)
elif not getenv("ADMIN_PASSWORD"):
app.logger.error("ADMIN_PASSWORD is not set")
stop(1)
if not getenv("FLASK_DEBUG", False) and not regex_match(
r"^(?=.*?\p{Lowercase_Letter})(?=.*?\p{Uppercase_Letter})(?=.*?\d)(?=.*?[ !\"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]).{8,}$",
getenv("ADMIN_PASSWORD", "changeme"),
):
app.logger.error("The admin password is not strong enough. It must contain at least 8 characters, including at least 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character (#@?!$%^&*-).")
stop(1)
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
user = User(getenv("ADMIN_USERNAME", "admin"), getenv("ADMIN_PASSWORD", "changeme"))
PLUGIN_KEYS = [
"id",
"name",
@ -192,6 +177,26 @@ while not db.is_initialized():
)
sleep(5)
USER = db.get_ui_user()
USER_PASSWORD_RX = re_compile(r"^(?=.*?\p{Lowercase_Letter})(?=.*?\p{Uppercase_Letter})(?=.*?\d)(?=.*?[ !\"#$%&'()*+,./:;<=>?@[\\\]^_`{|}~-]).{8,}$")
if USER:
USER = User(**USER)
elif getenv("ADMIN_USERNAME") and getenv("ADMIN_PASSWORD"):
if len(getenv("ADMIN_USERNAME", "admin")) > 256:
app.logger.error("The admin username is too long. It must be less than 256 characters.")
stop(1)
if not getenv("FLASK_DEBUG", False) and not USER_PASSWORD_RX.match(getenv("ADMIN_PASSWORD", "changeme")):
app.logger.error("The admin password is not strong enough. It must contain at least 8 characters, including at least 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character (#@?!$%^&*-).")
stop(1)
USER = User(getenv("ADMIN_USERNAME", "admin"), getenv("ADMIN_PASSWORD", "changeme"))
ret = db.create_ui_user(USER.get_id(), USER.password_hash)
if ret:
app.logger.error(f"Couldn't create the admin user in the database: {ret}")
stop(1)
app.logger.info("Database is ready")
Path(sep, "var", "tmp", "bunkerweb", "ui.healthy").write_text("ok", encoding="utf-8")
bw_version = Path(sep, "usr", "share", "bunkerweb", "VERSION").read_text(encoding="utf-8").strip()
@ -204,7 +209,7 @@ try:
CONFIG=Config(db),
CONFIGFILES=ConfigFiles(app.logger, db),
WTF_CSRF_SSL_STRICT=False,
USER=user,
USER=USER,
SEND_FILE_MAX_AGE_DEFAULT=86400,
PLUGIN_ARGS={},
RELOADING=False,
@ -226,6 +231,7 @@ csrf = CSRFProtect()
csrf.init_app(app)
LOG_RX = re_compile(r"^(?P<date>\d+/\d+/\d+\s\d+:\d+:\d+)\s\[(?P<level>[a-z]+)\]\s\d+#\d+:\s(?P<message>[^\n]+)$")
REVERSE_PROXY_PATH = re_compile(r"^(?P<host>https?://[a-zA-Z0-9.-]{1,255}(:((6553[0-5])|(655[0-2]\d)|(65[0-4]\d{2})|(6[0-4]\d{3})|([1-5]\d{4})|([0-5]{0,5})|(\d{1,4})))?)(?P<url>/.*)?$")
def manage_bunkerweb(method: str, *args, operation: str = "reloads"):
@ -307,7 +313,7 @@ def set_csp_header(response):
@login_manager.user_loader
def load_user(user_id):
return User(user_id, getenv("ADMIN_PASSWORD", "changeme"))
return app.config["USER"] if app.config["USER"] and user_id == app.config["USER"].get_id() else None
@app.errorhandler(CSRFError)
@ -325,7 +331,11 @@ def handle_csrf_error(_):
@app.route("/")
def index():
return redirect(url_for("login"))
if app.config["USER"]:
if current_user.is_authenticated: # type: ignore
return redirect(url_for("home"))
return redirect(url_for("login"), 301)
return redirect(url_for("setup"))
@app.route("/loading")
@ -340,6 +350,89 @@ def loading():
)
@app.route("/setup", methods=["GET", "POST"])
def setup():
if app.config["USER"]:
if current_user.is_authenticated: # type: ignore
return redirect(url_for("home"))
return redirect(url_for("login"), 301)
if request.method == "POST":
if not request.form:
flash("Missing form data.", "error")
return redirect(url_for("setup"))
if not any(key in request.form for key in ("admin_username", "admin_password", "server_name", "hostname")):
flash("Missing either admin_username, admin_password, server_name or hostname parameter.", "error")
return redirect(url_for("setup"))
error = False
if len(request.form["admin_username"]) > 256:
flash("The admin username is too long. It must be less than 256 characters.", "error")
error = True
if not USER_PASSWORD_RX.match(request.form["admin_password"]):
flash("The admin password is not strong enough. It must contain at least 8 characters, including at least 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character (#@?!$%^&*-).", "error")
error = True
db_config = app.config["CONFIG"].get_config(methods=False)
server_names = db_config["SERVER_NAME"].split(" ")
if request.form["server_name"] in server_names:
flash(f"The hostname {request.form['server_name']} is already in use.", "error")
error = True
else:
for server_name in server_names:
if request.form["server_name"] in db_config[f"{server_name}_SERVER_NAME"].split(" "):
flash(f"The hostname {request.form['server_name']} is already in use.", "error")
error = True
break
if not (hostname := REVERSE_PROXY_PATH.search(request.form["hostname"])):
flash("The hostname is not valid.", "error")
error = True
if error:
return redirect(url_for("setup"))
assert hostname
app.config["USER"] = User(request.form["admin_username"], request.form["admin_password"])
ret = db.create_ui_user(app.config["USER"].get_id(), app.config["USER"].password_hash)
if ret:
flash(f"Couldn't create the admin user in the database: {ret}", "error")
return redirect(url_for("setup"))
flash("The admin user was created successfully", "success")
app.config["RELOADING"] = True
app.config["LAST_RELOAD"] = time()
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=(
"services",
{
"SERVER_NAME": request.form["server_name"],
"USE_UI": "yes",
"USE_REVERSE_PROXY": "yes",
"REVERSE_PROXY_HOST": hostname.group("host"),
"REVERSE_PROXY_URL": hostname.group("url") or "/",
"INTERCEPTED_ERROR_CODES": "400 404 405 413 429 500 501 502 503 504",
},
request.form["server_name"],
request.form["server_name"],
),
kwargs={"operation": "new"},
).start()
return redirect(url_for("loading", next=url_for("services"), message=f"Creating service {request.form['server_name']} for the web UI"))
return render_template("setup.html", username=getenv("ADMIN_USERNAME", ""), password=getenv("ADMIN_PASSWORD", ""))
@app.route("/home")
@login_required
def home():
@ -1418,7 +1511,9 @@ def login():
401,
)
if current_user.is_authenticated:
if not app.config["USER"]:
return redirect(url_for("setup"))
elif current_user.is_authenticated: # type: ignore
return redirect(url_for("home"))
return render_template("login.html")

View file

@ -1,22 +1,30 @@
#!/usr/bin/python3
from functools import cached_property
from typing import Optional
from flask_login import UserMixin
from bcrypt import checkpw, hashpw, gensalt
class User(UserMixin):
def __init__(self, _id, password):
self.__id = _id
self.__password = hashpw(password.encode("utf-8"), gensalt())
def __init__(self, username: str, password: Optional[str] = None, password_hash: Optional[bytes] = None):
self.id = username
def get_id(self):
"""
Get the id of the user
:return: The id of the user
"""
return self.__id
if not password:
assert password_hash, "Either password or password_hash must be provided"
def check_password(self, password):
self.__password = password_hash or hashpw(password.encode("utf-8"), gensalt()) # type: ignore
@cached_property
def password_hash(self) -> bytes:
"""
Get the password hash
:return: The password hash
"""
return self.__password
def check_password(self, password: str):
"""
Check if the password is correct by hashing it and comparing it to the stored hash

View file

@ -83,7 +83,7 @@
<h1 class="block text-center font-bold dark:text-white mb-8 text-3xl">
Setup BunkerWeb
</h1>
<form action="/setup" method="POST" autocomplete="off">
<form action="setup" method="POST" autocomplete="off">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input
type="hidden"
@ -104,6 +104,7 @@
name="ADMIN_USERNAME"
class="col-span-12 dark:border-slate-600 dark:bg-slate-700 dark:text-gray-300 disabled:opacity-75 focus:valid:border-green-500 focus:invalid:border-red-500 outline-none focus:border-primary text-sm leading-5.6 ease block w-full appearance-none rounded-lg border border-solid border-gray-300 bg-white bg-clip-padding px-4 py-2 font-normal text-gray-700 transition-all placeholder:text-gray-500"
placeholder="enter username"
value="{{ username }}"
pattern="(.*?)"
maxlength="256"
required
@ -124,7 +125,9 @@
name="ADMIN_PASSWORD"
class="col-span-12 dark:border-slate-600 dark:bg-slate-700 dark:text-gray-300 disabled:opacity-75 focus:valid:border-green-500 focus:invalid:border-red-500 outline-none focus:border-primary text-sm leading-5.6 ease block w-full appearance-none rounded-lg border border-solid border-gray-300 bg-white bg-clip-padding px-4 py-2 font-normal text-gray-700 transition-all placeholder:text-gray-500"
placeholder="enter password"
pattern="(.*?)"
value="{{ password }}"
pattern="^(?=.*?\d)(?=.*?[ !\u0022#$%&'\(\)*+,.\/:;<=>?@\[\\\]^_`\u007B\u007C\u007D\u007E\u002D]).{8,}$"
minlength="8"
required
/>
</div>
@ -138,12 +141,12 @@
</h5>
<label class="sr-only" for="HOSTNAME">Hostname</label>
<input
type="password"
type="text"
id="HOSTNAME"
name="HOSTNAME"
class="col-span-12 dark:border-slate-600 dark:bg-slate-700 dark:text-gray-300 disabled:opacity-75 focus:valid:border-green-500 focus:invalid:border-red-500 outline-none focus:border-primary text-sm leading-5.6 ease block w-full appearance-none rounded-lg border border-solid border-gray-300 bg-white bg-clip-padding px-4 py-2 font-normal text-gray-700 transition-all placeholder:text-gray-500"
placeholder="enter hostname"
pattern="^https?:\/\/([a-zA-Z0-9.-]{1,255}(:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4})))?)(\/.*)$"
pattern="^https?:\/\/([a-zA-Z0-9.\u002D]{1,255}(:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4})))?)(\/.*)?$"
required
/>
</div>