login system

This commit is contained in:
2026-06-02 11:31:11 -04:00
parent f6bced78f3
commit a1b120076b
17 changed files with 579 additions and 27 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
.venv
__pycache__
*.pyc
dashboard.db*
dashboard.db*
.env
-5
View File
@@ -1,5 +0,0 @@
# Flask backend port (default: 3001)
PORT=3001
# Note: Target and results directories are configured via the dashboard Settings UI
# and stored in SQLite (dashboard.db), not in environment variables.
+11
View File
@@ -0,0 +1,11 @@
# Flask backend port (default: 3001)
PORT=3001
JWT_SECRET=
JWT_EXPIRES_HOURS=8
DEFAULT_ADMIN_USERNAME=wnc
DEFAULT_ADMIN_PASSWORD=@wnc111111
JWT_ALGORITHM=HS256
DEFAULT_VIEWER_USERNAME=viewer
DEFAULT_VIEWER_PASSWORD=viewer3040
+210 -3
View File
@@ -1,13 +1,41 @@
import os
from datetime import datetime, timedelta, timezone
from functools import wraps
from pathlib import Path
from flask import Flask, jsonify, request, send_from_directory
import jwt
from dotenv import load_dotenv
from flask import Flask, g, jsonify, request, send_from_directory
from flask_cors import CORS
from werkzeug.security import check_password_hash, generate_password_hash
from db_py import count_tests, del_config, get_all_tests, get_config, set_config
from db_py import (
count_tests,
count_users,
create_user,
del_config,
get_all_tests,
get_all_users,
get_config,
get_user_by_id,
get_user_by_username,
set_config,
clear_users,
)
from scanner import full_scan, is_scan_in_progress, resolve_runtime_path, scan_results_only
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")
PORT = int(os.getenv("PORT", "3001"))
JWT_SECRET = os.getenv("JWT_SECRET")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
JWT_EXPIRES_HOURS = int(os.getenv("JWT_EXPIRES_HOURS", "8"))
DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME")
DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD")
DEFAULT_VIEWER_USERNAME = os.getenv("DEFAULT_VIEWER_USERNAME")
DEFAULT_VIEWER_PASSWORD = os.getenv("DEFAULT_VIEWER_PASSWORD")
ALLOWED_KEYS = {
"target_dir",
"results_dir",
@@ -21,13 +49,110 @@ ALLOWED_KEYS = {
"smb_domain",
}
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR.parent / "dashboard" / "dist"
app = Flask(__name__, static_folder=str(DIST_DIR), static_url_path="")
CORS(app)
def _make_token(user):
now = datetime.now(timezone.utc)
payload = {
"sub": str(user["id"]),
"username": user["username"],
"role": user["role"],
"iat": int(now.timestamp()),
"exp": int((now + timedelta(hours=JWT_EXPIRES_HOURS)).timestamp()),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def _decode_token(token):
try:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except jwt.InvalidTokenError:
return None
def _extract_bearer_token():
auth_header = request.headers.get("Authorization", "")
if not auth_header.lower().startswith("bearer "):
return None
return auth_header[7:].strip() or None
def require_auth(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
token = _extract_bearer_token()
if not token:
return jsonify({"error": "Authentication required"}), 401
payload = _decode_token(token)
if payload is None:
return jsonify({"error": "Invalid or expired token"}), 401
try:
user_id = int(payload.get("sub"))
except (TypeError, ValueError):
return jsonify({"error": "Invalid token subject"}), 401
user = get_user_by_id(user_id)
if not user or not user.get("is_active"):
return jsonify({"error": "User is not authorized"}), 401
g.current_user = {
"id": user["id"],
"username": user["username"],
"role": user["role"],
}
return fn(*args, **kwargs)
return wrapper
def require_role(required_role):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
current_user = getattr(g, "current_user", None)
if not current_user:
return jsonify({"error": "Authentication required"}), 401
if current_user.get("role") != required_role:
return jsonify({"error": "Forbidden"}), 403
return fn(*args, **kwargs)
return wrapper
return decorator
def _create_default_user_if_missing(username, password, role):
if not username or not password:
print(f"[server] Skipping default {role} seed: username/password not configured")
return
existing = get_user_by_username(username)
if existing:
return
create_user(
username,
generate_password_hash(password),
role=role,
is_active=1,
)
print(f"[server] Created default {role} user: {username}")
def _ensure_default_users():
if count_users() == 0:
print("[server] No users found. Seeding default accounts...")
_create_default_user_if_missing(DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD, "admin")
_create_default_user_if_missing(DEFAULT_VIEWER_USERNAME, DEFAULT_VIEWER_PASSWORD, "viewer")
def _apply_smb_env_from_config():
mapping = {
"SMB_USERNAME": get_config("smb_username"),
@@ -43,6 +168,7 @@ def _apply_smb_env_from_config():
@app.get("/api/tests")
@require_auth
def get_tests_route():
completed = request.args.get("completed")
interference = request.args.get("interference")
@@ -90,6 +216,7 @@ def get_tests_route():
@app.get("/api/stats")
@require_auth
def get_stats_route():
tests = get_all_tests()
types = ["COE", "P2P", "P3P"]
@@ -197,11 +324,84 @@ def get_stats_route():
@app.get("/api/scan-status")
@require_auth
def get_scan_status_route():
return jsonify({"scanning": is_scan_in_progress()})
@app.post("/api/auth/login")
def auth_login_route():
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"error": "Request body must be a JSON object"}), 400
username = (body.get("username") or "").strip()
password = body.get("password") or ""
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
user = get_user_by_username(username)
if not user or not user.get("is_active"):
return jsonify({"error": "Invalid username or password"}), 401
if not check_password_hash(user["password_hash"], password):
return jsonify({"error": "Invalid username or password"}), 401
token = _make_token(user)
return jsonify(
{
"token": token,
"user": {
"id": user["id"],
"username": user["username"],
"role": user["role"],
},
}
)
@app.get("/api/auth/me")
@require_auth
def auth_me_route():
return jsonify({"user": g.current_user})
@app.get("/api/users")
@require_auth
@require_role("admin")
def list_users_route():
return jsonify(get_all_users())
@app.post("/api/users")
@require_auth
@require_role("admin")
def create_user_route():
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"error": "Request body must be a JSON object"}), 400
username = (body.get("username") or "").strip()
password = body.get("password") or ""
role = (body.get("role") or "viewer").strip().lower()
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
if role not in {"admin", "viewer"}:
return jsonify({"error": "Role must be admin or viewer"}), 400
if get_user_by_username(username):
return jsonify({"error": "User already exists"}), 409
user_id = create_user(username, generate_password_hash(password), role=role, is_active=1)
return jsonify({"id": user_id, "username": username, "role": role, "is_active": 1}), 201
@app.get("/api/config")
@require_auth
@require_role("admin")
def get_config_route():
config = {}
for key in ALLOWED_KEYS:
@@ -210,6 +410,8 @@ def get_config_route():
@app.post("/api/config")
@require_auth
@require_role("admin")
def set_config_route():
updates = request.get_json(silent=True)
if not isinstance(updates, dict):
@@ -249,6 +451,8 @@ def set_config_route():
@app.post("/api/config/rescan")
@require_auth
@require_role("admin")
def rescan_route():
_apply_smb_env_from_config()
target_dir = resolve_runtime_path(get_config("target_dir"))
@@ -267,6 +471,8 @@ def rescan_route():
@app.post("/api/config/rescan-results")
@require_auth
@require_role("admin")
def rescan_results_route():
_apply_smb_env_from_config()
results_dir = resolve_runtime_path(get_config("results_dir"))
@@ -295,6 +501,7 @@ def static_or_spa(path=""):
def bootstrap():
_ensure_default_users()
_apply_smb_env_from_config()
target_dir = resolve_runtime_path(get_config("target_dir"))
results_dir = resolve_runtime_path(get_config("results_dir"))
Binary file not shown.
+70
View File
@@ -54,8 +54,18 @@ def _init_db():
duration_seconds REAL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tests_test_id ON tests (test_id);
CREATE INDEX IF NOT EXISTS idx_tests_device ON tests (device);
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
"""
)
@@ -278,4 +288,64 @@ def count_tests():
return row["n"]
def get_user_by_username(username):
with _lock:
row = _conn.execute(
"""
SELECT id, username, password_hash, role, is_active, created_at
FROM users
WHERE username = ?
""",
(username,),
).fetchone()
return dict(row) if row else None
def create_user(username, password_hash, role="viewer", is_active=1):
with _tx():
cursor = _conn.execute(
"""
INSERT INTO users (username, password_hash, role, is_active)
VALUES (?, ?, ?, ?)
""",
(username, password_hash, role, is_active),
)
return cursor.lastrowid
def count_users():
with _lock:
row = _conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()
return row["n"]
def get_user_by_id(user_id):
with _lock:
row = _conn.execute(
"""
SELECT id, username, password_hash, role, is_active, created_at
FROM users
WHERE id = ?
""",
(user_id,),
).fetchone()
return dict(row) if row else None
def get_all_users():
with _lock:
rows = _conn.execute(
"""
SELECT id, username, role, is_active, created_at
FROM users
ORDER BY username ASC
"""
).fetchall()
return [dict(row) for row in rows]
def clear_users():
with _tx():
_conn.execute("DELETE FROM users")
_init_db()
+2
View File
@@ -1,3 +1,5 @@
Flask>=3.0.0,<4.0.0
Flask-Cors>=4.0.1,<5.0.0
smbprotocol>=1.13.0,<2.0.0
PyJWT>=2.9.0,<3.0.0
python-dotenv>=1.0.1,<2.0.0