diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 2784bd4..119954f 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -1,13 +1,14 @@ import copy, json, subprocess, hashlib, os, uuid import os as _os +import sqlite3 as _sqlite3 from datetime import datetime, timezone from flask import session APP_DIR = _os.path.dirname(_os.path.abspath(__file__)) CONFIGS_DIR = '/routlin_location' +DATA_DIR = '/data' WWW_DIR = '/www' -ACCOUNTS_FILE = f'{CONFIGS_DIR}/.dashboard-accounts' -SESSIONS_DB = f'{CONFIGS_DIR}/.dashboard-sessions' +ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-accounts' CONFIG_FILE = f'{CONFIGS_DIR}/config.json' DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue' DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' @@ -28,6 +29,93 @@ DASHB_INTERVAL_SECS = 30 QUEUE_MAX_LINES = 50 +# Accounts DB ======================================================== + +def open_accounts_db(): + con = _sqlite3.connect(ACCOUNTS_DB, timeout=5) + con.execute('PRAGMA journal_mode=WAL') + con.row_factory = _sqlite3.Row + return con + +def init_accounts_db(): + con = open_accounts_db() + con.executescript(''' + CREATE TABLE IF NOT EXISTS accounts ( + account_id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + access_level INTEGER NOT NULL DEFAULT 1, + hashed_password TEXT NOT NULL DEFAULT '', + created_ts INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + tz_offset_seconds INTEGER NOT NULL DEFAULT 0, + apply_changes_immediately INTEGER NOT NULL DEFAULT 0, + session_started_ts INTEGER NOT NULL, + last_seen_ts INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS pending_verifications ( + email TEXT PRIMARY KEY, + hashed_password TEXT NOT NULL, + tz_offset_seconds INTEGER NOT NULL DEFAULT 0, + code TEXT NOT NULL, + expires_ts INTEGER NOT NULL + ); + ''') + con.commit() + con.close() + +_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} +_LEVEL_STR_TO_INT = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3} + +def _account_row_to_dict(row): + if row is None: + return None + import time as _t + from datetime import datetime as _dt + d = dict(row) + d['email_address'] = d.pop('email', d.get('email_address', '')) + d['access_level_int'] = d.get('access_level', 1) + d['access_level'] = _LEVEL_INT_TO_STR.get(d['access_level_int'], 'viewer') + d['account_status'] = 'active' if d.get('hashed_password') else 'pending' + d['account_created_by'] = d.get('created_by', '') + ts = d.get('created_ts', 0) + try: + d['account_created_ts'] = _dt.fromtimestamp(int(ts)).strftime('%Y-%m-%d %H:%M') if ts else '-' + except Exception: + d['account_created_ts'] = '-' + return d + +def get_account_by_email(email): + try: + con = open_accounts_db() + row = con.execute('SELECT * FROM accounts WHERE lower(email)=?', (email.lower(),)).fetchone() + con.close() + return _account_row_to_dict(row) + except Exception: + return None + +def get_account_by_id(account_id): + try: + con = open_accounts_db() + row = con.execute('SELECT * FROM accounts WHERE account_id=?', (account_id,)).fetchone() + con.close() + return _account_row_to_dict(row) + except Exception: + return None + +def list_accounts(): + try: + con = open_accounts_db() + rows = con.execute('SELECT * FROM accounts ORDER BY created_ts').fetchall() + con.close() + return [_account_row_to_dict(r) for r in rows] + except Exception: + return [] + + _config_cache = None _config_mtime = None @@ -354,7 +442,6 @@ def queued_msg(cmd=None, description='', action_label='Configuration saved'): # Snapshot system =================================================== import re as _re -import sqlite3 as _sqlite3 def _db(): @@ -845,17 +932,7 @@ def config_datasource(name): return rows if name == 'accounts': - try: - with open(ACCOUNTS_FILE) as f: - data = json.load(f) - except Exception: - data = {} - rows = [] - for acct in data.get('accounts', []): - row = dict(acct) - row['account_status'] = 'active' if acct.get('hashed_password') else 'pending' - rows.append(row) - return rows + return list_accounts() if name == 'vpn_peers': rows = [] diff --git a/docker/routlin-dash/app/factory.py b/docker/routlin-dash/app/factory.py index 56f52e5..d46f455 100644 --- a/docker/routlin-dash/app/factory.py +++ b/docker/routlin-dash/app/factory.py @@ -65,7 +65,7 @@ def load_ddns(): return config_utils.load_config().get('ddns', {}) def load_accounts(): - return load_json(config_utils.ACCOUNTS_FILE) + return {'accounts': config_utils.list_accounts()} def run(cmd): try: diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 9d44208..773caff 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -33,7 +33,8 @@ from session_interface import SqliteSessionInterface app = Flask(__name__) app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24)) -app.session_interface = SqliteSessionInterface(config_utils.SESSIONS_DB) +app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB) +config_utils.init_accounts_db() # Static www/ serving ================================================= @@ -151,33 +152,26 @@ app.register_blueprint(api_apply_health_bp) def _seed_initial_account(): + import uuid as _uuid, time as _t email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() if not email: - try: - with open(config_utils.ACCOUNTS_FILE) as f: - data = json.load(f) - except Exception: - data = {'accounts': []} - if not data.get('accounts'): + if not config_utils.list_accounts(): print('[main] WARNING: No accounts exist and INITIAL_MANAGER_EMAIL is not set. ' 'Set it in docker-compose.yml to seed the initial manager account.', file=sys.stderr) return - try: - with open(config_utils.ACCOUNTS_FILE) as f: - data = json.load(f) - except Exception: - data = {'accounts': []} - if data.get('accounts'): + if config_utils.list_accounts(): return - data['accounts'] = [{ - 'email_address': email, - 'access_level': 'manager', - 'hashed_password': '', - 'timezone': '', - }] - with open(config_utils.ACCOUNTS_FILE, 'w') as f: - json.dump(data, f, indent=2) - print(f'[main] Seeded initial manager account: {email}', file=sys.stderr) + try: + con = config_utils.open_accounts_db() + con.execute( + 'INSERT INTO accounts(account_id,email,access_level,created_ts,created_by) VALUES(?,?,?,?,?)', + (str(_uuid.uuid4()), email, 3, int(_t.time()), 'system') + ) + con.commit() + con.close() + print(f'[main] Seeded initial manager account: {email}', file=sys.stderr) + except Exception as exc: + print(f'[main] WARNING: Could not seed initial account: {exc}', file=sys.stderr) _seed_initial_account() diff --git a/docker/routlin-dash/app/pages/accountcreate/action.py b/docker/routlin-dash/app/pages/accountcreate/action.py index 6b80c4a..0f496ce 100644 --- a/docker/routlin-dash/app/pages/accountcreate/action.py +++ b/docker/routlin-dash/app/pages/accountcreate/action.py @@ -1,7 +1,7 @@ from pathlib import Path from flask import Blueprint, request, session, redirect, flash -import json, os, bcrypt, secrets, smtplib -from datetime import datetime, timezone, timedelta +import os, bcrypt, secrets, smtplib +import time from email.message import EmailMessage import auth import config_utils @@ -11,15 +11,7 @@ _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) -CODE_TTL_MIN = 15 - - -def _load_accounts(): - try: - with open(config_utils.ACCOUNTS_FILE) as f: - return json.load(f) - except Exception: - return {'accounts': []} +CODE_TTL_SECS = 15 * 60 def _send_verification_email(to_address, code): @@ -38,7 +30,7 @@ def _send_verification_email(to_address, code): msg['To'] = to_address msg.set_content( f'Your verification code is: {code}\n\n' - f'This code expires in {CODE_TTL_MIN} minutes.\n\n' + f'This code expires in 15 minutes.\n\n' f'If you did not request this, you can ignore this email.' ) @@ -51,10 +43,19 @@ def _send_verification_email(to_address, code): smtp.send_message(msg) +def _tz_to_offset_seconds(tz_str): + try: + from zoneinfo import ZoneInfo + from datetime import datetime + return int(datetime.now(ZoneInfo(tz_str)).utcoffset().total_seconds()) + except Exception: + import settings as _s + return _s.get_host_utc_offset() + + @bp.route('/action/accountcreate/form_create', methods=['POST']) @auth.require_level('nothing') def form_create(): - # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': return redirect('/overview') @@ -75,8 +76,7 @@ def form_create(): flash('Password must be at least 8 characters.', 'error') return redirect(f'/{_PAGE}') - accounts = _load_accounts().get('accounts', []) - account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) + account = config_utils.get_account_by_email(email) if account is None: flash('Email address not recognised. Contact your manager.', 'error') @@ -86,10 +86,11 @@ def form_create(): flash('This account is already set up. Please log in instead.', 'error') return redirect(f'/{_PAGE}') - salt = bcrypt.gensalt() - hashed = bcrypt.hashpw(password.encode('utf-8'), salt) - code = f'{secrets.randbelow(1000000):06d}' - expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat() + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') + code = f'{secrets.randbelow(1000000):06d}' + expires_ts = int(time.time()) + CODE_TTL_SECS + tz_offset = _tz_to_offset_seconds(tz) try: _send_verification_email(account['email_address'], code) @@ -97,12 +98,20 @@ def form_create(): flash(f'Could not send verification email: {exc}', 'error') return redirect(f'/{_PAGE}') - session['pending_create_account'] = { - 'email': account['email_address'], - 'hashed_password': hashed.decode('utf-8'), - 'timezone': tz, - 'code': code, - 'expires': expires, - } + try: + con = config_utils.open_accounts_db() + con.execute( + '''INSERT OR REPLACE INTO pending_verifications + (email, hashed_password, tz_offset_seconds, code, expires_ts) + VALUES (?,?,?,?,?)''', + (account['email_address'].lower(), hashed, tz_offset, code, expires_ts) + ) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not store verification: {exc}', 'error') + return redirect(f'/{_PAGE}') + + session['pending_verify_email'] = account['email_address'] return redirect('/accountverifyemail') diff --git a/docker/routlin-dash/app/pages/accountlogin/action.py b/docker/routlin-dash/app/pages/accountlogin/action.py index 313378a..ac4ddeb 100644 --- a/docker/routlin-dash/app/pages/accountlogin/action.py +++ b/docker/routlin-dash/app/pages/accountlogin/action.py @@ -1,28 +1,19 @@ from pathlib import Path from flask import Blueprint, request, session, redirect, flash -import json, bcrypt +import bcrypt import auth import config_utils import sanitize +import settings _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) - -def _load_accounts(): - try: - with open(config_utils.ACCOUNTS_FILE) as f: - return json.load(f) - except Exception: - return {'accounts': []} - - @bp.route('/action/accountlogin/form_login', methods=['POST']) @auth.require_level('nothing') def form_login(): - # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': return redirect('/overview') @@ -33,8 +24,7 @@ def form_login(): flash('Email address and password are required.', 'error') return redirect(f'/{_PAGE}') - accounts = _load_accounts().get('accounts', []) - account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) + account = config_utils.get_account_by_email(email) if account is None: flash('Email address not recognised.', 'error') @@ -44,16 +34,14 @@ def form_login(): flash('Account setup is not complete. Please use Create Account to set your password first.', 'error') return redirect(f'/{_PAGE}') - stored_hash = account['hashed_password'].encode('utf-8') - if not bcrypt.checkpw(password.encode('utf-8'), stored_hash): + if not bcrypt.checkpw(password.encode('utf-8'), account['hashed_password'].encode('utf-8')): flash('Invalid email address or password.', 'error') return redirect(f'/{_PAGE}') session.clear() - session['email_address'] = account['email_address'] - session['access_level'] = account.get('access_level', 'viewer') - session['timezone'] = account.get('timezone', '') + session['account_id'] = account['account_id'] + session['tz_offset_seconds'] = settings.get_host_utc_offset() session['apply_changes_immediately'] = False - session.permanent = True + session.permanent = True return redirect('/overview') diff --git a/docker/routlin-dash/app/pages/accountmanage/action.py b/docker/routlin-dash/app/pages/accountmanage/action.py index 1b48bca..47dbd3c 100644 --- a/docker/routlin-dash/app/pages/accountmanage/action.py +++ b/docker/routlin-dash/app/pages/accountmanage/action.py @@ -1,6 +1,6 @@ from pathlib import Path from flask import Blueprint, request, session, redirect, flash -import json, os, re, sqlite3 +import os, re, sqlite3 from datetime import datetime, timezone import auth import config_utils @@ -10,24 +10,31 @@ _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) -VALID_LEVELS = {'viewer', 'administrator', 'manager'} +VALID_LEVELS = {'viewer': 1, 'administrator': 2, 'manager': 3} -def _load_accounts(): +@bp.route('/action/accountmanage/session_invalidate', methods=['POST']) +@auth.require_level('manager') +def session_invalidate(): + sid = request.form.get('session_id', '').strip() + if not sid: + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') try: - with open(config_utils.ACCOUNTS_FILE) as f: - return json.load(f) + con = config_utils.open_accounts_db() + con.execute('DELETE FROM sessions WHERE session_id=?', (sid,)) + con.commit() + con.close() + flash('Session invalidated.', 'success') except Exception: - return {'accounts': []} - -def _save_accounts(data): - with open(config_utils.ACCOUNTS_FILE, 'w') as f: - json.dump(data, f, indent=2) + flash('Failed to invalidate session.', 'error') + return redirect(f'/{_PAGE}') @bp.route('/action/accountmanage/accounts_add', methods=['POST']) @auth.require_level('manager') def accounts_add(): + import uuid as _uuid, time as _t email = sanitize.email(request.form.get('email_address', '')) access_level = request.form.get('access_level', '').strip() @@ -43,26 +50,24 @@ def accounts_add(): flash('Invalid access level.', 'error') return redirect(f'/{_PAGE}') - data = _load_accounts() - accounts = data.get('accounts', []) - - if any(a.get('email_address', '').lower() == email for a in accounts): + if config_utils.get_account_by_email(email): flash('An account with that email address already exists.', 'error') return redirect(f'/{_PAGE}') - now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') - accounts.append({ - 'email_address': email, - 'access_level': access_level, - 'account_created_utc': now, - 'account_created_by': session.get('email_address', ''), - 'hashed_password': '', - 'timezone': '', - }) - data['accounts'] = accounts - _save_accounts(data) + try: + con = config_utils.open_accounts_db() + con.execute( + 'INSERT INTO accounts(account_id,email,access_level,created_ts,created_by) VALUES(?,?,?,?,?)', + (str(_uuid.uuid4()), email, VALID_LEVELS[access_level], int(_t.time()), + session.get('email_address', '')) + ) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not add account: {exc}', 'error') + return redirect(f'/{_PAGE}') - flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success') + flash(f'Authorization added for {email}.', 'success') return redirect(f'/{_PAGE}') @@ -80,9 +85,7 @@ def accounts_edit(): flash('Invalid access level.', 'error') return redirect(f'/{_PAGE}') - data = _load_accounts() - accounts = data.get('accounts', []) - + accounts = config_utils.list_accounts() if row_index < 0 or row_index >= len(accounts): flash('Account not found.', 'error') return redirect(f'/{_PAGE}') @@ -92,29 +95,19 @@ def accounts_edit(): flash('You cannot change your own access level.', 'error') return redirect(f'/{_PAGE}') - accounts[row_index]['access_level'] = access_level - data['accounts'] = accounts - _save_accounts(data) - - flash('Account updated.', 'success') - return redirect(f'/{_PAGE}') - - -@bp.route('/action/accountmanage/session_invalidate', methods=['POST']) -@auth.require_level('manager') -def session_invalidate(): - sid = request.form.get('session_id', '').strip() - if not sid: - flash('Invalid request.', 'error') - return redirect(f'/{_PAGE}') try: - con = sqlite3.connect(config_utils.SESSIONS_DB, timeout=5) - con.execute('DELETE FROM sessions WHERE session_id=?', (sid,)) + con = config_utils.open_accounts_db() + con.execute( + 'UPDATE accounts SET access_level=? WHERE account_id=?', + (VALID_LEVELS[access_level], target['account_id']) + ) con.commit() con.close() - flash('Session invalidated.', 'success') - except Exception: - flash('Failed to invalidate session.', 'error') + except Exception as exc: + flash(f'Could not update account: {exc}', 'error') + return redirect(f'/{_PAGE}') + + flash('Account updated.', 'success') return redirect(f'/{_PAGE}') @@ -127,26 +120,29 @@ def accounts_delete(): flash('Invalid request.', 'error') return redirect(f'/{_PAGE}') - data = _load_accounts() - accounts = data.get('accounts', []) - + accounts = config_utils.list_accounts() if row_index < 0 or row_index >= len(accounts): flash('Account not found.', 'error') return redirect(f'/{_PAGE}') - target = accounts[row_index] - + target = accounts[row_index] target_email = target.get('email_address', '').lower() current_email = session.get('email_address', '').lower() initial_email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() + if target_email == current_email and target_email != initial_email: flash('You cannot remove your own account.', 'error') return redirect(f'/{_PAGE}') - removed_email = target.get('email_address', '') - accounts.pop(row_index) - data['accounts'] = accounts - _save_accounts(data) + try: + con = config_utils.open_accounts_db() + con.execute('DELETE FROM sessions WHERE account_id=?', (target['account_id'],)) + con.execute('DELETE FROM accounts WHERE account_id=?', (target['account_id'],)) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not delete account: {exc}', 'error') + return redirect(f'/{_PAGE}') - flash(f'Account for {removed_email} has been removed.', 'success') + flash(f'Account for {target["email_address"]} has been removed.', 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/accountmanage/content.json b/docker/routlin-dash/app/pages/accountmanage/content.json index 861fb85..7f9b252 100644 --- a/docker/routlin-dash/app/pages/accountmanage/content.json +++ b/docker/routlin-dash/app/pages/accountmanage/content.json @@ -32,7 +32,7 @@ {"label": "Email Address", "field": "email_address"}, {"label": "Access Level", "field": "access_level"}, {"label": "Added By", "field": "account_created_by"}, - {"label": "Added", "field": "account_created_utc"}, + {"label": "Added", "field": "account_created_ts"}, { "label": "Status", "field": "account_status", diff --git a/docker/routlin-dash/app/pages/accountmanage/view.py b/docker/routlin-dash/app/pages/accountmanage/view.py index a48bc67..7b07460 100644 --- a/docker/routlin-dash/app/pages/accountmanage/view.py +++ b/docker/routlin-dash/app/pages/accountmanage/view.py @@ -1,25 +1,33 @@ import json import sqlite3 import time -from datetime import datetime, timezone +from datetime import datetime import config_utils import factory -def _fmt_ts(ts): +def _fmt_ts(ts, now): try: - dt = datetime.fromtimestamp(int(ts), tz=timezone.utc) - return dt.strftime('%Y-%m-%d %H:%M UTC') + dt = datetime.fromtimestamp(int(ts)) + ago = config_utils.relative_time(int(ts), now) + return f'{dt.strftime("%Y-%m-%d %H:%M")} ({ago} ago)' except Exception: return '-' +_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} + + def _active_sessions_table(): try: - con = sqlite3.connect(config_utils.SESSIONS_DB, timeout=5) + con = sqlite3.connect(config_utils.ACCOUNTS_DB, timeout=5) + con.row_factory = sqlite3.Row rows = con.execute( - 'SELECT session_id, email, access_level, created_at, last_seen' - ' FROM sessions ORDER BY last_seen DESC' + '''SELECT s.session_id, a.email, a.access_level, + s.session_started_ts, s.last_seen_ts + FROM sessions s + JOIN accounts a ON a.account_id = s.account_id + ORDER BY s.last_seen_ts DESC''' ).fetchall() con.close() except Exception: @@ -30,12 +38,21 @@ def _active_sessions_table(): now = int(time.time()) trs = '' - for sid, email, access_level, created_at, last_seen in rows: - online = (now - int(last_seen)) < 300 - badge = ( - 'Online' - if online else - 'Offline' + for row in rows: + sid = row['session_id'] + email = row['email'] + access_level = _LEVEL_INT_TO_STR.get(row['access_level'], 'viewer') + started_ts = row['session_started_ts'] + last_seen = row['last_seen_ts'] + online = (now - int(last_seen)) < 300 + ago = config_utils.relative_time(int(last_seen), now) + tip = factory.e(f'Last seen {ago} ago') + badge_cls = 'badge-enabled' if online else 'badge-disabled' + badge_lbl = 'Online' if online else 'Offline' + badge = ( + f'' + f'{badge_lbl}' + f'' ) btn = ( f'