feat: implement PBKDF2 authentication, first-run setup, and role-based guards

This commit is contained in:
Silas Brito
2026-07-13 19:13:38 -03:00
parent 27652716d7
commit ecdee26142
2 changed files with 350 additions and 94 deletions
+119 -9
View File
@@ -1,6 +1,7 @@
import os
import sys
import sqlite3
import hashlib
import pandas as pd
def get_db_path():
@@ -9,12 +10,10 @@ def get_db_path():
If the application is running inside a Docker container, it uses /app/data/nbu_insights.db.
If running locally, it defaults to a local './data' directory.
"""
# Check if we are running in the container workspace or local workspace
container_data_dir = "/app/data"
if os.path.exists(container_data_dir) or os.environ.get("NBU_INSIGHTS_RUNNING") == "1":
db_dir = container_data_dir
else:
# Local development fallback
base_dir = os.path.dirname(os.path.abspath(__file__))
db_dir = os.path.join(base_dir, "data")
@@ -31,15 +30,39 @@ def get_connection():
conn.row_factory = sqlite3.Row
return conn
def hash_password(password, salt=None):
"""
Hashes a password using PBKDF2-SHA256 with 100,000 iterations and a unique salt.
Returns string 'salt_hex:hash_hex' which is safe to store in the DB.
"""
if salt is None:
salt = os.urandom(16)
elif isinstance(salt, str):
salt = bytes.fromhex(salt)
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
return salt.hex() + ":" + key.hex()
def verify_password(stored_password_hash, provided_password):
"""
Verifies a password against its stored PBKDF2-SHA256 hash.
"""
try:
salt_hex, key_hex = stored_password_hash.split(":")
salt = bytes.fromhex(salt_hex)
expected_key_hex = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt, 100000).hex()
return key_hex == expected_key_hex
except Exception:
return False
def init_db():
"""
Initializes the SQLite database tables (processed_files, backup_jobs, and job_actions)
according to the v2.0 spec.
Initializes the SQLite database tables according to v2.0/v3.0 specs.
"""
conn = get_connection()
cursor = conn.cursor()
# Track processed files
# Processed files table
cursor.execute("""
CREATE TABLE IF NOT EXISTS processed_files (
file_hash TEXT PRIMARY KEY,
@@ -48,7 +71,7 @@ def init_db():
);
""")
# Store backup jobs (with UPSERT mapping)
# Backup jobs table
cursor.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs (
job_id INTEGER PRIMARY KEY,
@@ -67,7 +90,7 @@ def init_db():
);
""")
# Store technician actions
# Technician actions table
cursor.execute("""
CREATE TABLE IF NOT EXISTS job_actions (
job_id INTEGER PRIMARY KEY,
@@ -78,6 +101,15 @@ def init_db():
);
""")
# Users table
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password_hash TEXT,
role TEXT DEFAULT 'user' -- 'admin' or 'user'
);
""")
conn.commit()
conn.close()
@@ -108,13 +140,11 @@ def mark_file_processed(file_hash, file_name):
def save_jobs(jobs_df):
"""
Saves or updates jobs into the backup_jobs table using SQL UPSERT.
Preserves existing job actions by updating job details but not touching actions.
"""
conn = get_connection()
cursor = conn.cursor()
for _, row in jobs_df.iterrows():
# Handle nan values for start/end times
start_time = str(row['Start Time']) if not pd.isna(row['Start Time']) else None
finish_time = str(row['Finish Time']) if not pd.isna(row['Finish Time']) else None
@@ -186,3 +216,83 @@ def get_historical_jobs():
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
# --- User Management CRUD Methods ---
def create_user(username, password, role='user'):
"""
Hashes the password and creates a new user inside the SQLite store.
Username is stored in lowercase to ensure uniqueness.
"""
conn = get_connection()
cursor = conn.cursor()
password_hash = hash_password(password)
try:
cursor.execute(
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
(username.lower().strip(), password_hash, role)
)
conn.commit()
success = True
except sqlite3.IntegrityError:
# Username collision
success = False
conn.close()
return success
def get_user(username):
"""
Retrieves user profile dictionary by username.
"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE username = ?", (username.lower().strip(),))
row = cursor.fetchone()
conn.close()
return dict(row) if row else None
def has_admin_user():
"""
Returns True if there is at least one administrator user registered.
Used for routing to the initial bootstrap setup screen.
"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1")
row = cursor.fetchone()
conn.close()
return row is not None
def get_all_users():
"""
Returns lists of all user details (excluding their hashed passwords).
"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT username, role FROM users ORDER BY username")
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def delete_user(username):
"""
Removes a user by their username.
"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE username = ?", (username.lower().strip(),))
conn.commit()
conn.close()
def reset_all_data():
"""
Destructive helper: Truncates all backup jobs, processed file hashes,
and technician actions. User accounts are NOT deleted.
"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM backup_jobs")
cursor.execute("DELETE FROM job_actions")
cursor.execute("DELETE FROM processed_files")
conn.commit()
conn.close()