feat: implement PBKDF2 authentication, first-run setup, and role-based guards
This commit is contained in:
+119
-9
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user