From 56ca505dc945dd89f4e55200891e184aa2de6510 Mon Sep 17 00:00:00 2001 From: Matthieu B Date: Fri, 18 Jul 2025 15:47:02 +0200 Subject: [PATCH] feat: Implement passkey management for admin accounts - Added routes for resetting and viewing passkeys for admin accounts. - Integrated passkey checks during login to enforce 2FA when applicable. - Updated login template to handle passkey authentication and display relevant messages. - Created a recovery tool for password resets and passkey management in case of lockouts. - Enhanced admin management UI to include passkey options. - Documented recovery tool usage and scenarios for admin access recovery. --- app/blueprints/admin_accounts/routes.py | 38 ++- app/blueprints/auth/routes.py | 68 +++- app/blueprints/webauthn/routes.py | 54 +++- app/templates/components/admin_passkeys.html | 133 ++++++++ app/templates/login.html | 73 ++++- app/templates/settings/admins.html | 11 + app/templates/tables/invite_card.html | 16 +- docs/SUMMARY.md | 1 + .../using-wizarr/password-passkey-recovery.md | 213 +++++++++++++ recovery_tool.py | 296 ++++++++++++++++++ 10 files changed, 886 insertions(+), 17 deletions(-) create mode 100644 app/templates/components/admin_passkeys.html create mode 100644 docs/using-wizarr/password-passkey-recovery.md create mode 100755 recovery_tool.py diff --git a/app/blueprints/admin_accounts/routes.py b/app/blueprints/admin_accounts/routes.py index fa937d4c..9706130f 100644 --- a/app/blueprints/admin_accounts/routes.py +++ b/app/blueprints/admin_accounts/routes.py @@ -5,7 +5,7 @@ from flask_login import current_user, login_required from app.blueprints.admin.routes import admin_bp from app.extensions import db from app.forms.admin import AdminCreateForm, AdminUpdateForm -from app.models import AdminAccount +from app.models import AdminAccount, WebAuthnCredential admin_accounts_bp = Blueprint("admin_accounts", __name__, url_prefix="/settings/admins") @@ -150,3 +150,39 @@ def change_password(): return render_template( "components/password_result.html", error="Failed to change password" ) + + +@admin_accounts_bp.route("//reset-passkeys", methods=["POST"]) +@login_required +def reset_passkeys(admin_id): + """Reset all passkeys for a specific admin account.""" + admin = AdminAccount.query.get_or_404(admin_id) + + try: + # Delete all passkeys for this admin + WebAuthnCredential.query.filter_by(admin_account_id=admin_id).delete() + db.session.commit() + flash( + _("All passkeys for {} have been reset").format(admin.username), "success" + ) + except Exception: + db.session.rollback() + flash(_("Failed to reset passkeys for {}").format(admin.username), "error") + + return redirect(url_for("admin_accounts.list_admins")) + + +@admin_accounts_bp.route("//passkeys", methods=["GET"]) +@login_required +def admin_passkeys(admin_id): + """View passkeys for a specific admin account.""" + admin = AdminAccount.query.get_or_404(admin_id) + passkeys = WebAuthnCredential.query.filter_by(admin_account_id=admin_id).all() + + if request.headers.get("HX-Request"): + return render_template( + "components/admin_passkeys.html", admin=admin, passkeys=passkeys + ) + return render_template( + "components/admin_passkeys.html", admin=admin, passkeys=passkeys + ) diff --git a/app/blueprints/auth/routes.py b/app/blueprints/auth/routes.py index 3ddef7a6..cbba07db 100644 --- a/app/blueprints/auth/routes.py +++ b/app/blueprints/auth/routes.py @@ -19,7 +19,11 @@ def login(): return redirect("/") if request.method == "GET": - return render_template("login.html") + # Check if there are any passkeys registered in the system + from app.models import WebAuthnCredential + + has_passkeys = WebAuthnCredential.query.first() is not None + return render_template("login.html", has_passkeys=has_passkeys) username = request.form.get("username") password = request.form.get("password") @@ -28,6 +32,19 @@ def login(): if ( account := AdminAccount.query.filter_by(username=username).first() ) and account.check_password(password): + # Check if this account has passkeys registered - if so, require 2FA + from app.models import WebAuthnCredential + + if WebAuthnCredential.query.filter_by(admin_account_id=account.id).first(): + # Store user in session for 2FA verification + from flask import session + + session["pending_2fa_user_id"] = account.id + session["pending_2fa_remember"] = bool(request.form.get("remember")) + return render_template( + "login.html", show_2fa=True, username=username, has_passkeys=True + ) + # No passkeys, allow direct login login_user(account, remember=bool(request.form.get("remember"))) return redirect("/") @@ -59,7 +76,54 @@ def login(): # Log failed login with IP logging.warning(f"AUTH FAIL: Failed login for user '{username}' from {client_ip}") - return render_template("login.html", error=_("Invalid username or password")) + # Check if there are any passkeys registered for error page + from app.models import WebAuthnCredential + + has_passkeys = WebAuthnCredential.query.first() is not None + return render_template( + "login.html", error=_("Invalid username or password"), has_passkeys=has_passkeys + ) + + +@auth_bp.route("/complete-2fa", methods=["POST"]) +def complete_2fa(): + """Complete 2FA authentication with passkey.""" + from flask import session + + user_id = session.get("pending_2fa_user_id") + remember = session.get("pending_2fa_remember", False) + + if not user_id: + # Check if there are any passkeys registered for error page + from app.models import WebAuthnCredential + + has_passkeys = WebAuthnCredential.query.first() is not None + return render_template( + "login.html", + error=_("No pending 2FA authentication"), + has_passkeys=has_passkeys, + ) + + # Get the user account + account = AdminAccount.query.get(user_id) + if not account: + session.pop("pending_2fa_user_id", None) + session.pop("pending_2fa_remember", None) + # Check if there are any passkeys registered for error page + from app.models import WebAuthnCredential + + has_passkeys = WebAuthnCredential.query.first() is not None + return render_template( + "login.html", error=_("Authentication failed"), has_passkeys=has_passkeys + ) + + # The actual WebAuthn verification will be handled by the existing WebAuthn route + # This route is called after successful WebAuthn authentication + session.pop("pending_2fa_user_id", None) + session.pop("pending_2fa_remember", None) + + login_user(account, remember=remember) + return redirect("/") # ── Logout ──────────────────────────────────────────────────────────── diff --git a/app/blueprints/webauthn/routes.py b/app/blueprints/webauthn/routes.py index 8f7f5c9e..2293ed93 100644 --- a/app/blueprints/webauthn/routes.py +++ b/app/blueprints/webauthn/routes.py @@ -246,7 +246,7 @@ def register_complete(): @webauthn_bp.route("/webauthn/authenticate/begin", methods=["POST"]) def authenticate_begin(): - """Begin WebAuthn authentication process (usernameless).""" + """Begin WebAuthn authentication process (usernameless or 2FA).""" try: rp_id, _, _ = get_rp_config() except ValueError as e: @@ -255,14 +255,31 @@ def authenticate_begin(): if not rp_id: return jsonify({"error": "RP ID is required"}), 500 - # Get all credentials from all admin accounts for usernameless authentication - all_credentials = WebAuthnCredential.query.all() - if not all_credentials: - return jsonify({"error": "No passkeys registered"}), 404 + # Check if this is a 2FA authentication + from flask import session - allow_credentials = [ - PublicKeyCredentialDescriptor(id=cred.credential_id) for cred in all_credentials - ] + pending_2fa_user_id = session.get("pending_2fa_user_id") + + if pending_2fa_user_id: + # 2FA mode - only get credentials for the specific user + user_credentials = WebAuthnCredential.query.filter_by( + admin_account_id=pending_2fa_user_id + ).all() + if not user_credentials: + return jsonify({"error": "No passkeys registered for this user"}), 404 + allow_credentials = [ + PublicKeyCredentialDescriptor(id=cred.credential_id) + for cred in user_credentials + ] + else: + # Usernameless mode - get all credentials from all admin accounts + all_credentials = WebAuthnCredential.query.all() + if not all_credentials: + return jsonify({"error": "No passkeys registered"}), 404 + allow_credentials = [ + PublicKeyCredentialDescriptor(id=cred.credential_id) + for cred in all_credentials + ] authentication_options = generate_authentication_options( rp_id=rp_id, @@ -298,7 +315,7 @@ def authenticate_begin(): @webauthn_bp.route("/webauthn/authenticate/complete", methods=["POST"]) def authenticate_complete(): - """Complete WebAuthn authentication process (usernameless).""" + """Complete WebAuthn authentication process (usernameless or 2FA).""" credential_data = request.get_json() challenge = session.get("webauthn_challenge") @@ -308,7 +325,7 @@ def authenticate_complete(): try: credential = parse_authentication_credential_json(credential_data["credential"]) - # Find the credential by credential_id (usernameless authentication) + # Find the credential by credential_id db_credential = WebAuthnCredential.query.filter_by( credential_id=credential.raw_id ).first() @@ -316,6 +333,18 @@ def authenticate_complete(): if not db_credential: return jsonify({"error": "Credential not found"}), 404 + # Check if this is 2FA authentication + pending_2fa_user_id = session.get("pending_2fa_user_id") + + if ( + pending_2fa_user_id + and db_credential.admin_account_id != pending_2fa_user_id + ): + # 2FA mode - verify the credential belongs to the pending user + return jsonify( + {"error": "Credential does not belong to authenticated user"} + ), 403 + # Get the admin account associated with this credential admin_account = db_credential.admin_account @@ -341,10 +370,13 @@ def authenticate_complete(): session.pop("webauthn_challenge", None) + if pending_2fa_user_id: + # 2FA mode - complete the authentication via the auth route + return jsonify({"verified": True, "redirect": url_for("auth.complete_2fa")}) + # Usernameless mode - login directly from flask_login import login_user login_user(admin_account, remember=True) - return jsonify({"verified": True, "redirect": url_for("admin.dashboard")}) except Exception: diff --git a/app/templates/components/admin_passkeys.html b/app/templates/components/admin_passkeys.html new file mode 100644 index 00000000..32a86065 --- /dev/null +++ b/app/templates/components/admin_passkeys.html @@ -0,0 +1,133 @@ + +
+
+

+ {{ _("Passkeys for {}").format(admin.username) }} +

+ +
+ + {% if passkeys %} +
+ {% for passkey in passkeys %} +
+
+
+ + + +
+
+

{{ passkey.name }}

+

+ {{ _("Created") }}: {{ passkey.created_at.strftime('%Y-%m-%d %H:%M') }} +

+ {% if passkey.last_used_at %} +

+ {{ _("Last used") }}: {{ passkey.last_used_at.strftime('%Y-%m-%d %H:%M') }} +

+ {% endif %} +
+
+
+ +
+
+ {% endfor %} +
+ +
+ + +
+ {% else %} +
+ + + +

+ {{ _("No passkeys registered for this user.") }} +

+ +
+ {% endif %} +
+ + \ No newline at end of file diff --git a/app/templates/login.html b/app/templates/login.html index 498e2039..22dab793 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -15,9 +15,32 @@ Login class="w-full bg-white rounded-lg shadow-sm dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">

- {{ _("Login") }} + {% if show_2fa %} + {{ _("Two-Factor Authentication") }} + {% else %} + {{ _("Login") }} + {% endif %}

{{ error }}

+ + {% if show_2fa %} +
+
+ + + +
+

+ {{ _("Welcome back, {}!").format(username) }} +

+

+ {{ _("Please authenticate with your passkey to complete login.") }} +

+
+
+
+ {% endif %} + {% if not show_2fa %}
@@ -45,8 +68,31 @@ Login + {% endif %} + {% if show_2fa or has_passkeys %}
+ {% if show_2fa %} + + + + + + {% elif has_passkeys %} +
@@ -63,7 +109,9 @@ Login {{ _("Sign in with Passkey") }} + {% endif %}
+ {% endif %}
@@ -73,6 +121,14 @@ Login document.addEventListener('DOMContentLoaded', function() { const passkeyLoginBtn = document.getElementById('passkey-login'); const usernameInput = document.getElementById('username'); + const backToLoginBtn = document.getElementById('back-to-login'); + + // Handle back to login button + if (backToLoginBtn) { + backToLoginBtn.addEventListener('click', function() { + window.location.href = '/login'; + }); + } passkeyLoginBtn.addEventListener('click', async function() { try { @@ -130,7 +186,20 @@ document.addEventListener('DOMContentLoaded', function() { if (completeResponse.ok) { const result = await completeResponse.json(); if (result.verified && result.redirect) { - window.location.href = result.redirect; + // For 2FA completion, we need to make a POST request to complete the login + if (result.redirect.includes('complete-2fa')) { + const completeLoginResponse = await fetch(result.redirect, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + if (completeLoginResponse.ok) { + window.location.href = '/'; + } else { + throw new Error('{{ _("Failed to complete 2FA login") }}'); + } + } else { + window.location.href = result.redirect; + } } else { throw new Error('{{ _("Authentication failed") }}'); } diff --git a/app/templates/settings/admins.html b/app/templates/settings/admins.html index 9baf62d4..e25aab7e 100644 --- a/app/templates/settings/admins.html +++ b/app/templates/settings/admins.html @@ -16,6 +16,17 @@ {{ a.username }}
+
+ +
+ + + +