v1.4.0 #14
@@ -11,6 +11,7 @@ from datetime import datetime, timedelta
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
import pyotp
|
||||||
|
|
||||||
from database import Database
|
from database import Database
|
||||||
from ssh_keys import SSHKeyManager
|
from ssh_keys import SSHKeyManager
|
||||||
@@ -56,8 +57,11 @@ try:
|
|||||||
default_user = db.get_user_by_username('admin')
|
default_user = db.get_user_by_username('admin')
|
||||||
if not default_user:
|
if not default_user:
|
||||||
default_password = os.getenv('ADMIN_PASSWORD', 'admin')
|
default_password = os.getenv('ADMIN_PASSWORD', 'admin')
|
||||||
db.create_user('admin', generate_password_hash(default_password))
|
db.create_user('admin', generate_password_hash(default_password), is_admin=True)
|
||||||
logger.warning("Created default admin user (password: 'admin' - CHANGE THIS!)")
|
logger.warning("Created default admin user (password: 'admin' - CHANGE THIS!)")
|
||||||
|
elif not default_user.get('is_admin'):
|
||||||
|
db.update_user_admin(default_user['id'], True)
|
||||||
|
logger.info("Updated default admin user with admin privileges")
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Database initialization failed: {e}")
|
logger.error(f"Database initialization failed: {e}")
|
||||||
@@ -80,6 +84,34 @@ def login_required(f):
|
|||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""Decorator to require admin privileges."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if 'user_id' not in session:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
if not session.get('is_admin'):
|
||||||
|
flash('Admin access required', 'error')
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user():
|
||||||
|
"""Get current logged-in user from database."""
|
||||||
|
user_id = session.get('user_id')
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
return db.get_user_by_id(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_in_user(user):
|
||||||
|
"""Set session keys for authenticated user."""
|
||||||
|
session['user_id'] = user['id']
|
||||||
|
session['username'] = user['username']
|
||||||
|
session['is_admin'] = bool(user.get('is_admin'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Redirect to dashboard if logged in, otherwise to login."""
|
"""Redirect to dashboard if logged in, otherwise to login."""
|
||||||
@@ -91,6 +123,9 @@ def index():
|
|||||||
@app.route('/login', methods=['GET', 'POST'])
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
"""Login page."""
|
"""Login page."""
|
||||||
|
if session.get('totp_pending_user_id'):
|
||||||
|
return redirect(url_for('login_totp'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
username = request.form.get('username')
|
username = request.form.get('username')
|
||||||
password = request.form.get('password')
|
password = request.form.get('password')
|
||||||
@@ -101,8 +136,14 @@ def login():
|
|||||||
|
|
||||||
user = db.get_user_by_username(username)
|
user = db.get_user_by_username(username)
|
||||||
if user and check_password_hash(user['password_hash'], password):
|
if user and check_password_hash(user['password_hash'], password):
|
||||||
session['user_id'] = user['id']
|
if user.get('totp_enabled'):
|
||||||
session['username'] = user['username']
|
# Step 1 complete: require second factor on separate page.
|
||||||
|
session.clear()
|
||||||
|
session['totp_pending_user_id'] = user['id']
|
||||||
|
return redirect(url_for('login_totp'))
|
||||||
|
|
||||||
|
session.clear()
|
||||||
|
_sign_in_user(user)
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
else:
|
else:
|
||||||
flash('Invalid username or password', 'error')
|
flash('Invalid username or password', 'error')
|
||||||
@@ -110,6 +151,43 @@ def login():
|
|||||||
return render_template('login.html')
|
return render_template('login.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/login/totp', methods=['GET', 'POST'])
|
||||||
|
def login_totp():
|
||||||
|
"""Second-factor login page for users with TOTP enabled."""
|
||||||
|
pending_user_id = session.get('totp_pending_user_id')
|
||||||
|
if not pending_user_id:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
user = db.get_user_by_id(pending_user_id)
|
||||||
|
if not user or not user.get('totp_enabled'):
|
||||||
|
session.pop('totp_pending_user_id', None)
|
||||||
|
flash('TOTP session expired. Please log in again.', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
otp_code = (request.form.get('otp_code') or '').strip().replace(' ', '')
|
||||||
|
if not otp_code:
|
||||||
|
flash('TOTP code is required', 'error')
|
||||||
|
return render_template('login_totp.html', username=user['username'])
|
||||||
|
|
||||||
|
totp_secret = user.get('totp_secret')
|
||||||
|
if not totp_secret:
|
||||||
|
session.pop('totp_pending_user_id', None)
|
||||||
|
flash('TOTP is enabled but no secret is configured. Contact an admin.', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
is_valid = pyotp.TOTP(totp_secret).verify(otp_code, valid_window=1)
|
||||||
|
if not is_valid:
|
||||||
|
flash('Invalid TOTP code', 'error')
|
||||||
|
return render_template('login_totp.html', username=user['username'])
|
||||||
|
|
||||||
|
session.clear()
|
||||||
|
_sign_in_user(user)
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
return render_template('login_totp.html', username=user['username'])
|
||||||
|
|
||||||
|
|
||||||
@app.route('/logout')
|
@app.route('/logout')
|
||||||
def logout():
|
def logout():
|
||||||
"""Logout and clear session."""
|
"""Logout and clear session."""
|
||||||
@@ -117,6 +195,197 @@ def logout():
|
|||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/profile', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def profile():
|
||||||
|
"""Manage current user's profile: username, password, and TOTP."""
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.form.get('action')
|
||||||
|
|
||||||
|
if action == 'update_username':
|
||||||
|
new_username = (request.form.get('new_username') or '').strip()
|
||||||
|
if not new_username:
|
||||||
|
flash('Username is required', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
existing = db.get_user_by_username(new_username)
|
||||||
|
if existing and existing['id'] != user['id']:
|
||||||
|
flash('That username is already in use', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
if db.update_user_username(user['id'], new_username):
|
||||||
|
session['username'] = new_username
|
||||||
|
flash('Username updated successfully', 'success')
|
||||||
|
else:
|
||||||
|
flash('Failed to update username', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
if action == 'update_password':
|
||||||
|
current_password = request.form.get('current_password') or ''
|
||||||
|
new_password = request.form.get('new_password') or ''
|
||||||
|
confirm_password = request.form.get('confirm_password') or ''
|
||||||
|
|
||||||
|
if not check_password_hash(user['password_hash'], current_password):
|
||||||
|
flash('Current password is incorrect', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
if len(new_password) < 8:
|
||||||
|
flash('New password must be at least 8 characters', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
if new_password != confirm_password:
|
||||||
|
flash('New password and confirmation do not match', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
if db.update_user_password(user['id'], generate_password_hash(new_password)):
|
||||||
|
flash('Password updated successfully', 'success')
|
||||||
|
else:
|
||||||
|
flash('Failed to update password', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
if action == 'generate_totp_secret':
|
||||||
|
secret = pyotp.random_base32()
|
||||||
|
if db.update_user_totp(user['id'], secret, False):
|
||||||
|
flash('Generated a new TOTP secret. Verify a code to enable TOTP.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Failed to generate TOTP secret', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
if action == 'enable_totp':
|
||||||
|
otp_code = (request.form.get('otp_code') or '').strip().replace(' ', '')
|
||||||
|
fresh_user = db.get_user_by_id(user['id'])
|
||||||
|
totp_secret = fresh_user.get('totp_secret') if fresh_user else None
|
||||||
|
|
||||||
|
if not totp_secret:
|
||||||
|
flash('No TOTP secret found. Generate one first.', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
if not pyotp.TOTP(totp_secret).verify(otp_code, valid_window=1):
|
||||||
|
flash('Invalid TOTP code. Please try again.', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
if db.update_user_totp(user['id'], totp_secret, True):
|
||||||
|
flash('TOTP enabled for your account.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Failed to enable TOTP', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
if action == 'disable_totp':
|
||||||
|
if db.update_user_totp(user['id'], None, False):
|
||||||
|
flash('TOTP disabled.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Failed to disable TOTP', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
flash('Unknown action', 'error')
|
||||||
|
return redirect(url_for('profile'))
|
||||||
|
|
||||||
|
user = get_current_user()
|
||||||
|
if user.get('totp_secret'):
|
||||||
|
totp_uri = pyotp.TOTP(user['totp_secret']).provisioning_uri(
|
||||||
|
name=user['username'],
|
||||||
|
issuer_name='OPNsense Backup Manager',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
totp_uri = None
|
||||||
|
return render_template('profile.html', user=user, totp_uri=totp_uri)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def users():
|
||||||
|
"""List and manage users."""
|
||||||
|
all_users = db.get_all_users()
|
||||||
|
return render_template('users.html', users=all_users)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/create', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def create_user():
|
||||||
|
"""Create a user account."""
|
||||||
|
username = (request.form.get('username') or '').strip()
|
||||||
|
password = request.form.get('password') or ''
|
||||||
|
is_admin = bool(request.form.get('is_admin'))
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
flash('Username and password are required', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
if len(password) < 8:
|
||||||
|
flash('Password must be at least 8 characters', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
if db.get_user_by_username(username):
|
||||||
|
flash('Username already exists', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
user_id = db.create_user(username, generate_password_hash(password), is_admin=is_admin)
|
||||||
|
if user_id:
|
||||||
|
flash('User created successfully', 'success')
|
||||||
|
else:
|
||||||
|
flash('Failed to create user', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/<int:user_id>/toggle-admin', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def toggle_user_admin(user_id):
|
||||||
|
"""Toggle admin role for a user."""
|
||||||
|
target_user = db.get_user_by_id(user_id)
|
||||||
|
if not target_user:
|
||||||
|
flash('User not found', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
all_users = db.get_all_users()
|
||||||
|
admin_count = sum(1 for u in all_users if u.get('is_admin'))
|
||||||
|
|
||||||
|
new_is_admin = not bool(target_user.get('is_admin'))
|
||||||
|
if not new_is_admin and admin_count <= 1:
|
||||||
|
flash('Cannot remove admin role from the last admin user', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
if target_user['id'] == session.get('user_id') and not new_is_admin:
|
||||||
|
flash('You cannot remove your own admin role', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
if db.update_user_admin(user_id, new_is_admin):
|
||||||
|
flash('User admin role updated', 'success')
|
||||||
|
else:
|
||||||
|
flash('Failed to update user admin role', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_user(user_id):
|
||||||
|
"""Delete a user account."""
|
||||||
|
if user_id == session.get('user_id'):
|
||||||
|
flash('You cannot delete your own account', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
target_user = db.get_user_by_id(user_id)
|
||||||
|
if not target_user:
|
||||||
|
flash('User not found', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
all_users = db.get_all_users()
|
||||||
|
admin_count = sum(1 for u in all_users if u.get('is_admin'))
|
||||||
|
if target_user.get('is_admin') and admin_count <= 1:
|
||||||
|
flash('Cannot delete the last admin user', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
if db.delete_user(user_id):
|
||||||
|
flash('User deleted successfully', 'success')
|
||||||
|
else:
|
||||||
|
flash('Failed to delete user', 'error')
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/dashboard')
|
@app.route('/dashboard')
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
|
|||||||
+116
-3
@@ -58,6 +58,19 @@ class Database:
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Backward-compatible user auth columns.
|
||||||
|
cursor.execute("SHOW COLUMNS FROM users LIKE 'is_admin'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute("ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE")
|
||||||
|
|
||||||
|
cursor.execute("SHOW COLUMNS FROM users LIKE 'totp_secret'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute("ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) NULL")
|
||||||
|
|
||||||
|
cursor.execute("SHOW COLUMNS FROM users LIKE 'totp_enabled'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute("ALTER TABLE users ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE")
|
||||||
|
|
||||||
# Create opnsense_instances table
|
# Create opnsense_instances table
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
@@ -131,14 +144,14 @@ class Database:
|
|||||||
logger.error(f"Error initializing database: {e}")
|
logger.error(f"Error initializing database: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def create_user(self, username: str, password_hash: str) -> Optional[int]:
|
def create_user(self, username: str, password_hash: str, is_admin: bool = False) -> Optional[int]:
|
||||||
"""Create a new user."""
|
"""Create a new user."""
|
||||||
try:
|
try:
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"INSERT INTO users (username, password_hash) VALUES (%s, %s)",
|
"INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, %s)",
|
||||||
(username, password_hash)
|
(username, password_hash, is_admin)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
user_id = cursor.lastrowid
|
user_id = cursor.lastrowid
|
||||||
@@ -160,6 +173,106 @@ class Database:
|
|||||||
except Error as e:
|
except Error as e:
|
||||||
logger.error(f"Error getting user: {e}")
|
logger.error(f"Error getting user: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get user by ID."""
|
||||||
|
try:
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
return user
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Error getting user by id: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all_users(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all users."""
|
||||||
|
try:
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, is_admin, totp_enabled, created_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
users = cursor.fetchall()
|
||||||
|
cursor.close()
|
||||||
|
return users
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Error getting users: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def update_user_username(self, user_id: int, username: str) -> bool:
|
||||||
|
"""Update username for a user."""
|
||||||
|
try:
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE users SET username = %s WHERE id = %s", (username, user_id))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
return True
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Error updating username: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_user_password(self, user_id: int, password_hash: str) -> bool:
|
||||||
|
"""Update password hash for a user."""
|
||||||
|
try:
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, user_id))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
return True
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Error updating password hash: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_user_totp(self, user_id: int, totp_secret: Optional[str], totp_enabled: bool) -> bool:
|
||||||
|
"""Update TOTP settings for a user."""
|
||||||
|
try:
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE users SET totp_secret = %s, totp_enabled = %s WHERE id = %s",
|
||||||
|
(totp_secret, totp_enabled, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
return True
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Error updating TOTP settings: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_user_admin(self, user_id: int, is_admin: bool) -> bool:
|
||||||
|
"""Update admin flag for a user."""
|
||||||
|
try:
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE users SET is_admin = %s WHERE id = %s", (is_admin, user_id))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
return True
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Error updating user admin flag: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_user(self, user_id: int) -> bool:
|
||||||
|
"""Delete user by ID."""
|
||||||
|
try:
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
return True
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Error deleting user: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def create_instance(self, name: str, identifier: str, ssh_key_id: str, description: str = "") -> Optional[int]:
|
def create_instance(self, name: str, identifier: str, ssh_key_id: str, description: str = "") -> Optional[int]:
|
||||||
"""Create a new OPNsense instance."""
|
"""Create a new OPNsense instance."""
|
||||||
|
|||||||
+2
-1
@@ -4,4 +4,5 @@ paramiko
|
|||||||
mysql-connector-python
|
mysql-connector-python
|
||||||
cryptography
|
cryptography
|
||||||
werkzeug
|
werkzeug
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
pyotp
|
||||||
+16
-2
@@ -32,8 +32,15 @@
|
|||||||
<a href="{{ url_for('prune_page') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
<a href="{{ url_for('prune_page') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
Prune
|
Prune
|
||||||
</a>
|
</a>
|
||||||
|
{% if session.is_admin %}
|
||||||
|
<a href="{{ url_for('users') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
|
Users
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<span class="text-neutral-400">|</span>
|
<span class="text-neutral-400">|</span>
|
||||||
<span class="text-neutral-300">{{ session.username }}</span>
|
<a href="{{ url_for('profile') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
|
{{ session.username }}
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('logout') }}" class="bg-neutral-700 hover:bg-neutral-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
<a href="{{ url_for('logout') }}" class="bg-neutral-700 hover:bg-neutral-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
Logout
|
Logout
|
||||||
</a>
|
</a>
|
||||||
@@ -65,8 +72,15 @@
|
|||||||
<a href="{{ url_for('prune_page') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
<a href="{{ url_for('prune_page') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
||||||
Prune
|
Prune
|
||||||
</a>
|
</a>
|
||||||
|
{% if session.is_admin %}
|
||||||
|
<a href="{{ url_for('users') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
||||||
|
Users
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<div class="border-t border-neutral-700 pt-2 mt-2">
|
<div class="border-t border-neutral-700 pt-2 mt-2">
|
||||||
<div class="px-3 py-2 text-sm text-neutral-400">{{ session.username }}</div>
|
<a href="{{ url_for('profile') }}" class="block px-3 py-2 text-sm text-neutral-400 hover:text-orange-500 rounded-md transition-colors hover:bg-neutral-700">
|
||||||
|
{{ session.username }}
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('logout') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
<a href="{{ url_for('logout') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
||||||
Logout
|
Logout
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Two-Factor Verification - OPNsense Backup Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="min-h-[calc(100vh-12rem)] flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="bg-neutral-800 rounded-lg shadow-lg p-8 border border-neutral-700">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-orange-500 mb-2">Two-Factor Verification</h1>
|
||||||
|
<p class="text-neutral-400">Enter the code from your authenticator app.</p>
|
||||||
|
{% if username %}
|
||||||
|
<p class="text-sm text-neutral-500 mt-2">Signing in as <span class="text-neutral-300">{{ username }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('login_totp') }}" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="otp_code" class="block text-sm font-medium text-neutral-300 mb-2">
|
||||||
|
TOTP Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="otp_code"
|
||||||
|
name="otp_code"
|
||||||
|
required
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
|
placeholder="6-digit code"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 hover:cursor-pointer">
|
||||||
|
Verify and Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="{{ url_for('logout') }}" class="text-sm text-neutral-400 hover:text-orange-500 transition-colors">
|
||||||
|
Cancel and return to login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Profile - OPNsense Backup Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Profile</h1>
|
||||||
|
<p class="text-neutral-400">Manage your username, password, and multi-factor authentication.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-neutral-100 mb-4">Change Username</h2>
|
||||||
|
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
|
||||||
|
<input type="hidden" name="action" value="update_username">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-neutral-300 mb-2">Current Username</label>
|
||||||
|
<input type="text" disabled value="{{ user.username }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-400">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new_username" class="block text-sm font-medium text-neutral-300 mb-2">New Username</label>
|
||||||
|
<input id="new_username" name="new_username" type="text" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">Update Username</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-neutral-100 mb-4">Change Password</h2>
|
||||||
|
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
|
||||||
|
<input type="hidden" name="action" value="update_password">
|
||||||
|
<div>
|
||||||
|
<label for="current_password" class="block text-sm font-medium text-neutral-300 mb-2">Current Password</label>
|
||||||
|
<input id="current_password" name="current_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new_password" class="block text-sm font-medium text-neutral-300 mb-2">New Password</label>
|
||||||
|
<input id="new_password" name="new_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="confirm_password" class="block text-sm font-medium text-neutral-300 mb-2">Confirm New Password</label>
|
||||||
|
<input id="confirm_password" name="confirm_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">Update Password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6 space-y-5">
|
||||||
|
<h2 class="text-xl font-bold text-neutral-100">Two-Factor Authentication (TOTP)</h2>
|
||||||
|
<p class="text-neutral-400 text-sm">
|
||||||
|
Use an authenticator app (Aegis, Authy, 1Password, Google Authenticator, etc.) to secure your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if user.totp_enabled %}
|
||||||
|
<div class="bg-green-900/30 border border-green-700 rounded-md p-4 text-sm text-green-300">
|
||||||
|
TOTP is currently enabled for your account.
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('profile') }}">
|
||||||
|
<input type="hidden" name="action" value="disable_totp">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||||
|
Disable TOTP
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{% if not user.totp_secret %}
|
||||||
|
<form method="post" action="{{ url_for('profile') }}">
|
||||||
|
<input type="hidden" name="action" value="generate_totp_secret">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||||
|
Generate TOTP Secret
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-neutral-900 border border-neutral-700 rounded-md p-4 space-y-2">
|
||||||
|
<p class="text-sm text-neutral-300"><strong>Scan QR Code:</strong></p>
|
||||||
|
<div id="totp-qrcode" class="bg-white p-2 rounded-md inline-block"></div>
|
||||||
|
<p class="text-sm text-neutral-300"><strong>Secret:</strong> <code class="text-orange-400">{{ user.totp_secret }}</code></p>
|
||||||
|
<p class="text-sm text-neutral-300"><strong>OTPAuth URI:</strong></p>
|
||||||
|
<code id="totp-uri" class="text-xs text-orange-400 break-all block">{{ totp_uri }}</code>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
|
||||||
|
<input type="hidden" name="action" value="enable_totp">
|
||||||
|
<div>
|
||||||
|
<label for="otp_code" class="block text-sm font-medium text-neutral-300 mb-2">Enter a code from your authenticator app</label>
|
||||||
|
<input id="otp_code" name="otp_code" type="text" inputmode="numeric" required class="w-full max-w-xs px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors" placeholder="123456">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||||
|
Verify and Enable
|
||||||
|
</button>
|
||||||
|
<button formaction="{{ url_for('profile') }}" name="action" value="generate_totp_secret" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||||
|
Regenerate Secret
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if totp_uri %}
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const qrContainer = document.getElementById('totp-qrcode');
|
||||||
|
const uriElement = document.getElementById('totp-uri');
|
||||||
|
if (!qrContainer || !uriElement) return;
|
||||||
|
|
||||||
|
const otpUri = uriElement.textContent.trim();
|
||||||
|
if (!otpUri) return;
|
||||||
|
|
||||||
|
new QRCode(qrContainer, {
|
||||||
|
text: otpUri,
|
||||||
|
width: 192,
|
||||||
|
height: 192,
|
||||||
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -170,10 +170,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 flex-wrap">
|
<div class="flex justify-end gap-3 flex-wrap">
|
||||||
<button type="submit" name="action" value="run_now" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors">
|
<button type="submit" name="action" value="run_now" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||||
Save & Run Now
|
Save & Run Now
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="px-5 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors">
|
<button type="submit" class="px-5 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Users - OPNsense Backup Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Users</h1>
|
||||||
|
<p class="text-neutral-400">Create and manage user accounts.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-neutral-100 mb-4">Create User</h2>
|
||||||
|
<form method="post" action="{{ url_for('create_user') }}" class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||||
|
<div class="md:col-span-1">
|
||||||
|
<label for="username" class="block text-sm font-medium text-neutral-300 mb-2">Username</label>
|
||||||
|
<input id="username" name="username" type="text" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="password" class="block text-sm font-medium text-neutral-300 mb-2">Password</label>
|
||||||
|
<input id="password" name="password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-1 flex items-center gap-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||||
|
<input type="checkbox" name="is_admin">
|
||||||
|
Admin
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-neutral-700">
|
||||||
|
<h2 class="text-xl font-bold text-neutral-100">Existing Users</h2>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-neutral-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Username</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Role</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">TOTP</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Created</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-neutral-700">
|
||||||
|
{% for user in users %}
|
||||||
|
<tr class="hover:bg-neutral-700/50 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-100">{{ user.username }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-300">
|
||||||
|
{% if user.is_admin %}Admin{% else %}User{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-300">
|
||||||
|
{% if user.totp_enabled %}Enabled{% else %}Disabled{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400">
|
||||||
|
{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'N/A' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<form method="post" action="{{ url_for('toggle_user_admin', user_id=user.id) }}">
|
||||||
|
<button type="submit" class="text-orange-500 hover:text-orange-400 hover:cursor-pointer">
|
||||||
|
{% if user.is_admin %}Remove Admin{% else %}Make Admin{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('delete_user', user_id=user.id) }}" onsubmit="return confirm('Delete user {{ user.username }}?');">
|
||||||
|
<button type="submit" class="text-red-500 hover:text-red-400 hover:cursor-pointer">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user