mirror of
https://github.com/wizarrrr/wizarr.git
synced 2025-12-23 23:59:23 -05:00
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.
This commit is contained in:
@@ -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("/<int:admin_id>/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("/<int:admin_id>/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
|
||||
)
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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:
|
||||
|
||||
133
app/templates/components/admin_passkeys.html
Normal file
133
app/templates/components/admin_passkeys.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!-- Admin Passkey Management Component -->
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ _("Passkeys for {}").format(admin.username) }}
|
||||
</h3>
|
||||
<button type="button" onclick="closeModal()"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if passkeys %}
|
||||
<div class="space-y-3">
|
||||
{% for passkey in passkeys %}
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ passkey.name }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("Created") }}: {{ passkey.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</p>
|
||||
{% if passkey.last_used_at %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("Last used") }}: {{ passkey.last_used_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button type="button" onclick="deletePasskey({{ passkey.id }})"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button type="button" onclick="resetAllPasskeys({{ admin.id }}, '{{ admin.username }}')"
|
||||
class="px-4 py-2 text-sm font-medium text-red-600 bg-red-50 dark:bg-red-900 dark:text-red-300 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-100 dark:hover:bg-red-800">
|
||||
{{ _("Reset All Passkeys") }}
|
||||
</button>
|
||||
<button type="button" onclick="closeModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
{{ _("Close") }}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
<p class="mt-4 text-gray-500 dark:text-gray-400">
|
||||
{{ _("No passkeys registered for this user.") }}
|
||||
</p>
|
||||
<button type="button" onclick="closeModal()"
|
||||
class="mt-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
{{ _("Close") }}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function deletePasskey(passkeyId) {
|
||||
if (confirm('{{ _("Are you sure you want to delete this passkey?") }}')) {
|
||||
fetch(`/webauthn/credentials/${passkeyId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('{{ _("Failed to delete passkey") }}');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('{{ _("An error occurred while deleting the passkey") }}');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resetAllPasskeys(adminId, username) {
|
||||
if (confirm(`{{ _("Are you sure you want to reset ALL passkeys for {}?").format('${username}') }} {{ _("This action cannot be undone.") }}`)) {
|
||||
fetch(`/settings/admins/${adminId}/reset-passkeys`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
alert('{{ _("All passkeys have been reset") }}');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('{{ _("Failed to reset passkeys") }}');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('{{ _("An error occurred while resetting passkeys") }}');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
// Close modal logic - depends on your modal implementation
|
||||
if (window.parent && window.parent.closeModal) {
|
||||
window.parent.closeModal();
|
||||
} else {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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">
|
||||
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
|
||||
<h1 class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
|
||||
{{ _("Login") }}
|
||||
{% if show_2fa %}
|
||||
{{ _("Two-Factor Authentication") }}
|
||||
{% else %}
|
||||
{{ _("Login") }}
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-500"><span class="font-medium">{{ error }}</p>
|
||||
|
||||
{% if show_2fa %}
|
||||
<div class="mb-4 p-4 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
{{ _("Welcome back, {}!").format(username) }}
|
||||
</p>
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400">
|
||||
{{ _("Please authenticate with your passkey to complete login.") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not show_2fa %}
|
||||
<form class="space-y-4 md:space-y-6" action="/login" method="POST">
|
||||
<div>
|
||||
<label for="username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ _("Username") }}</label>
|
||||
@@ -45,8 +68,31 @@ Login
|
||||
<button type="submit" id="password-login"
|
||||
class="w-full text-white bg-primary hover:bg-primary_hover focus:ring-4 focus:outline-hidden focus:ring-amber-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary dark:hover:bg-primary_hover dark:focus:ring-primary_hover">{{ _("Sign in") }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if show_2fa or has_passkeys %}
|
||||
<div class="mt-4">
|
||||
{% if show_2fa %}
|
||||
<!-- 2FA Mode: Only show passkey authentication -->
|
||||
<button type="button" id="passkey-login"
|
||||
class="w-full flex items-center justify-center px-4 py-2 border border-blue-300 dark:border-blue-600 rounded-lg text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900 hover:bg-blue-100 dark:hover:bg-blue-800">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
{{ _("Authenticate with Passkey") }}
|
||||
</button>
|
||||
|
||||
<!-- Back to login button -->
|
||||
<button type="button" id="back-to-login"
|
||||
class="mt-2 w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||
</svg>
|
||||
{{ _("Back to Login") }}
|
||||
</button>
|
||||
{% elif has_passkeys %}
|
||||
<!-- Normal Mode: Show "Or" divider and passkey option -->
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
|
||||
@@ -63,7 +109,9 @@ Login
|
||||
</svg>
|
||||
{{ _("Sign in with Passkey") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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") }}');
|
||||
}
|
||||
|
||||
@@ -16,6 +16,17 @@
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ a.username }}</span>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<button hx-get="{{ url_for('admin_accounts.admin_passkeys', admin_id=a.id) }}"
|
||||
hx-target="#create-admin-modal"
|
||||
hx-trigger="click"
|
||||
_="on htmx:afterOnLoad wait 10ms then remove .hidden to #modal"
|
||||
class="inline-flex items-center justify-center p-2 text-blue-600 rounded-lg hover:text-blue-800 hover:bg-blue-100 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900"
|
||||
title="{{ _('Manage Passkeys') }}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button hx-get="{{ url_for('admin_accounts.edit_admin', admin_id=a.id) }}"
|
||||
hx-target="#create-admin-modal"
|
||||
hx-trigger="click"
|
||||
|
||||
@@ -72,7 +72,7 @@ accept datetime or string #} {%- if v is string -%} {{ v[:16] }} {%- elif v -%}
|
||||
class="cursor-pointer list-none focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 p-4 flex flex-col gap-2 sm:gap-1"
|
||||
>
|
||||
<!-- top row: status + code + actions -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick="tableCopyLink('{{ invite.code }}')"
|
||||
@@ -86,6 +86,20 @@ accept datetime or string #} {%- if v is string -%} {{ v[:16] }} {%- elif v -%}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Expand/Collapse indicator -->
|
||||
<div class="flex items-center justify-center p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-200 group-open:rotate-180"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
onclick="tableCopyLink('{{ invite.code }}')"
|
||||
id="copy_{{ invite.code}}"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* [Single-Sign-On (SSO)](using-wizarr/single-sign-on-sso.md)
|
||||
* [Discord Integration](using-wizarr/discord-integration.md)
|
||||
* [Customise The Wizard](using-wizarr/customise-steps.md)
|
||||
* [Password and Passkey Recovery](using-wizarr/password-passkey-recovery.md)
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
213
docs/using-wizarr/password-passkey-recovery.md
Normal file
213
docs/using-wizarr/password-passkey-recovery.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Password and Passkey Recovery
|
||||
|
||||
This guide helps administrators recover access to their Wizarr instance when locked out due to forgotten passwords or lost passkeys.
|
||||
|
||||
## Recovery Tool Overview
|
||||
|
||||
The recovery tool is a command-line utility designed to help administrators regain access when locked out. It provides emergency recovery options for common authentication issues.
|
||||
|
||||
### Key Features
|
||||
|
||||
* **Password Reset**: Reset passwords for admin accounts
|
||||
* **Passkey Management**: Remove all passkeys for specific admin accounts
|
||||
* **Emergency Admin**: Create emergency admin accounts
|
||||
* **Account Listing**: View all admin accounts and their passkey status
|
||||
* **Container-Friendly**: Designed to work seamlessly in Docker containers
|
||||
|
||||
## When to Use the Recovery Tool
|
||||
|
||||
Use the recovery tool in these situations:
|
||||
|
||||
* **Forgotten Password**: You cannot remember your admin password
|
||||
* **Lost Passkey**: Your passkey device is lost or broken
|
||||
* **2FA Lockout**: You're locked out due to passkey 2FA requirements
|
||||
* **Complete Lockout**: All admin accounts are inaccessible
|
||||
|
||||
## Running the Recovery Tool
|
||||
|
||||
### Docker Container
|
||||
|
||||
For Docker installations:
|
||||
|
||||
```bash
|
||||
# Find your container name
|
||||
docker ps
|
||||
|
||||
# Run it directly
|
||||
docker exec -it <container_name> uv run recovery_tool.py
|
||||
```
|
||||
|
||||
## Recovery Options
|
||||
|
||||
The recovery tool provides five main options:
|
||||
|
||||
### 1. List All Admin Accounts
|
||||
|
||||
View all administrators and their current status:
|
||||
|
||||
* Username and ID
|
||||
* Number of registered passkeys
|
||||
* Account creation date
|
||||
* Legacy admin status
|
||||
|
||||
### 2. Reset Admin Password
|
||||
|
||||
Change the password for any admin account:
|
||||
|
||||
1. Select the admin account from the list
|
||||
2. Enter a new password (minimum 6 characters)
|
||||
3. Confirm the password
|
||||
4. The password is immediately updated
|
||||
|
||||
### 3. Remove All Passkeys
|
||||
|
||||
Delete all passkeys for a specific admin account:
|
||||
|
||||
1. Select the admin account
|
||||
2. Confirm the operation (this cannot be undone)
|
||||
3. All passkeys are removed
|
||||
4. 2FA requirement is disabled for that account
|
||||
|
||||
### 4. Create Emergency Admin
|
||||
|
||||
Create a new admin account with password authentication:
|
||||
|
||||
1. Enter a username (must be unique)
|
||||
2. Set a password (minimum 6 characters)
|
||||
3. Confirm the password
|
||||
4. New admin account is created immediately
|
||||
|
||||
### 5. Exit
|
||||
|
||||
Close the recovery tool safely.
|
||||
|
||||
## Common Recovery Scenarios
|
||||
|
||||
### Scenario 1: Forgotten Admin Password
|
||||
|
||||
**Problem**: You remember your username but forgot your password.
|
||||
|
||||
**Solution**:
|
||||
1. Run the recovery tool
|
||||
2. Select option 2 (Reset admin password)
|
||||
3. Choose your admin account
|
||||
4. Enter a new password
|
||||
5. Log in with the new password
|
||||
|
||||
### Scenario 2: Lost Passkey Device
|
||||
|
||||
**Problem**: Your passkey device is lost, broken, or unavailable.
|
||||
|
||||
**Solution**:
|
||||
1. Run the recovery tool
|
||||
2. Select option 3 (Remove all passkeys for admin)
|
||||
3. Choose your admin account
|
||||
4. Confirm the removal
|
||||
5. Log in with username/password (2FA disabled)
|
||||
|
||||
### Scenario 3: Complete Lockout
|
||||
|
||||
**Problem**: All admin accounts are inaccessible.
|
||||
|
||||
**Solution**:
|
||||
1. Run the recovery tool
|
||||
2. Select option 4 (Create emergency admin account)
|
||||
3. Create a new admin account
|
||||
4. Log in with the emergency account
|
||||
5. Manage other accounts through the web interface
|
||||
6. Delete the emergency account when no longer needed
|
||||
|
||||
### Scenario 4: 2FA Lockout
|
||||
|
||||
**Problem**: You're locked out due to passkey 2FA requirements.
|
||||
|
||||
**Solution**:
|
||||
1. Run the recovery tool
|
||||
2. Select option 3 (Remove all passkeys for admin)
|
||||
3. Choose your admin account
|
||||
4. Confirm passkey removal
|
||||
5. Log in with username/password only
|
||||
|
||||
## Security Considerations
|
||||
|
||||
{% hint style="warning" %}
|
||||
**Important Security Notes**
|
||||
|
||||
* Only run this tool when you have direct access to the server/container
|
||||
* The tool requires database access and should only be used by system administrators
|
||||
* Anyone with server access can use this tool to gain admin privileges
|
||||
{% endhint %}
|
||||
|
||||
### After Recovery
|
||||
|
||||
Once you regain access, consider these security steps:
|
||||
|
||||
1. **Update Passwords**: Change passwords for all admin accounts
|
||||
2. **Re-register Passkeys**: Set up new passkeys for 2FA
|
||||
3. **Review Admin Access**: Audit who has admin privileges
|
||||
4. **Delete Emergency Accounts**: Remove temporary accounts when no longer needed
|
||||
5. **Secure Server Access**: Ensure only authorized personnel can access the server
|
||||
|
||||
## System Requirements
|
||||
|
||||
The recovery tool requires:
|
||||
|
||||
* Access to the server or container running Wizarr
|
||||
* Read/write access to the database
|
||||
* Proper Flask environment configuration
|
||||
* Python execution privileges
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
The tool automatically uses your existing Wizarr configuration. Ensure these environment variables are set if needed:
|
||||
|
||||
* `DATABASE_URL` - Database connection string
|
||||
* `FLASK_ENV` - Flask environment (development/production)
|
||||
* `SECRET_KEY` - Flask secret key
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Import Error**
|
||||
* Ensure you're running the tool from the Wizarr root directory
|
||||
* Check that all dependencies are installed
|
||||
|
||||
**Database Connection Error**
|
||||
* Verify the database is accessible
|
||||
* Check that the Flask environment is properly configured
|
||||
|
||||
**Permission Error**
|
||||
* Ensure you have write access to the database
|
||||
* Check file permissions on the database file
|
||||
|
||||
### Tool Output
|
||||
|
||||
The recovery tool provides clear feedback:
|
||||
|
||||
* ✅ Success messages for completed operations
|
||||
* ❌ Error messages for failed operations
|
||||
* ⚠️ Warning messages for destructive operations
|
||||
* ℹ️ Information messages for status updates
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Regular Backups**: Keep database backups before making changes
|
||||
2. **Test Access**: Verify you can log in after making changes
|
||||
3. **Document Changes**: Keep records of recovery actions taken
|
||||
4. **Secure Storage**: Store recovery procedures in a secure location
|
||||
5. **Regular Reviews**: Periodically review admin account access
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues with the recovery tool:
|
||||
|
||||
1. Check the troubleshooting section above
|
||||
2. Verify database connectivity
|
||||
3. Ensure proper permissions
|
||||
4. Review Flask application logs for additional details
|
||||
5. Contact support through the official channels
|
||||
|
||||
{% hint style="info" %}
|
||||
**Remember**: This tool is for emergency recovery only. Regular admin management should be done through the web interface.
|
||||
{% endhint %}
|
||||
296
recovery_tool.py
Executable file
296
recovery_tool.py
Executable file
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wizarr Recovery Tool
|
||||
CLI tool for password reset and passkey removal when admin is locked out.
|
||||
Designed to work in containerized environments.
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root to the Python path
|
||||
project_root = Path(__file__).parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# Flask app initialization imports - moved here to satisfy E402
|
||||
from app import create_app # noqa: E402
|
||||
from app.extensions import db # noqa: E402
|
||||
from app.models import AdminAccount, Settings, WebAuthnCredential # noqa: E402
|
||||
|
||||
|
||||
def print_banner():
|
||||
"""Print the recovery tool banner."""
|
||||
print("=" * 60)
|
||||
print("🔧 WIZARR RECOVERY TOOL")
|
||||
print("=" * 60)
|
||||
print("This tool helps recover admin access when locked out.")
|
||||
print("Use with caution - only run when necessary.")
|
||||
print()
|
||||
|
||||
|
||||
def list_admins():
|
||||
"""List all admin accounts."""
|
||||
print("📋 Admin Accounts:")
|
||||
print("-" * 40)
|
||||
|
||||
# Multi-admin accounts
|
||||
admin_accounts = AdminAccount.query.all()
|
||||
if admin_accounts:
|
||||
for account in admin_accounts:
|
||||
passkey_count = WebAuthnCredential.query.filter_by(
|
||||
admin_account_id=account.id
|
||||
).count()
|
||||
print(f" ID: {account.id}")
|
||||
print(f" Username: {account.username}")
|
||||
print(f" Passkeys: {passkey_count}")
|
||||
print(f" Created: {account.created_at}")
|
||||
print()
|
||||
|
||||
# Legacy admin (Settings table)
|
||||
legacy_username = (
|
||||
db.session.query(Settings.value).filter_by(key="admin_username").scalar()
|
||||
)
|
||||
if legacy_username:
|
||||
print(" Legacy Admin:")
|
||||
print(f" Username: {legacy_username}")
|
||||
print(" Note: Legacy admin does not support passkeys")
|
||||
print()
|
||||
|
||||
if not admin_accounts and not legacy_username:
|
||||
print(" No admin accounts found.")
|
||||
print()
|
||||
|
||||
|
||||
def reset_admin_password():
|
||||
"""Reset password for an admin account."""
|
||||
print("🔑 Reset Admin Password")
|
||||
print("-" * 40)
|
||||
|
||||
# List admins first
|
||||
admin_accounts = AdminAccount.query.all()
|
||||
if not admin_accounts:
|
||||
print("No multi-admin accounts found.")
|
||||
|
||||
# Check for legacy admin
|
||||
legacy_username = (
|
||||
db.session.query(Settings.value).filter_by(key="admin_username").scalar()
|
||||
)
|
||||
if legacy_username:
|
||||
print(f"Found legacy admin: {legacy_username}")
|
||||
choice = input("Reset legacy admin password? (y/N): ").lower()
|
||||
if choice == "y":
|
||||
new_password = getpass.getpass("Enter new password: ")
|
||||
confirm_password = getpass.getpass("Confirm new password: ")
|
||||
|
||||
if new_password != confirm_password:
|
||||
print("❌ Passwords do not match.")
|
||||
return
|
||||
|
||||
if len(new_password) < 6:
|
||||
print("❌ Password must be at least 6 characters long.")
|
||||
return
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
password_hash = generate_password_hash(new_password)
|
||||
|
||||
# Update legacy admin password
|
||||
admin_password_setting = Settings.query.filter_by(
|
||||
key="admin_password"
|
||||
).first()
|
||||
if admin_password_setting:
|
||||
admin_password_setting.value = password_hash
|
||||
else:
|
||||
admin_password_setting = Settings(
|
||||
key="admin_password", value=password_hash
|
||||
)
|
||||
db.session.add(admin_password_setting)
|
||||
|
||||
db.session.commit()
|
||||
print("✅ Legacy admin password reset successfully.")
|
||||
else:
|
||||
print("No admin accounts found.")
|
||||
return
|
||||
|
||||
# Show admin options
|
||||
print("Available admin accounts:")
|
||||
for i, account in enumerate(admin_accounts, 1):
|
||||
passkey_count = WebAuthnCredential.query.filter_by(
|
||||
admin_account_id=account.id
|
||||
).count()
|
||||
print(
|
||||
f" {i}. {account.username} (ID: {account.id}, Passkeys: {passkey_count})"
|
||||
)
|
||||
|
||||
try:
|
||||
choice = int(input("Select admin account (number): ")) - 1
|
||||
if choice < 0 or choice >= len(admin_accounts):
|
||||
print("❌ Invalid choice.")
|
||||
return
|
||||
|
||||
selected_admin = admin_accounts[choice]
|
||||
|
||||
new_password = getpass.getpass("Enter new password: ")
|
||||
confirm_password = getpass.getpass("Confirm new password: ")
|
||||
|
||||
if new_password != confirm_password:
|
||||
print("❌ Passwords do not match.")
|
||||
return
|
||||
|
||||
if len(new_password) < 6:
|
||||
print("❌ Password must be at least 6 characters long.")
|
||||
return
|
||||
|
||||
selected_admin.set_password(new_password)
|
||||
db.session.commit()
|
||||
print(f"✅ Password for {selected_admin.username} reset successfully.")
|
||||
|
||||
except ValueError:
|
||||
print("❌ Invalid input. Please enter a number.")
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
|
||||
|
||||
def remove_all_passkeys():
|
||||
"""Remove all passkeys for an admin account."""
|
||||
print("🔐 Remove Admin Passkeys")
|
||||
print("-" * 40)
|
||||
|
||||
admin_accounts = AdminAccount.query.all()
|
||||
if not admin_accounts:
|
||||
print("No multi-admin accounts found.")
|
||||
return
|
||||
|
||||
# Show admin options
|
||||
print("Available admin accounts:")
|
||||
for i, account in enumerate(admin_accounts, 1):
|
||||
passkey_count = WebAuthnCredential.query.filter_by(
|
||||
admin_account_id=account.id
|
||||
).count()
|
||||
print(
|
||||
f" {i}. {account.username} (ID: {account.id}, Passkeys: {passkey_count})"
|
||||
)
|
||||
|
||||
try:
|
||||
choice = int(input("Select admin account (number): ")) - 1
|
||||
if choice < 0 or choice >= len(admin_accounts):
|
||||
print("❌ Invalid choice.")
|
||||
return
|
||||
|
||||
selected_admin = admin_accounts[choice]
|
||||
passkey_count = WebAuthnCredential.query.filter_by(
|
||||
admin_account_id=selected_admin.id
|
||||
).count()
|
||||
|
||||
if passkey_count == 0:
|
||||
print(f"ℹ️ No passkeys found for {selected_admin.username}.")
|
||||
return
|
||||
|
||||
print(
|
||||
f"⚠️ This will remove ALL {passkey_count} passkeys for {selected_admin.username}."
|
||||
)
|
||||
confirm = input("Are you sure? Type 'YES' to confirm: ")
|
||||
|
||||
if confirm == "YES":
|
||||
WebAuthnCredential.query.filter_by(
|
||||
admin_account_id=selected_admin.id
|
||||
).delete()
|
||||
db.session.commit()
|
||||
print(f"✅ All passkeys for {selected_admin.username} have been removed.")
|
||||
else:
|
||||
print("❌ Operation cancelled.")
|
||||
|
||||
except ValueError:
|
||||
print("❌ Invalid input. Please enter a number.")
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
|
||||
|
||||
def create_emergency_admin():
|
||||
"""Create an emergency admin account."""
|
||||
print("🚨 Create Emergency Admin")
|
||||
print("-" * 40)
|
||||
|
||||
username = input("Enter username for emergency admin: ").strip()
|
||||
if not username:
|
||||
print("❌ Username cannot be empty.")
|
||||
return
|
||||
|
||||
# Check if username already exists
|
||||
if AdminAccount.query.filter_by(username=username).first():
|
||||
print(f"❌ Username '{username}' already exists.")
|
||||
return
|
||||
|
||||
password = getpass.getpass("Enter password: ")
|
||||
confirm_password = getpass.getpass("Confirm password: ")
|
||||
|
||||
if password != confirm_password:
|
||||
print("❌ Passwords do not match.")
|
||||
return
|
||||
|
||||
if len(password) < 6:
|
||||
print("❌ Password must be at least 6 characters long.")
|
||||
return
|
||||
|
||||
try:
|
||||
emergency_admin = AdminAccount()
|
||||
emergency_admin.username = username
|
||||
emergency_admin.set_password(password)
|
||||
|
||||
db.session.add(emergency_admin)
|
||||
db.session.commit()
|
||||
|
||||
print(f"✅ Emergency admin '{username}' created successfully.")
|
||||
print(" You can now login with this account.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating emergency admin: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main recovery tool interface."""
|
||||
print_banner()
|
||||
|
||||
try:
|
||||
# Initialize Flask app
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
while True:
|
||||
print("🔧 Recovery Options:")
|
||||
print("1. List all admin accounts")
|
||||
print("2. Reset admin password")
|
||||
print("3. Remove all passkeys for admin")
|
||||
print("4. Create emergency admin account")
|
||||
print("5. Exit")
|
||||
print()
|
||||
|
||||
choice = input("Select option (1-5): ").strip()
|
||||
|
||||
if choice == "1":
|
||||
list_admins()
|
||||
elif choice == "2":
|
||||
reset_admin_password()
|
||||
elif choice == "3":
|
||||
remove_all_passkeys()
|
||||
elif choice == "4":
|
||||
create_emergency_admin()
|
||||
elif choice == "5":
|
||||
print("👋 Goodbye!")
|
||||
break
|
||||
else:
|
||||
print("❌ Invalid choice. Please select 1-5.")
|
||||
|
||||
print()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Goodbye!")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user