Add back-end logic for 2FA in UI

This commit is contained in:
Théophile Diot 2023-12-27 15:35:29 +00:00
parent 398be91471
commit 1920d89b49
No known key found for this signature in database
GPG key ID: 248FEA4BAE400D06
10 changed files with 395 additions and 107 deletions

1
.gitleaksignore Normal file
View file

@ -0,0 +1 @@
src/ui/templates/profile.html:hashicorp-tf-password:343

View file

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

View file

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

View file

@ -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"])

View file

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

View file

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

View file

@ -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})"

View file

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

View file

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

View file

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