feat: add ability to change username, password and setup totp
Release / release (pull_request) Successful in 20s

This commit is contained in:
2026-03-23 11:28:56 +00:00
parent 8d12327752
commit 68a8bdfe9e
8 changed files with 660 additions and 11 deletions
+272 -3
View File
@@ -11,6 +11,7 @@ from datetime import datetime, timedelta
import threading
import time
from dotenv import load_dotenv
import pyotp
from database import Database
from ssh_keys import SSHKeyManager
@@ -56,8 +57,11 @@ try:
default_user = db.get_user_by_username('admin')
if not default_user:
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!)")
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")
except Exception as e:
logger.error(f"Database initialization failed: {e}")
@@ -80,6 +84,34 @@ def login_required(f):
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('/')
def index():
"""Redirect to dashboard if logged in, otherwise to login."""
@@ -91,6 +123,9 @@ def index():
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Login page."""
if session.get('totp_pending_user_id'):
return redirect(url_for('login_totp'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
@@ -101,8 +136,14 @@ def login():
user = db.get_user_by_username(username)
if user and check_password_hash(user['password_hash'], password):
session['user_id'] = user['id']
session['username'] = user['username']
if user.get('totp_enabled'):
# 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'))
else:
flash('Invalid username or password', 'error')
@@ -110,6 +151,43 @@ def login():
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')
def logout():
"""Logout and clear session."""
@@ -117,6 +195,197 @@ def logout():
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')
@login_required
def dashboard():