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:
Matthieu B
2025-07-18 15:47:02 +02:00
parent 6de36caa57
commit 56ca505dc9
10 changed files with 886 additions and 17 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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