feat: ✨ two factor authentication
This commit is contained in:
@@ -209,6 +209,28 @@ def init_db(app=None):
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE')
|
||||
|
||||
# Add 2FA columns to User table if they don't exist
|
||||
cursor.execute("SHOW COLUMNS FROM User LIKE 'totp_secret'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('ALTER TABLE User ADD COLUMN totp_secret VARCHAR(255) DEFAULT NULL')
|
||||
|
||||
cursor.execute("SHOW COLUMNS FROM User LIKE 'totp_enabled'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('ALTER TABLE User ADD COLUMN totp_enabled BOOLEAN DEFAULT FALSE')
|
||||
|
||||
cursor.execute("SHOW COLUMNS FROM User LIKE 'backup_codes'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('ALTER TABLE User ADD COLUMN backup_codes TEXT DEFAULT NULL')
|
||||
|
||||
cursor.execute("SHOW COLUMNS FROM User LIKE 'two_fa_setup_complete'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('ALTER TABLE User ADD COLUMN two_fa_setup_complete BOOLEAN DEFAULT FALSE')
|
||||
|
||||
# Add require_2fa column to Role table if it doesn't exist
|
||||
cursor.execute("SHOW COLUMNS FROM Role LIKE 'require_2fa'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('ALTER TABLE Role ADD COLUMN require_2fa BOOLEAN DEFAULT FALSE')
|
||||
|
||||
# Ensure AuditLog foreign keys have ON DELETE SET NULL to preserve audit logs
|
||||
# This is critical - audit logs should NEVER be deleted, even when referenced entities are deleted
|
||||
try:
|
||||
|
||||
+3
-1
@@ -2,4 +2,6 @@ Flask
|
||||
mysql-connector-python
|
||||
dotenv
|
||||
gunicorn
|
||||
requests
|
||||
requests
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
@@ -409,6 +409,10 @@ def register_routes(app):
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
# If already logged in, redirect to index
|
||||
if session.get('logged_in'):
|
||||
return redirect(url_for('index'))
|
||||
|
||||
error = None
|
||||
if request.method == 'POST':
|
||||
email = request.form['email']
|
||||
@@ -419,8 +423,37 @@ def register_routes(app):
|
||||
cursor.execute('SELECT id, password FROM User WHERE email = %s', (email,))
|
||||
user = cursor.fetchone()
|
||||
if user and verify_password(password, user[1]):
|
||||
user_id = user[0]
|
||||
# Check if user's role requires 2FA
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT u.totp_enabled, u.two_fa_setup_complete, r.require_2fa
|
||||
FROM User u
|
||||
LEFT JOIN Role r ON u.role_id = r.id
|
||||
WHERE u.id = %s
|
||||
''', (user_id,))
|
||||
result = cursor.fetchone()
|
||||
totp_enabled = result[0] if result else False
|
||||
setup_complete = result[1] if result else False
|
||||
role_requires_2fa = result[2] if result else False
|
||||
|
||||
# If role requires 2FA but user hasn't set it up, redirect to setup
|
||||
if role_requires_2fa and not setup_complete:
|
||||
session['pending_user_id'] = user_id
|
||||
session['pending_email'] = email
|
||||
return redirect(url_for('setup_2fa'))
|
||||
|
||||
# If 2FA is enabled, require verification
|
||||
if totp_enabled:
|
||||
session['pending_user_id'] = user_id
|
||||
session['pending_email'] = email
|
||||
return redirect(url_for('verify_2fa'))
|
||||
|
||||
# Normal login - no 2FA required
|
||||
session['logged_in'] = True
|
||||
session['user_id'] = user[0]
|
||||
session['user_id'] = user_id
|
||||
session.modified = True # Ensure session is saved
|
||||
logging.info(f"User {email} logged in successfully.")
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
@@ -434,6 +467,128 @@ def register_routes(app):
|
||||
logging.info(f"User {user_name} logged out.")
|
||||
session.clear()
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/setup-2fa', methods=['GET', 'POST'])
|
||||
def setup_2fa():
|
||||
from totp_utils import generate_totp_secret, get_totp_uri, generate_qr_code, verify_totp, generate_backup_codes, format_backup_codes
|
||||
from flask import current_app
|
||||
import json
|
||||
|
||||
# If already logged in, redirect to index
|
||||
if session.get('logged_in'):
|
||||
return redirect(url_for('index'))
|
||||
|
||||
pending_user_id = session.get('pending_user_id')
|
||||
if not pending_user_id:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'generate':
|
||||
# Generate new TOTP secret
|
||||
secret = generate_totp_secret()
|
||||
session['temp_totp_secret'] = secret
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT email FROM User WHERE id = %s', (pending_user_id,))
|
||||
email = cursor.fetchone()[0]
|
||||
|
||||
totp_uri = get_totp_uri(secret, email)
|
||||
qr_code = generate_qr_code(totp_uri)
|
||||
return render_with_user('setup_2fa.html', secret=secret, qr_code=qr_code, email=email, step='verify')
|
||||
|
||||
elif action == 'verify':
|
||||
code = request.form.get('code', '').strip()
|
||||
secret = session.get('temp_totp_secret')
|
||||
|
||||
if not secret:
|
||||
return render_with_user('setup_2fa.html', error='Session expired. Please start over.', step='generate')
|
||||
|
||||
if verify_totp(secret, code):
|
||||
# Save TOTP secret and generate backup codes
|
||||
backup_codes = generate_backup_codes()
|
||||
backup_codes_json = json.dumps(backup_codes)
|
||||
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE User
|
||||
SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s, two_fa_setup_complete = TRUE
|
||||
WHERE id = %s
|
||||
''', (secret, backup_codes_json, pending_user_id))
|
||||
|
||||
session.pop('temp_totp_secret', None)
|
||||
session['logged_in'] = True
|
||||
session['user_id'] = pending_user_id
|
||||
session.pop('pending_user_id', None)
|
||||
session.pop('pending_email', None)
|
||||
session.modified = True # Ensure session is saved
|
||||
|
||||
formatted_codes = format_backup_codes(backup_codes)
|
||||
logging.info(f"User {pending_user_id} enabled 2FA successfully.")
|
||||
return render_with_user('setup_2fa.html', backup_codes=formatted_codes, step='backup_codes')
|
||||
else:
|
||||
return render_with_user('setup_2fa.html', error='Invalid code. Please try again.', secret=secret, step='verify')
|
||||
|
||||
return render_with_user('setup_2fa.html', step='generate')
|
||||
|
||||
@app.route('/verify-2fa', methods=['GET', 'POST'])
|
||||
def verify_2fa():
|
||||
from totp_utils import verify_totp, verify_backup_code
|
||||
from flask import current_app
|
||||
|
||||
# If already logged in, redirect to index
|
||||
if session.get('logged_in'):
|
||||
return redirect(url_for('index'))
|
||||
|
||||
pending_user_id = session.get('pending_user_id')
|
||||
if not pending_user_id:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
code = request.form.get('code', '').strip()
|
||||
use_backup = request.form.get('use_backup') == 'true'
|
||||
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT totp_secret, backup_codes FROM User WHERE id = %s', (pending_user_id,))
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
return render_with_user('verify_2fa.html', error='User not found.')
|
||||
|
||||
totp_secret, backup_codes_json = result
|
||||
|
||||
if use_backup:
|
||||
# Verify backup code
|
||||
valid, updated_codes = verify_backup_code(backup_codes_json, code)
|
||||
if valid:
|
||||
# Update backup codes in database
|
||||
cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s',
|
||||
(updated_codes, pending_user_id))
|
||||
session['logged_in'] = True
|
||||
session['user_id'] = pending_user_id
|
||||
session.pop('pending_user_id', None)
|
||||
session.pop('pending_email', None)
|
||||
session.modified = True # Ensure session is saved
|
||||
logging.info(f"User {pending_user_id} logged in with backup code.")
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
return render_with_user('verify_2fa.html', error='Invalid backup code.')
|
||||
else:
|
||||
# Verify TOTP code
|
||||
if verify_totp(totp_secret, code):
|
||||
session['logged_in'] = True
|
||||
session['user_id'] = pending_user_id
|
||||
session.pop('pending_user_id', None)
|
||||
session.pop('pending_email', None)
|
||||
session.modified = True # Ensure session is saved
|
||||
logging.info(f"User {pending_user_id} logged in with 2FA.")
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
return render_with_user('verify_2fa.html', error='Invalid code. Please try again.')
|
||||
|
||||
return render_with_user('verify_2fa.html')
|
||||
|
||||
@app.route('/')
|
||||
@permission_required('view_index')
|
||||
@@ -1092,6 +1247,154 @@ def register_routes(app):
|
||||
api_key = result[0]
|
||||
return render_with_user('api_docs.html', api_key=api_key)
|
||||
|
||||
@app.route('/account', methods=['GET'])
|
||||
@login_required
|
||||
def account_settings():
|
||||
from totp_utils import format_backup_codes
|
||||
from flask import current_app
|
||||
import json
|
||||
|
||||
user_id = session.get('user_id')
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT u.totp_enabled, u.backup_codes, r.require_2fa
|
||||
FROM User u
|
||||
LEFT JOIN Role r ON u.role_id = r.id
|
||||
WHERE u.id = %s
|
||||
''', (user_id,))
|
||||
result = cursor.fetchone()
|
||||
totp_enabled = result[0] if result else False
|
||||
backup_codes_json = result[1] if result else None
|
||||
role_requires_2fa = result[2] if result else False
|
||||
|
||||
backup_codes = None
|
||||
if backup_codes_json:
|
||||
try:
|
||||
codes = json.loads(backup_codes_json)
|
||||
backup_codes = format_backup_codes(codes)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return render_with_user('account_settings.html',
|
||||
totp_enabled=totp_enabled,
|
||||
backup_codes=backup_codes,
|
||||
role_requires_2fa=role_requires_2fa)
|
||||
|
||||
@app.route('/account/enable-2fa', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def enable_2fa():
|
||||
from totp_utils import generate_totp_secret, get_totp_uri, generate_qr_code, verify_totp, generate_backup_codes, format_backup_codes
|
||||
from flask import current_app
|
||||
import json
|
||||
|
||||
user_id = session.get('user_id')
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'generate':
|
||||
secret = generate_totp_secret()
|
||||
session['temp_totp_secret'] = secret
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT email FROM User WHERE id = %s', (user_id,))
|
||||
email = cursor.fetchone()[0]
|
||||
|
||||
totp_uri = get_totp_uri(secret, email)
|
||||
qr_code = generate_qr_code(totp_uri)
|
||||
return render_with_user('enable_2fa.html', secret=secret, qr_code=qr_code, email=email, step='verify')
|
||||
|
||||
elif action == 'verify':
|
||||
code = request.form.get('code', '').strip()
|
||||
secret = session.get('temp_totp_secret')
|
||||
|
||||
if not secret:
|
||||
return render_with_user('enable_2fa.html', error='Session expired. Please start over.', step='generate')
|
||||
|
||||
if verify_totp(secret, code):
|
||||
backup_codes = generate_backup_codes()
|
||||
backup_codes_json = json.dumps(backup_codes)
|
||||
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE User
|
||||
SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s, two_fa_setup_complete = TRUE
|
||||
WHERE id = %s
|
||||
''', (secret, backup_codes_json, user_id))
|
||||
|
||||
session.pop('temp_totp_secret', None)
|
||||
formatted_codes = format_backup_codes(backup_codes)
|
||||
logging.info(f"User {user_id} enabled 2FA.")
|
||||
return render_with_user('enable_2fa.html', backup_codes=formatted_codes, step='backup_codes')
|
||||
else:
|
||||
return render_with_user('enable_2fa.html', error='Invalid code. Please try again.', secret=secret, step='verify')
|
||||
|
||||
return render_with_user('enable_2fa.html', step='generate')
|
||||
|
||||
@app.route('/account/disable-2fa', methods=['POST'])
|
||||
@login_required
|
||||
def disable_2fa():
|
||||
from flask import current_app
|
||||
|
||||
user_id = session.get('user_id')
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE User
|
||||
SET totp_secret = NULL, totp_enabled = FALSE, backup_codes = NULL, two_fa_setup_complete = FALSE
|
||||
WHERE id = %s
|
||||
''', (user_id,))
|
||||
|
||||
logging.info(f"User {user_id} disabled 2FA.")
|
||||
return redirect(url_for('account_settings', success='2FA has been disabled.'))
|
||||
|
||||
@app.route('/account/regenerate-backup-codes', methods=['POST'])
|
||||
@login_required
|
||||
def regenerate_backup_codes():
|
||||
from totp_utils import generate_backup_codes, format_backup_codes
|
||||
from flask import current_app
|
||||
import json
|
||||
|
||||
user_id = session.get('user_id')
|
||||
backup_codes = generate_backup_codes()
|
||||
backup_codes_json = json.dumps(backup_codes)
|
||||
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s', (backup_codes_json, user_id))
|
||||
|
||||
formatted_codes = format_backup_codes(backup_codes)
|
||||
logging.info(f"User {user_id} regenerated backup codes.")
|
||||
return render_with_user('regenerate_backup_codes.html', backup_codes=formatted_codes)
|
||||
|
||||
@app.route('/account/change-password', methods=['POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
from flask import current_app
|
||||
|
||||
user_id = session.get('user_id')
|
||||
current_password = request.form.get('current_password')
|
||||
new_password = request.form.get('new_password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
if new_password != confirm_password:
|
||||
return redirect(url_for('account_settings', error='New passwords do not match.'))
|
||||
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT password FROM User WHERE id = %s', (user_id,))
|
||||
result = cursor.fetchone()
|
||||
if not result or not verify_password(current_password, result[0]):
|
||||
return redirect(url_for('account_settings', error='Current password is incorrect.'))
|
||||
|
||||
hashed_password = hash_password(new_password)
|
||||
cursor.execute('UPDATE User SET password = %s WHERE id = %s', (hashed_password, user_id))
|
||||
|
||||
logging.info(f"User {user_id} changed password.")
|
||||
return redirect(url_for('account_settings', success='Password changed successfully.'))
|
||||
|
||||
@app.route('/users', methods=['GET', 'POST'])
|
||||
@permission_required('view_users')
|
||||
def users():
|
||||
@@ -1160,11 +1463,12 @@ def register_routes(app):
|
||||
else:
|
||||
role_name = request.form['role_name'].strip()
|
||||
role_description = request.form.get('role_description', '').strip()
|
||||
require_2fa = request.form.get('require_2fa') == 'on'
|
||||
if not role_name:
|
||||
error = 'Role name is required.'
|
||||
else:
|
||||
try:
|
||||
cursor.execute('INSERT INTO Role (name, description) VALUES (%s, %s)', (role_name, role_description))
|
||||
cursor.execute('INSERT INTO Role (name, description, require_2fa) VALUES (%s, %s, %s)', (role_name, role_description, require_2fa))
|
||||
role_id = cursor.lastrowid
|
||||
# Get selected permissions
|
||||
permission_ids = request.form.getlist('permissions')
|
||||
@@ -1184,11 +1488,12 @@ def register_routes(app):
|
||||
role_id = request.form['role_id']
|
||||
role_name = request.form['role_name'].strip()
|
||||
role_description = request.form.get('role_description', '').strip()
|
||||
require_2fa = request.form.get('require_2fa') == 'on'
|
||||
if not role_name:
|
||||
error = 'Role name is required.'
|
||||
else:
|
||||
try:
|
||||
cursor.execute('UPDATE Role SET name=%s, description=%s WHERE id=%s', (role_name, role_description, role_id))
|
||||
cursor.execute('UPDATE Role SET name=%s, description=%s, require_2fa=%s WHERE id=%s', (role_name, role_description, require_2fa, role_id))
|
||||
# Update permissions
|
||||
cursor.execute('DELETE FROM RolePermission WHERE role_id=%s', (role_id,))
|
||||
permission_ids = request.form.getlist('permissions')
|
||||
@@ -1239,7 +1544,7 @@ def register_routes(app):
|
||||
users = cursor.fetchall()
|
||||
|
||||
# Get all roles
|
||||
cursor.execute('SELECT id, name, description FROM Role ORDER BY name')
|
||||
cursor.execute('SELECT id, name, description, require_2fa FROM Role ORDER BY name')
|
||||
roles = cursor.fetchall()
|
||||
|
||||
# Get all permissions grouped by category
|
||||
|
||||
+2
-1
@@ -50,12 +50,13 @@ function closeAddRoleModal() {
|
||||
document.getElementById('add-role-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function editRole(roleId, roleName, roleDescription) {
|
||||
function editRole(roleId, roleName, roleDescription, require2fa) {
|
||||
// Make sure add modal is closed first
|
||||
document.getElementById('add-role-modal').classList.add('hidden');
|
||||
document.getElementById('edit-role-id').value = roleId;
|
||||
document.getElementById('edit-role-name').value = roleName;
|
||||
document.getElementById('edit-role-description').value = roleDescription || '';
|
||||
document.getElementById('edit-role-require-2fa').checked = require2fa === true || require2fa === 'True' || require2fa === 1;
|
||||
|
||||
const permissionsDiv = document.getElementById('edit-role-permissions');
|
||||
permissionsDiv.innerHTML = '';
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Account Settings - {{ NAME }} IPAM</title>
|
||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||
<link href="/static/css/output.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||
{% include 'header.html' %}
|
||||
<div class="flex-1 flex items-center justify-center mx-4">
|
||||
<div class="container py-8 max-w-3xl pt-20">
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">
|
||||
<i class="fas fa-user-cog mr-2"></i>
|
||||
Account Settings
|
||||
</h1>
|
||||
|
||||
{% if success %}
|
||||
<div class="bg-green-200 dark:bg-green-900 text-green-800 dark:text-green-200 p-4 rounded-lg mb-6">
|
||||
<i class="fas fa-check-circle mr-2"></i>{{ success }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Change Password Section -->
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||
<h2 class="text-2xl font-bold mb-4">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
Change Password
|
||||
</h2>
|
||||
<form method="POST" action="/account/change-password" class="space-y-4">
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium mb-2">Current Password</label>
|
||||
<input type="password" name="current_password" id="current_password"
|
||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium mb-2">New Password</label>
|
||||
<input type="password" name="new_password" id="new_password"
|
||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium mb-2">Confirm New Password</label>
|
||||
<input type="password" name="confirm_password" id="confirm_password"
|
||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600" required>
|
||||
</div>
|
||||
<button type="submit" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Change Password</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Two-Factor Authentication Section -->
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||
<h2 class="text-2xl font-bold mb-4">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Two-Factor Authentication
|
||||
</h2>
|
||||
|
||||
{% if totp_enabled %}
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-200 dark:bg-green-900 text-green-800 dark:text-green-200 p-4 rounded-lg">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
2FA is currently <strong>enabled</strong> for your account.
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/account/disable-2fa" class="mt-4" onsubmit="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.');">
|
||||
<input type="hidden" name="confirm_disable" value="true">
|
||||
<button type="submit" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-times"></i>
|
||||
<span>Disable 2FA</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
Backup Codes
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Backup codes can be used to access your account if you lose your authenticator device.
|
||||
Each code can only be used once.
|
||||
</p>
|
||||
|
||||
{% if backup_codes %}
|
||||
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg mb-4">
|
||||
<div class="grid grid-cols-2 gap-4 font-mono text-center">
|
||||
{% for code_pair in backup_codes %}
|
||||
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
|
||||
{{ code_pair }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="window.print()" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-print"></i>
|
||||
<span>Print Backup Codes</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
You don't have any backup codes. Generate new ones below.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/account/regenerate-backup-codes" class="mt-4" onsubmit="return confirm('This will invalidate your existing backup codes. Are you sure you want to generate new ones?');">
|
||||
<button type="submit" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-redo"></i>
|
||||
<span>Regenerate Backup Codes</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="space-y-4">
|
||||
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
2FA is currently <strong>disabled</strong> for your account.
|
||||
{% if role_requires_2fa %}
|
||||
<br><strong>Note:</strong> Your role requires 2FA. You should enable it now.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<a href="/account/enable-2fa" class="block w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<span>Enable 2FA</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Enable Two-Factor Authentication - {{ NAME }} IPAM</title>
|
||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||
<link href="/static/css/output.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||
{% include 'header.html' %}
|
||||
<div class="flex-1 flex items-center justify-center mx-4">
|
||||
<div class="container py-8 max-w-2xl pt-20">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||
<div class="mb-4">
|
||||
<a href="/account" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||
<i class="fas fa-arrow-left mr-2"></i>Back to Account Settings
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Enable Two-Factor Authentication
|
||||
</h1>
|
||||
|
||||
{% if step == 'generate' %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-center text-gray-700 dark:text-gray-300">
|
||||
Two-factor authentication adds an extra layer of security to your account.
|
||||
</p>
|
||||
<p class="text-center text-gray-600 dark:text-gray-400 text-sm">
|
||||
You'll need an authenticator app like Google Authenticator, Authy, or Microsoft Authenticator.
|
||||
</p>
|
||||
<form method="POST" class="mt-6">
|
||||
<input type="hidden" name="action" value="generate">
|
||||
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
<span>Generate QR Code</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% elif step == 'verify' %}
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
||||
Scan this QR code with your authenticator app:
|
||||
</p>
|
||||
<div class="flex justify-center mb-4">
|
||||
<img src="data:image/png;base64,{{ qr_code }}" alt="QR Code" class="border-4 border-gray-400 dark:border-zinc-600 rounded-lg p-2 bg-white">
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Or enter this secret manually:
|
||||
</p>
|
||||
<div class="bg-gray-300 dark:bg-zinc-900 p-3 rounded-lg font-mono text-sm break-all text-center">
|
||||
{{ secret }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="space-y-4">
|
||||
<input type="hidden" name="action" value="verify">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium mb-2">Enter the 6-digit code from your app:</label>
|
||||
<input type="text" name="code" id="code" maxlength="6" pattern="[0-9]{6}"
|
||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600 text-center text-2xl font-mono tracking-widest"
|
||||
placeholder="000000" required autofocus>
|
||||
</div>
|
||||
{% if error %}
|
||||
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-3 rounded-lg">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-check"></i>
|
||||
<span>Verify & Enable 2FA</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% elif step == 'backup_codes' %}
|
||||
<div class="space-y-6">
|
||||
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
|
||||
<p class="font-semibold mb-2">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
Important: Save Your Backup Codes
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
These codes can be used to access your account if you lose your authenticator device.
|
||||
Store them in a safe place. Each code can only be used once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4 text-center">Your Backup Codes</h2>
|
||||
<div class="grid grid-cols-2 gap-4 font-mono text-center">
|
||||
{% for code_pair in backup_codes %}
|
||||
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
|
||||
{{ code_pair }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button onclick="window.print()" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-print"></i>
|
||||
<span>Print Codes</span>
|
||||
</button>
|
||||
<a href="/account" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
|
||||
<i class="fas fa-check"></i>
|
||||
<span>Continue</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Auto-focus code input and move cursor on input
|
||||
const codeInput = document.getElementById('code');
|
||||
if (codeInput) {
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
this.value = this.value.replace(/[^0-9]/g, '');
|
||||
if (this.value.length === 6) {
|
||||
this.form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+56
-12
@@ -18,22 +18,44 @@
|
||||
</div>
|
||||
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav">
|
||||
{% if has_permission('view_index') %}
|
||||
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium">Home</a>
|
||||
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-home"></i>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_permission('view_devices') %}
|
||||
<a href="/devices" class="text-gray-200 hover:text-gray-400 font-medium">Devices</a>
|
||||
<a href="/devices" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-server"></i>
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_permission('view_racks') %}
|
||||
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium">Racks</a>
|
||||
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-th"></i>
|
||||
<span>Racks</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_permission('view_admin') %}
|
||||
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium">Admin</a>
|
||||
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_permission('view_help') %}
|
||||
<a href="/help" class="text-gray-200 hover:text-gray-400 font-medium">Help</a>
|
||||
<a href="/help" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
<span>Help</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user_name %}
|
||||
<a href="/logout" class="text-gray-200 hover:text-gray-400 font-medium">Logout</a>
|
||||
<a href="/account" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-user-cog"></i>
|
||||
<span>Account</span>
|
||||
</a>
|
||||
<a href="/logout" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<button class="lg:hidden flex items-center text-gray-200 hover:cursor-pointer focus:outline-none flex-shrink-0" id="nav-toggle" aria-label="Open navigation menu">
|
||||
@@ -53,22 +75,44 @@
|
||||
</div>
|
||||
</form>
|
||||
{% if has_permission('view_index') %}
|
||||
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Home</a>
|
||||
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-home"></i>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_permission('view_devices') %}
|
||||
<a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Devices</a>
|
||||
<a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-server"></i>
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_permission('view_racks') %}
|
||||
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Racks</a>
|
||||
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-th"></i>
|
||||
<span>Racks</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_permission('view_admin') %}
|
||||
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Admin</a>
|
||||
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_permission('view_help') %}
|
||||
<a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Help</a>
|
||||
<a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
<span>Help</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user_name %}
|
||||
<a href="/logout" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Logout</a>
|
||||
<a href="/account" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-user-cog"></i>
|
||||
<span>Account</span>
|
||||
</a>
|
||||
<a href="/logout" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script src="/static/js/header.js"></script>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Regenerate Backup Codes - {{ NAME }} IPAM</title>
|
||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||
<link href="/static/css/output.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||
{% include 'header.html' %}
|
||||
<div class="flex-1 flex items-center justify-center mx-4">
|
||||
<div class="container py-8 max-w-2xl pt-20">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||
<div class="mb-4">
|
||||
<a href="/account" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||
<i class="fas fa-arrow-left mr-2"></i>Back to Account Settings
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
New Backup Codes
|
||||
</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
|
||||
<p class="font-semibold mb-2">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
Important: Save Your New Backup Codes
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
Your old backup codes have been invalidated. These new codes can be used to access your account if you lose your authenticator device.
|
||||
Store them in a safe place. Each code can only be used once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4 text-center">Your New Backup Codes</h2>
|
||||
<div class="grid grid-cols-2 gap-4 font-mono text-center">
|
||||
{% for code_pair in backup_codes %}
|
||||
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
|
||||
{{ code_pair }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button onclick="window.print()" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-print"></i>
|
||||
<span>Print Codes</span>
|
||||
</button>
|
||||
<a href="/account" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
|
||||
<i class="fas fa-check"></i>
|
||||
<span>Done</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Setup Two-Factor Authentication - {{ NAME }} IPAM</title>
|
||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||
<link href="/static/css/output.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||
{% include 'header.html' %}
|
||||
<div class="flex-1 flex items-center justify-center mx-4">
|
||||
<div class="container py-8 max-w-2xl pt-20">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Setup Two-Factor Authentication
|
||||
</h1>
|
||||
|
||||
{% if step == 'generate' %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-center text-gray-700 dark:text-gray-300">
|
||||
Your role requires two-factor authentication. Let's set it up now.
|
||||
</p>
|
||||
<p class="text-center text-gray-600 dark:text-gray-400 text-sm">
|
||||
You'll need an authenticator app like Google Authenticator, Authy, or Microsoft Authenticator.
|
||||
</p>
|
||||
<form method="POST" class="mt-6">
|
||||
<input type="hidden" name="action" value="generate">
|
||||
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
<span>Generate QR Code</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% elif step == 'verify' %}
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
||||
Scan this QR code with your authenticator app:
|
||||
</p>
|
||||
<div class="flex justify-center mb-4">
|
||||
<img src="data:image/png;base64,{{ qr_code }}" alt="QR Code" class="border-4 border-gray-400 dark:border-zinc-600 rounded-lg p-2 bg-white">
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Or enter this secret manually:
|
||||
</p>
|
||||
<div class="bg-gray-300 dark:bg-zinc-900 p-3 rounded-lg font-mono text-sm break-all text-center">
|
||||
{{ secret }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="space-y-4">
|
||||
<input type="hidden" name="action" value="verify">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium mb-2">Enter the 6-digit code from your app:</label>
|
||||
<input type="text" name="code" id="code" maxlength="6" pattern="[0-9]{6}"
|
||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600 text-center text-2xl font-mono tracking-widest"
|
||||
placeholder="000000" required autofocus>
|
||||
</div>
|
||||
{% if error %}
|
||||
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-3 rounded-lg">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-check"></i>
|
||||
<span>Verify & Enable 2FA</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% elif step == 'backup_codes' %}
|
||||
<div class="space-y-6">
|
||||
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
|
||||
<p class="font-semibold mb-2">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
Important: Save Your Backup Codes
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
These codes can be used to access your account if you lose your authenticator device.
|
||||
Store them in a safe place. Each code can only be used once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4 text-center">Your Backup Codes</h2>
|
||||
<div class="grid grid-cols-2 gap-4 font-mono text-center">
|
||||
{% for code_pair in backup_codes %}
|
||||
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
|
||||
{{ code_pair }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button onclick="window.print()" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-print"></i>
|
||||
<span>Print Codes</span>
|
||||
</button>
|
||||
<a href="/" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
|
||||
<i class="fas fa-check"></i>
|
||||
<span>Continue</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Auto-focus code input and move cursor on input
|
||||
const codeInput = document.getElementById('code');
|
||||
if (codeInput) {
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
this.value = this.value.replace(/[^0-9]/g, '');
|
||||
if (this.value.length === 6) {
|
||||
this.form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+25
-1
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
{% if can_manage_roles %}
|
||||
<div class="flex space-x-2">
|
||||
<button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Role">
|
||||
<button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}', {{ 'true' if role[3] else 'false' }}); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Role">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button type="button" onclick="deleteRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Role">
|
||||
@@ -211,6 +211,18 @@
|
||||
<input type="text" name="role_name" placeholder="Role Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" required>
|
||||
<input type="text" name="role_description" placeholder="Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="require_2fa" class="mr-2 w-4 h-4">
|
||||
<span class="text-sm">
|
||||
<i class="fas fa-shield-alt mr-1"></i>
|
||||
Require Two-Factor Authentication for this role
|
||||
</span>
|
||||
</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1 ml-6">
|
||||
Users with this role will be required to set up 2FA on their next login.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h3 class="font-bold mb-3">Permissions</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto border border-gray-600 rounded-lg p-4 bg-gray-300 dark:bg-zinc-900">
|
||||
@@ -322,6 +334,18 @@
|
||||
<input type="text" name="role_name" id="edit-role-name" placeholder="Role Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" required>
|
||||
<input type="text" name="role_description" id="edit-role-description" placeholder="Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="require_2fa" id="edit-role-require-2fa" class="mr-2 w-4 h-4">
|
||||
<span class="text-sm">
|
||||
<i class="fas fa-shield-alt mr-1"></i>
|
||||
Require Two-Factor Authentication for this role
|
||||
</span>
|
||||
</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1 ml-6">
|
||||
Users with this role will be required to set up 2FA on their next login.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h3 class="font-bold mb-3">Permissions</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto border border-gray-600 rounded-lg p-4 bg-gray-300 dark:bg-zinc-900" id="edit-role-permissions">
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify Two-Factor Authentication - {{ NAME }} IPAM</title>
|
||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||
<link href="/static/css/output.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||
{% include 'header.html' %}
|
||||
<div class="flex-1 flex items-center justify-center mx-4">
|
||||
<div class="container py-8 max-w-md pt-20">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Two-Factor Authentication
|
||||
</h1>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-center text-gray-700 dark:text-gray-300">
|
||||
Enter the 6-digit code from your authenticator app:
|
||||
</p>
|
||||
|
||||
<form method="POST" class="space-y-4">
|
||||
<div>
|
||||
<input type="text" name="code" id="code" maxlength="10"
|
||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600 text-center text-2xl font-mono tracking-widest"
|
||||
placeholder="000000" required autofocus>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-3 rounded-lg">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" name="use_backup" value="false" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
||||
<i class="fas fa-check"></i>
|
||||
<span>Verify Code</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<button type="button" onclick="toggleBackupMode()" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 underline">
|
||||
Use backup code instead
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let backupMode = false;
|
||||
|
||||
function toggleBackupMode() {
|
||||
backupMode = !backupMode;
|
||||
const codeInput = document.getElementById('code');
|
||||
const form = codeInput.closest('form');
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
|
||||
if (backupMode) {
|
||||
codeInput.placeholder = "Enter backup code";
|
||||
codeInput.maxLength = 20;
|
||||
codeInput.pattern = "";
|
||||
submitBtn.innerHTML = '<i class="fas fa-key"></i><span>Verify Backup Code</span>';
|
||||
submitBtn.setAttribute('name', 'use_backup');
|
||||
submitBtn.setAttribute('value', 'true');
|
||||
} else {
|
||||
codeInput.placeholder = "000000";
|
||||
codeInput.maxLength = 10;
|
||||
codeInput.pattern = "[0-9]{6}";
|
||||
submitBtn.innerHTML = '<i class="fas fa-check"></i><span>Verify Code</span>';
|
||||
submitBtn.removeAttribute('name');
|
||||
submitBtn.removeAttribute('value');
|
||||
}
|
||||
codeInput.value = '';
|
||||
codeInput.focus();
|
||||
}
|
||||
|
||||
// Auto-submit on 6 digits for TOTP codes
|
||||
const codeInput = document.getElementById('code');
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
if (!backupMode) {
|
||||
this.value = this.value.replace(/[^0-9]/g, '');
|
||||
if (this.value.length === 6) {
|
||||
this.form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import pyotp
|
||||
import qrcode
|
||||
import secrets
|
||||
import json
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from flask import current_app
|
||||
|
||||
def generate_totp_secret():
|
||||
"""Generate a new TOTP secret"""
|
||||
return pyotp.random_base32()
|
||||
|
||||
def get_totp_uri(secret, email, issuer_name="IPAM"):
|
||||
"""Generate TOTP URI for QR code"""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.provisioning_uri(
|
||||
name=email,
|
||||
issuer_name=issuer_name
|
||||
)
|
||||
|
||||
def generate_qr_code(uri):
|
||||
"""Generate QR code image from URI"""
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
def verify_totp(secret, code):
|
||||
"""Verify a TOTP code"""
|
||||
if not secret or not code:
|
||||
return False
|
||||
try:
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code, valid_window=1) # Allow 1 time step window for clock skew
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def generate_backup_codes(count=10):
|
||||
"""Generate backup codes for 2FA"""
|
||||
return [secrets.token_urlsafe(8).upper() for _ in range(count)]
|
||||
|
||||
def verify_backup_code(backup_codes_json, code):
|
||||
"""Verify a backup code and remove it if valid"""
|
||||
if not backup_codes_json or not code:
|
||||
return False, None
|
||||
|
||||
try:
|
||||
codes = json.loads(backup_codes_json)
|
||||
code_upper = code.upper().strip()
|
||||
if code_upper in codes:
|
||||
codes.remove(code_upper)
|
||||
return True, json.dumps(codes) if codes else None
|
||||
return False, None
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
return False, None
|
||||
|
||||
def format_backup_codes(codes):
|
||||
"""Format backup codes for display (group in pairs)"""
|
||||
formatted = []
|
||||
for i in range(0, len(codes), 2):
|
||||
if i + 1 < len(codes):
|
||||
formatted.append(f"{codes[i]} {codes[i+1]}")
|
||||
else:
|
||||
formatted.append(codes[i])
|
||||
return formatted
|
||||
|
||||
Reference in New Issue
Block a user