mirror of
https://github.com/bunkerity/bunkerweb
synced 2026-05-24 09:28:37 +00:00
Add back-end logic for 2FA in UI
This commit is contained in:
parent
398be91471
commit
1920d89b49
10 changed files with 395 additions and 107 deletions
1
.gitleaksignore
Normal file
1
.gitleaksignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
src/ui/templates/profile.html:hashicorp-tf-password:343
|
||||
|
|
@ -1674,18 +1674,24 @@ class Database:
|
|||
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()
|
||||
user = session.query(Users).with_entities(Users.username, Users.password, Users.is_two_factor_enabled, Users.secret_token, Users.method).filter_by(id=1).first()
|
||||
if not user:
|
||||
return None
|
||||
return {"username": user.username, "password_hash": user.password.encode("utf-8")}
|
||||
return {
|
||||
"username": user.username,
|
||||
"password_hash": user.password.encode("utf-8"),
|
||||
"is_two_factor_enabled": user.is_two_factor_enabled,
|
||||
"secret_token": user.secret_token,
|
||||
"method": user.method,
|
||||
}
|
||||
|
||||
def create_ui_user(self, username: str, password: bytes) -> str:
|
||||
def create_ui_user(self, username: str, password: bytes, *, secret_token: Optional[str] = None, method: str = "manual") -> 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")))
|
||||
session.add(Users(id=1, username=username, password=password.decode("utf-8"), secret_token=secret_token, method=method))
|
||||
|
||||
try:
|
||||
session.commit()
|
||||
|
|
@ -1694,7 +1700,7 @@ class Database:
|
|||
|
||||
return ""
|
||||
|
||||
def update_ui_user(self, username: str, password: bytes) -> str:
|
||||
def update_ui_user(self, username: str, password: bytes, is_two_factor_enabled: bool = False, secret_token: Optional[str] = None) -> str:
|
||||
"""Update ui user."""
|
||||
with self.__db_session() as session:
|
||||
user = session.query(Users).filter_by(id=1).first()
|
||||
|
|
@ -1703,6 +1709,8 @@ class Database:
|
|||
|
||||
user.username = username
|
||||
user.password = password.decode("utf-8")
|
||||
user.is_two_factor_enabled = is_two_factor_enabled
|
||||
user.secret_token = secret_token
|
||||
|
||||
try:
|
||||
session.commit()
|
||||
|
|
|
|||
|
|
@ -257,6 +257,9 @@ class Users(Base):
|
|||
id = Column(Integer, primary_key=True, default=1)
|
||||
username = Column(String(256), nullable=False, unique=True)
|
||||
password = Column(String(60), nullable=False)
|
||||
is_two_factor_enabled = Column(Boolean, nullable=False, default=False)
|
||||
secret_token = Column(String(32), nullable=True, unique=True)
|
||||
method = Column(METHODS_ENUM, nullable=False, default="manual")
|
||||
|
||||
|
||||
class Metadata(Base):
|
||||
|
|
|
|||
267
src/ui/main.py
267
src/ui/main.py
|
|
@ -61,7 +61,6 @@ from threading import Thread
|
|||
from tempfile import NamedTemporaryFile
|
||||
from time import sleep, time
|
||||
from traceback import format_exc
|
||||
from typing import Optional
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from src.Instances import Instances
|
||||
|
|
@ -70,7 +69,7 @@ from src.Config import Config
|
|||
from src.ReverseProxied import ReverseProxied
|
||||
from src.User import User
|
||||
|
||||
from utils import check_settings, path_to_dict
|
||||
from utils import check_settings, get_b64encoded_qr_image, path_to_dict
|
||||
from Database import Database # type: ignore
|
||||
from logging import getLogger
|
||||
|
||||
|
|
@ -148,21 +147,13 @@ elif INTEGRATION == "Kubernetes":
|
|||
|
||||
db = Database(app.logger, ui=True)
|
||||
|
||||
if INTEGRATION in (
|
||||
"Swarm",
|
||||
"Kubernetes",
|
||||
"Autoconf",
|
||||
):
|
||||
if INTEGRATION in ("Swarm", "Kubernetes", "Autoconf"):
|
||||
while not db.is_autoconf_loaded():
|
||||
app.logger.warning(
|
||||
"Autoconf is not loaded yet in the database, retrying in 5s ...",
|
||||
)
|
||||
app.logger.warning("Autoconf is not loaded yet in the database, retrying in 5s ...")
|
||||
sleep(5)
|
||||
|
||||
while not db.is_initialized():
|
||||
app.logger.warning(
|
||||
"Database is not initialized, retrying in 5s ...",
|
||||
)
|
||||
app.logger.warning("Database is not initialized, retrying in 5s ...")
|
||||
sleep(5)
|
||||
|
||||
USER = "Error"
|
||||
|
|
@ -174,16 +165,37 @@ USER_PASSWORD_RX = re_compile(r"^(?=.*?\p{Lowercase_Letter})(?=.*?\p{Uppercase_L
|
|||
|
||||
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 getenv("ADMIN_USERNAME") or getenv("ADMIN_PASSWORD"):
|
||||
if USER.method == "manual":
|
||||
updated = False
|
||||
if getenv("ADMIN_USERNAME", "") and USER.get_id() != getenv("ADMIN_USERNAME", ""):
|
||||
USER.id = getenv("ADMIN_USERNAME", "")
|
||||
updated = True
|
||||
if getenv("ADMIN_PASSWORD", "") and not USER.check_password(getenv("ADMIN_PASSWORD", "")):
|
||||
USER.update_password(getenv("ADMIN_PASSWORD", ""))
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
ret = db.update_ui_user(USER.get_id(), USER.password_hash, USER.is_two_factor_enabled, app.config["USER"].secret_token)
|
||||
if ret:
|
||||
app.logger.error(f"Couldn't update the admin user in the database: {ret}")
|
||||
stop(1)
|
||||
app.logger.info("The admin user was updated successfully")
|
||||
else:
|
||||
app.logger.error("The admin user wasn't created manually. You can't change it from the environment variables.")
|
||||
elif getenv("FLASK_DEBUG", False) or getenv("ADMIN_USERNAME") and getenv("ADMIN_PASSWORD"):
|
||||
if not getenv("FLASK_DEBUG", False):
|
||||
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)
|
||||
elif 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_name = getenv("ADMIN_USERNAME", "admin")
|
||||
USER = User(user_name, getenv("ADMIN_PASSWORD", "changeme"))
|
||||
ret = db.create_ui_user(user_name, USER.password_hash)
|
||||
|
||||
if ret:
|
||||
app.logger.error(f"Couldn't create the admin user in the database: {ret}")
|
||||
|
|
@ -265,6 +277,7 @@ def manage_bunkerweb(method: str, *args, operation: str = "reloads"):
|
|||
# update changes in db
|
||||
ret = db.checked_changes(changes, value=True)
|
||||
if ret:
|
||||
app.logger.error(f"Couldn't set the changes to checked in the database: {ret}")
|
||||
app.config["TO_FLASH"].append({"content": f"An error occurred when setting the changes to checked in the database : {ret}", "type": "error"})
|
||||
if method == "global_config":
|
||||
operation = app.config["CONFIG"].edit_global_conf(args[0])
|
||||
|
|
@ -319,22 +332,25 @@ def handle_csrf_error(_):
|
|||
session.clear()
|
||||
logout_user()
|
||||
flash("Wrong CSRF token !", "error")
|
||||
return render_template("login.html"), 403
|
||||
if not app.config["USER"]:
|
||||
return render_template("setup.html"), 403
|
||||
return render_template("login.html", is_totp=app.config["USER"].is_two_factor_enabled), 403
|
||||
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
if app.config["USER"]:
|
||||
if current_user.is_authenticated:
|
||||
passed = True
|
||||
if session.get("ip") != request.remote_addr:
|
||||
passed = False
|
||||
elif session.get("user_agent") != request.headers.get("User-Agent"):
|
||||
passed = False
|
||||
if app.config["USER"] and current_user.is_authenticated:
|
||||
passed = True
|
||||
if not session.get("totp_validated", False) and app.config["USER"].is_two_factor_enabled and "/totp" not in request.path and not request.path.startswith(("/css", "/images", "/js", "/json", "/webfonts")):
|
||||
return redirect(url_for("totp", next=request.form.get("next")))
|
||||
elif session.get("ip") != request.remote_addr:
|
||||
passed = False
|
||||
elif session.get("user_agent") != request.headers.get("User-Agent"):
|
||||
passed = False
|
||||
|
||||
if not passed:
|
||||
logout_user()
|
||||
session.clear()
|
||||
if not passed:
|
||||
logout_user()
|
||||
session.clear()
|
||||
|
||||
|
||||
@app.route("/")
|
||||
|
|
@ -349,13 +365,7 @@ def index():
|
|||
@app.route("/loading")
|
||||
@login_required
|
||||
def loading():
|
||||
next_url: str = request.values.get("next", None) or url_for("home")
|
||||
message: Optional[str] = request.values.get("message", None)
|
||||
return render_template(
|
||||
"loading.html",
|
||||
message=message if message is not None else "Loading",
|
||||
next=next_url,
|
||||
)
|
||||
return render_template("loading.html", message=request.values.get("message", "Loading"), next=request.values.get("next", None) or url_for("home"))
|
||||
|
||||
|
||||
@app.route("/check", methods=["GET"])
|
||||
|
|
@ -414,12 +424,13 @@ def setup():
|
|||
error = True
|
||||
|
||||
if error:
|
||||
return redirect(url_for("setup"), 400)
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
app.config["USER"] = User(request.form["admin_username"], request.form["admin_password"])
|
||||
app.config["USER"] = User(request.form["admin_username"], request.form["admin_password"], method="ui")
|
||||
|
||||
ret = db.create_ui_user(request.form["admin_username"], app.config["USER"].password_hash)
|
||||
ret = db.create_ui_user(request.form["admin_username"], app.config["USER"].password_hash, method="ui")
|
||||
if ret:
|
||||
app.logger.error(f"Couldn't create the admin user in the database: {ret}")
|
||||
flash(f"Couldn't create the admin user in the database: {ret}", "error")
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
|
|
@ -458,6 +469,31 @@ def setup():
|
|||
)
|
||||
|
||||
|
||||
@app.route("/totp", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def totp():
|
||||
if request.method == "POST":
|
||||
if not request.form:
|
||||
flash("Missing form data.", "error")
|
||||
return redirect(url_for("totp"))
|
||||
|
||||
if "totp_token" not in request.form:
|
||||
flash("Missing token parameter.", "error")
|
||||
return redirect(url_for("totp"))
|
||||
|
||||
if not app.config["USER"].check_otp(request.form["totp_token"]):
|
||||
flash("The token is invalid.", "error")
|
||||
return redirect(url_for("totp"))
|
||||
|
||||
session["totp_validated"] = True
|
||||
redirect(url_for("loading", next=request.form.get("next") or url_for("home")))
|
||||
|
||||
if app.config["USER"] and (not app.config["USER"].is_two_factor_enabled or session.get("totp_validated", False)):
|
||||
return redirect(url_for("home"))
|
||||
|
||||
return render_template("totp.html", dark_mode=app.config["DARK_MODE"])
|
||||
|
||||
|
||||
@app.route("/home")
|
||||
@login_required
|
||||
def home():
|
||||
|
|
@ -471,13 +507,8 @@ def home():
|
|||
services_number: the number of services
|
||||
posts: a list of posts
|
||||
"""
|
||||
|
||||
try:
|
||||
r = get(
|
||||
"https://github.com/bunkerity/bunkerweb/releases/latest",
|
||||
allow_redirects=True,
|
||||
timeout=5,
|
||||
)
|
||||
r = get("https://github.com/bunkerity/bunkerweb/releases/latest", allow_redirects=True, timeout=5)
|
||||
r.raise_for_status()
|
||||
except BaseException:
|
||||
r = None
|
||||
|
|
@ -531,53 +562,104 @@ def profile():
|
|||
flash("Missing form data.", "error")
|
||||
return redirect(url_for("profile"))
|
||||
|
||||
if "curr_password" not in request.form:
|
||||
flash("Missing curr_password parameter.", "error")
|
||||
return redirect(url_for("profile"))
|
||||
|
||||
error = False
|
||||
|
||||
if not app.config["USER"].check_password(request.form["curr_password"]):
|
||||
flash("The current password is incorrect.", "error")
|
||||
error = True
|
||||
|
||||
if request.form.get("admin_username") and len(request.form["admin_username"]) > 256:
|
||||
flash("The admin username is too long. It must be less than 256 characters.", "error")
|
||||
error = True
|
||||
|
||||
if request.form.get("admin_password"):
|
||||
if not request.form.get("admin_password_check"):
|
||||
flash("Missing admin_password_check parameter.", "error")
|
||||
if "curr_password" in request.form:
|
||||
if not app.config["USER"].check_password(request.form["curr_password"]):
|
||||
flash("The current password is incorrect.", "error")
|
||||
error = True
|
||||
elif request.form["admin_password"] != request.form["admin_password_check"]:
|
||||
flash("The passwords do not match.", "error")
|
||||
|
||||
if request.form.get("admin_username") and len(request.form["admin_username"]) > 256:
|
||||
flash("The admin username is too long. It must be less than 256 characters.", "error")
|
||||
error = True
|
||||
elif 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")
|
||||
|
||||
if request.form.get("admin_password"):
|
||||
if not request.form.get("admin_password_check"):
|
||||
flash("Missing admin_password_check parameter.", "error")
|
||||
error = True
|
||||
elif request.form["admin_password"] != request.form["admin_password_check"]:
|
||||
flash("The passwords do not match.", "error")
|
||||
error = True
|
||||
elif 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
|
||||
elif request.form.get("admin_password_check"):
|
||||
flash("Missing admin_password parameter.", "error")
|
||||
error = True
|
||||
elif request.form.get("admin_password_check"):
|
||||
flash("Missing admin_password parameter.", "error")
|
||||
error = True
|
||||
|
||||
if not error and not any(request.form.get(key) for key in ("admin_username", "admin_password")):
|
||||
flash("Nothing to update.")
|
||||
error = True
|
||||
if not error and not any(request.form.get(key) for key in ("admin_username", "admin_password")):
|
||||
flash("Nothing to update.")
|
||||
error = True
|
||||
|
||||
if error:
|
||||
return redirect(url_for("profile"), 400)
|
||||
if error:
|
||||
return redirect(url_for("profile"))
|
||||
|
||||
app.config["USER"] = User(request.form.get("admin_username") or app.config["USER"].get_id(), request.form.get("admin_password") or request.form["curr_password"])
|
||||
app.config["USER"] = User(
|
||||
request.form.get("admin_username") or app.config["USER"].get_id(),
|
||||
request.form.get("admin_password") or request.form["curr_password"],
|
||||
is_two_factor_enabled=app.config["USER"].is_two_factor_enabled,
|
||||
secret_token=app.config["USER"].secret_token,
|
||||
method=app.config["USER"].method,
|
||||
)
|
||||
|
||||
ret = db.update_ui_user(app.config["USER"].get_id(), app.config["USER"].password_hash)
|
||||
session.clear()
|
||||
logout_user()
|
||||
elif "totp_password" in request.form:
|
||||
if "totp_token" not in request.form:
|
||||
flash("Missing totp_token parameter.", "error")
|
||||
return redirect(url_for("profile"))
|
||||
|
||||
if not app.config["USER"].check_password(request.form.get("totp_password", "")):
|
||||
flash("The current password is incorrect.", "error")
|
||||
error = True
|
||||
|
||||
if not app.config["USER"].check_otp(request.form["totp_token"]):
|
||||
flash("The token is invalid.", "error")
|
||||
error = True
|
||||
|
||||
app.logger.warning(request.form["totp_password"])
|
||||
|
||||
if error:
|
||||
return redirect(url_for("profile"))
|
||||
|
||||
app.logger.warning("TOTP validated")
|
||||
|
||||
session["totp_validated"] = not app.config["USER"].is_two_factor_enabled
|
||||
|
||||
if app.config["USER"].is_two_factor_enabled:
|
||||
app.config["USER"].secret_token = None
|
||||
|
||||
app.config["USER"].is_two_factor_enabled = session["totp_validated"]
|
||||
|
||||
app.logger.warning(app.config["USER"])
|
||||
else:
|
||||
flash("Missing form data.", "error")
|
||||
return redirect(url_for("profile"))
|
||||
|
||||
ret = db.update_ui_user(
|
||||
app.config["USER"].get_id(),
|
||||
app.config["USER"].password_hash,
|
||||
app.config["USER"].is_two_factor_enabled,
|
||||
app.config["USER"].secret_token if app.config["USER"].is_two_factor_enabled else None,
|
||||
)
|
||||
if ret:
|
||||
app.logger.error(f"Couldn't update the admin user in the database: {ret}")
|
||||
flash(f"Couldn't update the admin user in the database: {ret}", "error")
|
||||
return redirect(url_for("profile"), 500)
|
||||
return redirect(url_for("profile"))
|
||||
|
||||
app.logger.warning("User updated")
|
||||
|
||||
session.clear()
|
||||
logout_user()
|
||||
return redirect(url_for("profile"))
|
||||
|
||||
return render_template("profile.html", username=app.config["USER"].get_id(), dark_mode=app.config["DARK_MODE"])
|
||||
secret_token = ""
|
||||
totp_qr_image = ""
|
||||
|
||||
if not app.config["USER"].is_two_factor_enabled:
|
||||
app.config["USER"].refresh_totp()
|
||||
secret_token = app.config["USER"].secret_token
|
||||
totp_qr_image = get_b64encoded_qr_image(app.config["USER"].get_authentication_setup_uri())
|
||||
|
||||
return render_template("profile.html", username=app.config["USER"].get_id(), is_totp=app.config["USER"].is_two_factor_enabled, secret_token=secret_token, totp_qr_image=totp_qr_image, dark_mode=app.config["DARK_MODE"])
|
||||
|
||||
|
||||
@app.route("/instances", methods=["GET", "POST"])
|
||||
|
|
@ -827,7 +909,7 @@ def configs():
|
|||
|
||||
if operation:
|
||||
flash(operation, "error")
|
||||
return redirect(url_for("loading", next=url_for("configs"))), 500
|
||||
return redirect(url_for("loading", next=url_for("configs")))
|
||||
|
||||
if request.form["operation"] in ("new", "edit"):
|
||||
if not app.config["CONFIGFILES"].check_name(variables["name"]):
|
||||
|
|
@ -913,7 +995,7 @@ def plugins():
|
|||
|
||||
if variables["external"] != "True":
|
||||
flash(f"Can't delete internal plugin {variables['name']}", "error")
|
||||
return redirect(url_for("loading", next=url_for("plugins"))), 500
|
||||
return redirect(url_for("loading", next=url_for("plugins")))
|
||||
|
||||
plugins = app.config["CONFIG"].get_plugins()
|
||||
for plugin in deepcopy(plugins):
|
||||
|
|
@ -1580,28 +1662,27 @@ def login():
|
|||
if request.method == "POST" and "username" in request.form and "password" in request.form:
|
||||
if app.config["USER"].get_id() == request.form["username"] and app.config["USER"].check_password(request.form["password"]):
|
||||
# log the user in
|
||||
next_url = request.form.get("next")
|
||||
session["ip"] = request.remote_addr
|
||||
session["user_agent"] = request.headers.get("User-Agent")
|
||||
session["totp_validated"] = False
|
||||
login_user(app.config["USER"], duration=timedelta(hours=1))
|
||||
|
||||
# redirect him to the page he originally wanted or to the home page
|
||||
return redirect(url_for("loading", next=next_url or url_for("home")))
|
||||
return redirect(url_for("loading", next=request.form.get("next") or url_for("home")))
|
||||
else:
|
||||
flash("Invalid username or password", "error")
|
||||
fail = True
|
||||
|
||||
if fail:
|
||||
return (
|
||||
render_template("login.html", error="Invalid username or password"),
|
||||
401,
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
kwargs = {
|
||||
"is_totp": app.config["USER"].is_two_factor_enabled,
|
||||
} | ({"error": "Invalid username or password"} if fail else {})
|
||||
|
||||
return render_template("login.html", **kwargs), 401 if fail else 200
|
||||
|
||||
|
||||
@app.route("/darkmode", methods=["POST"])
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ Flask-Login==0.6.3
|
|||
Flask_WTF==1.2.1
|
||||
gunicorn[gthread]==21.2.0
|
||||
importlib-metadata==7.0.1
|
||||
pyotp==2.9.0
|
||||
python_dateutil==2.8.2
|
||||
qrcode==7.4.2
|
||||
regex==2023.12.25
|
||||
werkzeug==3.0.1
|
||||
|
|
|
|||
|
|
@ -151,10 +151,22 @@ packaging==23.2 \
|
|||
--hash=sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5 \
|
||||
--hash=sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7
|
||||
# via gunicorn
|
||||
pyotp==2.9.0 \
|
||||
--hash=sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63 \
|
||||
--hash=sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612
|
||||
# via -r requirements.in
|
||||
pypng==0.20220715.0 \
|
||||
--hash=sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c \
|
||||
--hash=sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1
|
||||
# via qrcode
|
||||
python-dateutil==2.8.2 \
|
||||
--hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
|
||||
--hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9
|
||||
# via -r requirements.in
|
||||
qrcode==7.4.2 \
|
||||
--hash=sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a \
|
||||
--hash=sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845
|
||||
# via -r requirements.in
|
||||
regex==2023.12.25 \
|
||||
--hash=sha256:0694219a1d54336fd0445ea382d49d36882415c0134ee1e8332afd1529f0baa5 \
|
||||
--hash=sha256:086dd15e9435b393ae06f96ab69ab2d333f5d65cbe65ca5a3ef0ec9564dfe770 \
|
||||
|
|
@ -258,6 +270,10 @@ soupsieve==2.5 \
|
|||
--hash=sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690 \
|
||||
--hash=sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7
|
||||
# via beautifulsoup4
|
||||
typing-extensions==4.9.0 \
|
||||
--hash=sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783 \
|
||||
--hash=sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd
|
||||
# via qrcode
|
||||
werkzeug==3.0.1 \
|
||||
--hash=sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc \
|
||||
--hash=sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10
|
||||
|
|
|
|||
|
|
@ -1,18 +1,34 @@
|
|||
#!/usr/bin/python3
|
||||
|
||||
from typing import Optional
|
||||
from flask_login import UserMixin
|
||||
|
||||
from bcrypt import checkpw, hashpw, gensalt
|
||||
from flask_login import UserMixin
|
||||
from pyotp import random_base32
|
||||
from pyotp.totp import TOTP
|
||||
|
||||
|
||||
class User(UserMixin):
|
||||
def __init__(self, username: str, password: Optional[str] = None, password_hash: Optional[bytes] = None):
|
||||
def __init__(
|
||||
self,
|
||||
username: str,
|
||||
password: Optional[str] = None,
|
||||
*,
|
||||
is_two_factor_enabled: bool = False,
|
||||
password_hash: Optional[bytes] = None,
|
||||
secret_token: Optional[str] = None,
|
||||
method: str = "manual",
|
||||
):
|
||||
self.id = username
|
||||
|
||||
if not password:
|
||||
assert password_hash, "Either password or password_hash must be provided"
|
||||
|
||||
self.__password = password_hash or hashpw(password.encode("utf-8"), gensalt()) # type: ignore
|
||||
self.__password = password_hash or hashpw(password.encode("utf-8"), gensalt(rounds=13)) # type: ignore
|
||||
self.is_two_factor_enabled = is_two_factor_enabled
|
||||
self.secret_token = secret_token
|
||||
self.method = method
|
||||
self.__totp = None
|
||||
|
||||
@property
|
||||
def password_hash(self) -> bytes:
|
||||
|
|
@ -23,6 +39,14 @@ class User(UserMixin):
|
|||
"""
|
||||
return self.__password
|
||||
|
||||
def update_password(self, password: str):
|
||||
"""
|
||||
Set the password by hashing it
|
||||
|
||||
:param password: The password to be hashed
|
||||
"""
|
||||
self.__password = hashpw(password.encode("utf-8"), gensalt(rounds=13))
|
||||
|
||||
def check_password(self, password: str):
|
||||
"""
|
||||
Check if the password is correct by hashing it and comparing it to the stored hash
|
||||
|
|
@ -32,3 +56,27 @@ class User(UserMixin):
|
|||
the user is returned.
|
||||
"""
|
||||
return checkpw(password.encode("utf-8"), self.__password)
|
||||
|
||||
def get_authentication_setup_uri(self) -> str:
|
||||
if not self.__totp:
|
||||
return ""
|
||||
return self.__totp.provisioning_uri(name=self.id, issuer_name="BunkerWeb UI")
|
||||
|
||||
def refresh_totp(self):
|
||||
self.secret_token = random_base32()
|
||||
self.__totp = TOTP(self.secret_token)
|
||||
|
||||
def check_otp(self, otp: str) -> bool:
|
||||
"""
|
||||
Check if the otp is correct by comparing it to the stored secret token
|
||||
|
||||
:param otp: The otp to be checked
|
||||
:return: The otp is being checked against the secret token. If the otp is correct,
|
||||
the user is returned.
|
||||
"""
|
||||
if not self.__totp:
|
||||
return False
|
||||
return self.__totp.verify(otp, valid_window=3)
|
||||
|
||||
def __repr__(self):
|
||||
return f"User({self.id!r}, {self.__password!r}, {self.is_two_factor_enabled!r}, {self.secret_token!r}, {self.method!r})"
|
||||
|
|
|
|||
51
src/ui/templates/profile.html
vendored
51
src/ui/templates/profile.html
vendored
|
|
@ -113,6 +113,7 @@ url_for(request.endpoint)[1:].split("/")[-1].strip() %}
|
|||
<button
|
||||
data-setting-password="invisible"
|
||||
class="hidden -translate-y-0.2 scale-110 h-5 w-5 items-center align-middle"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="fill-primary pointer-events-none dark:fill-blue-500 hover:brightness-75 transition-all"
|
||||
|
|
@ -170,6 +171,7 @@ url_for(request.endpoint)[1:].split("/")[-1].strip() %}
|
|||
<button
|
||||
data-setting-password="invisible"
|
||||
class="hidden -translate-y-0.2 scale-110 h-5 w-5 items-center align-middle"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="fill-primary pointer-events-none dark:fill-blue-500 hover:brightness-75 transition-all"
|
||||
|
|
@ -229,6 +231,7 @@ url_for(request.endpoint)[1:].split("/")[-1].strip() %}
|
|||
<button
|
||||
data-setting-password="invisible"
|
||||
class="hidden -translate-y-0.2 scale-110 h-5 w-5 items-center align-middle"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="fill-primary pointer-events-none dark:fill-blue-500 hover:brightness-75 transition-all"
|
||||
|
|
@ -305,18 +308,53 @@ url_for(request.endpoint)[1:].split("/")[-1].strip() %}
|
|||
<h5
|
||||
class="text-base my-1 transition duration-300 ease-in-out text-md font-bold m-0"
|
||||
>
|
||||
Secret key
|
||||
Secret token
|
||||
</h5>
|
||||
<label class="sr-only" for="secret-key">secret key</label>
|
||||
<label class="sr-only" for="secret_token">secret token</label>
|
||||
<input
|
||||
type="text"
|
||||
id="secret-key"
|
||||
name="secret-key"
|
||||
type="password"
|
||||
id="secret_token"
|
||||
name="secret_token"
|
||||
class="col-span-12 regular-input"
|
||||
placeholder="secret token"
|
||||
value="{{ secret_token }}"
|
||||
readonly
|
||||
/>
|
||||
<div
|
||||
data-setting-password-container
|
||||
class="absolute flex right-8 h-5 w-5 top-[60%] md:top-[45%] lg:top-11"
|
||||
>
|
||||
<button
|
||||
data-setting-password="visible"
|
||||
class="h-5 w-5 flex items-center align-middle"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="fill-primary pointer-events-none dark:fill-blue-500 hover:brightness-75 transition-all"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 576 512"
|
||||
>
|
||||
<path
|
||||
d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM432 256c0 79.5-64.5 144-144 144s-144-64.5-144-144s64.5-144 144-144s144 64.5 144 144zM288 192c0 35.3-28.7 64-64 64c-11.5 0-22.3-3-31.6-8.4c-.2 2.8-.4 5.5-.4 8.4c0 53 43 96 96 96s96-43 96-96s-43-96-96-96c-2.8 0-5.6 .1-8.4 .4c5.3 9.3 8.4 20.1 8.4 31.6z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
data-setting-password="invisible"
|
||||
class="hidden -translate-y-0.2 scale-110 h-5 w-5 items-center align-middle"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="fill-primary pointer-events-none dark:fill-blue-500 hover:brightness-75 transition-all"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 640 512"
|
||||
>
|
||||
<path
|
||||
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c5.2-11.8 8-24.8 8-38.5c0-53-43-96-96-96c-2.8 0-5.6 .1-8.4 .4c5.3 9.3 8.4 20.1 8.4 31.6c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zm223.1 298L373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- end secret -->
|
||||
{% endif %} {% if is_totp or not is_totp %}
|
||||
|
|
@ -386,6 +424,7 @@ url_for(request.endpoint)[1:].split("/")[-1].strip() %}
|
|||
<button
|
||||
data-setting-password="invisible"
|
||||
class="hidden -translate-y-0.2 scale-110 h-5 w-5 items-center align-middle"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="fill-primary pointer-events-none dark:fill-blue-500 hover:brightness-75 transition-all"
|
||||
|
|
@ -409,7 +448,7 @@ url_for(request.endpoint)[1:].split("/")[-1].strip() %}
|
|||
value="profile"
|
||||
class="valid-btn"
|
||||
>
|
||||
{% if not is_totp %} enabled totp {% else %} disabled totp {% endif %}
|
||||
{% if not is_totp %} enable totp {% else %} disable totp {% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
76
src/ui/templates/totp.html
vendored
76
src/ui/templates/totp.html
vendored
|
|
@ -126,6 +126,82 @@
|
|||
</div>
|
||||
<!-- end form -->
|
||||
</main>
|
||||
<script>
|
||||
class Loader {
|
||||
constructor() {
|
||||
this.menuContainer = document.querySelector("[data-menu-container]");
|
||||
this.logoContainer = document.querySelector("[data-loader]");
|
||||
this.logoEl = document.querySelector("[data-loader-img]");
|
||||
this.isLoading = true;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loading();
|
||||
window.addEventListener("load", (e) => {
|
||||
setTimeout(() => {
|
||||
this.logoContainer.classList.add("opacity-0");
|
||||
}, 350);
|
||||
|
||||
setTimeout(() => {
|
||||
this.isLoading = false;
|
||||
this.logoContainer.classList.add("hidden");
|
||||
}, 650);
|
||||
|
||||
setTimeout(() => {
|
||||
this.logoContainer.remove();
|
||||
}, 800);
|
||||
});
|
||||
}
|
||||
|
||||
loading() {
|
||||
if ((this.isLoading = true)) {
|
||||
setTimeout(() => {
|
||||
this.logoEl.classList.toggle("scale-105");
|
||||
this.loading();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FlashMsg {
|
||||
constructor() {
|
||||
this.delayBeforeRemove = 8000;
|
||||
this.init();
|
||||
}
|
||||
|
||||
//remove flash message after this.delay if exist
|
||||
init() {
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
try {
|
||||
const flashEl = document.querySelector("[data-flash-message]");
|
||||
setTimeout(() => {
|
||||
try {
|
||||
flashEl.remove();
|
||||
} catch (err) {}
|
||||
}, this.delayBeforeRemove);
|
||||
} catch (err) {}
|
||||
});
|
||||
|
||||
window.addEventListener("click", (e) => {
|
||||
try {
|
||||
if (
|
||||
e.target
|
||||
.closest("button")
|
||||
.hasAttribute("data-close-flash-message")
|
||||
) {
|
||||
const closeBtn = e.target.closest("button");
|
||||
const flashEl = closeBtn.closest("[data-flash-message]");
|
||||
flashEl.remove();
|
||||
}
|
||||
} catch (err) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const setLoader = new Loader();
|
||||
const setFlash = new FlashMsg();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
#!/usr/bin/python3
|
||||
|
||||
from base64 import b64encode
|
||||
from io import BytesIO
|
||||
from os.path import join
|
||||
from typing import List, Optional
|
||||
|
||||
from qrcode.main import QRCode
|
||||
|
||||
|
||||
def path_to_dict(
|
||||
path: str,
|
||||
|
|
@ -130,3 +134,13 @@ def path_to_dict(
|
|||
|
||||
def check_settings(settings: dict, check: str) -> bool:
|
||||
return any(setting["context"] == check for setting in settings.values())
|
||||
|
||||
|
||||
def get_b64encoded_qr_image(data: str):
|
||||
qr = QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="#0b5577", back_color="white")
|
||||
buffered = BytesIO()
|
||||
img.save(buffered)
|
||||
return b64encode(buffered.getvalue()).decode("utf-8")
|
||||
|
|
|
|||
Loading…
Reference in a new issue