diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 8fb2fcf..d4d127f 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -9,7 +9,7 @@ APP_DIR = _os.path.dirname(_os.path.abspath(__file__)) CONFIGS_DIR = _settings.routlin_location() DATA_DIR = '/data' WWW_DIR = '/www' -ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-accounts' +ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-singleaccount' if _settings.is_single_user() else 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' @@ -81,6 +81,23 @@ def init_accounts_db(): pass con.close() +def init_single_user_session_db(): + con = _sqlite3.connect(ACCOUNTS_DB, timeout=5) + con.execute('PRAGMA journal_mode=WAL') + con.executescript(''' + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + logged_in INTEGER NOT NULL DEFAULT 0, + timezone TEXT NOT NULL DEFAULT '', + preferences_json TEXT NOT NULL DEFAULT '{}', + flashes_json TEXT, + session_started_ts INTEGER NOT NULL, + last_seen_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} diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 0cddae0..4bb6e32 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -32,8 +32,13 @@ from api_apply_health import bp as api_apply_health_bp from session_interface import SqliteSessionInterface app = Flask(__name__) -app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB) -config_utils.init_accounts_db() +if settings.is_single_user(): + from session_interface import SingleUserSessionInterface + config_utils.init_single_user_session_db() + app.session_interface = SingleUserSessionInterface(config_utils.ACCOUNTS_DB) +else: + app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB) + config_utils.init_accounts_db() # Static www/ serving ================================================= @@ -83,6 +88,9 @@ def serve_view(page_name): if not factory.passes(view_req, level): return redirect('/overview' if level > 0 else '/accountlogin') + if settings.is_single_user() and page_name in ('accountmanage', 'accountcreate', 'accountverifyemail'): + return redirect('/overview' if level > 0 else '/accountlogin') + cfg = config_utils.load_config() if level >= factory.LEVEL_RANK['administrator']: @@ -138,10 +146,11 @@ app.register_blueprint(physicalinterfaces_bp) app.register_blueprint(portforwarding_bp) app.register_blueprint(portwrangling_bp) app.register_blueprint(preferences_bp) -app.register_blueprint(accountverifyemail_bp) +if not settings.is_single_user(): + app.register_blueprint(accountverifyemail_bp) + app.register_blueprint(accountcreate_bp) + app.register_blueprint(accountmanage_bp) app.register_blueprint(vpn_bp) -app.register_blueprint(accountcreate_bp) -app.register_blueprint(accountmanage_bp) app.register_blueprint(accountlogout_bp) app.register_blueprint(mdns_bp) app.register_blueprint(radius_bp) @@ -151,6 +160,8 @@ app.register_blueprint(api_apply_health_bp) def _seed_initial_account(): + if settings.is_single_user(): + return import uuid as _uuid, time as _t email = settings.get_initial_manager_email() if not email: diff --git a/docker/routlin-dash/app/pages/accountlogin/action.py b/docker/routlin-dash/app/pages/accountlogin/action.py index b402b9d..f79ea25 100644 --- a/docker/routlin-dash/app/pages/accountlogin/action.py +++ b/docker/routlin-dash/app/pages/accountlogin/action.py @@ -24,6 +24,21 @@ def form_login(): flash('Email address and password are required.', 'error') return redirect(f'/{_PAGE}') + if settings.is_single_user(): + stored_hash = settings.get_initial_manager_password_hash() + if email != settings.get_initial_manager_email() or not stored_hash: + flash('Invalid email address or password.', 'error') + return redirect(f'/{_PAGE}') + if not bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')): + flash('Invalid email address or password.', 'error') + return redirect(f'/{_PAGE}') + session.clear() + session['logged_in'] = True + session['timezone'] = settings.get_host_timezone() + session['apply_changes_immediately'] = False + session.permanent = True + return redirect('/overview') + account = config_utils.get_account_by_email(email) if account is None: diff --git a/docker/routlin-dash/app/pages/preferences/action.py b/docker/routlin-dash/app/pages/preferences/action.py index 9a334ea..f204a17 100644 --- a/docker/routlin-dash/app/pages/preferences/action.py +++ b/docker/routlin-dash/app/pages/preferences/action.py @@ -4,6 +4,7 @@ import bcrypt import auth import config_utils import sanitize +import settings _PAGE = Path(__file__).parent.name @@ -25,9 +26,62 @@ def accountdetails_save(): return redirect(f'/{_PAGE}') +@bp.route('/action/preferences/email_change_direct', methods=['POST']) +@auth.require_level('viewer') +def email_change_direct(): + if session.get('email_address', '').lower() != settings.get_initial_manager_email(): + flash('Not authorised.', 'error') + return redirect(f'/{_PAGE}') + + new_email = sanitize.email(request.form.get('new_email', '').strip()) + if not new_email: + flash('A valid email address is required.', 'error') + return redirect(f'/{_PAGE}') + + current_email = session.get('email_address', '').lower() + if new_email == current_email: + flash('That is already your current email address.', 'error') + return redirect(f'/{_PAGE}') + + if not settings.is_single_user() and config_utils.get_account_by_email(new_email): + flash('That email address is already in use.', 'error') + return redirect(f'/{_PAGE}') + + if not settings.is_single_user(): + try: + con = config_utils.open_accounts_db() + con.execute( + 'UPDATE accounts SET email=?, requested_email=NULL WHERE account_id=?', + (new_email, session.get('account_id', '')) + ) + con.commit() + con.close() + except Exception as exc: + flash(f'Could not update account: {exc}', 'error') + return redirect(f'/{_PAGE}') + + try: + import json as _json + cfg_path = Path(settings._APP_CONFIG_PATH) + cfg = _json.loads(cfg_path.read_text()) if cfg_path.exists() else {} + cfg['initial_manager_email'] = new_email + cfg_path.write_text(_json.dumps(cfg, indent=2) + '\n') + settings._app_config_cache = None + except Exception as exc: + flash(f'Could not update app_config.json: {exc}', 'error') + return redirect(f'/{_PAGE}') + + session['email_address'] = new_email + flash('Email address updated.', 'success') + return redirect(f'/{_PAGE}') + + @bp.route('/action/preferences/email_change_request', methods=['POST']) @auth.require_level('viewer') def email_change_request(): + if settings.is_single_user(): + flash('Not available in single-user mode.', 'error') + return redirect(f'/{_PAGE}') new_email = sanitize.email(request.form.get('new_email', '').strip()) if not new_email: flash('A valid email address is required.', 'error') @@ -77,6 +131,26 @@ def changepassword_save(): flash('New password must be at least 8 characters.', 'error') return redirect(f'/{_PAGE}') + hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + if settings.is_single_user(): + stored_hash = settings.get_initial_manager_password_hash() + if not stored_hash or not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash.encode('utf-8')): + flash('Current password is incorrect.', 'error') + return redirect(f'/{_PAGE}') + try: + import json as _json + cfg_path = Path(settings._APP_CONFIG_PATH) + cfg = _json.loads(cfg_path.read_text()) if cfg_path.exists() else {} + cfg['initial_manager_password'] = hashed + cfg_path.write_text(_json.dumps(cfg, indent=2) + '\n') + settings._app_config_cache = None + except Exception as exc: + flash(f'Could not update password: {exc}', 'error') + return redirect(f'/{_PAGE}') + flash('Password changed successfully.', 'success') + return redirect(f'/{_PAGE}') + account = config_utils.get_account_by_id(session.get('account_id', '')) if account is None: flash('Account not found. Please log in again.', 'error') @@ -86,8 +160,6 @@ def changepassword_save(): flash('Current password is incorrect.', 'error') return redirect(f'/{_PAGE}') - hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - try: con = config_utils.open_accounts_db() con.execute( diff --git a/docker/routlin-dash/app/pages/preferences/content.json b/docker/routlin-dash/app/pages/preferences/content.json index 2a374a1..d7977c5 100644 --- a/docker/routlin-dash/app/pages/preferences/content.json +++ b/docker/routlin-dash/app/pages/preferences/content.json @@ -54,7 +54,7 @@ }, { "type": "form", - "action": "/action/preferences/email_change_request", + "action": "%EMAIL_CHANGE_ACTION%", "method": "post", "items": [ { @@ -70,7 +70,7 @@ "items": [ { "type": "button_primary", - "text": "Submit Request" + "text": "%EMAIL_CHANGE_BTN_TEXT%" } ] } diff --git a/docker/routlin-dash/app/pages/preferences/view.py b/docker/routlin-dash/app/pages/preferences/view.py index b4e8812..3d2e68d 100644 --- a/docker/routlin-dash/app/pages/preferences/view.py +++ b/docker/routlin-dash/app/pages/preferences/view.py @@ -3,6 +3,7 @@ from flask import session import sanitize import config_utils import factory +import settings def collect_tokens(cfg): @@ -12,8 +13,19 @@ def collect_tokens(cfg): tokens['PREF_TIMEZONE'] = session.get('timezone', '') tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]) - account = config_utils.get_account_by_id(session.get('account_id', '')) - requested = (account or {}).get('requested_email', '') + is_initial_manager = session.get('email_address', '').lower() == settings.get_initial_manager_email() + if is_initial_manager: + tokens['EMAIL_CHANGE_ACTION'] = '/action/preferences/email_change_direct' + tokens['EMAIL_CHANGE_BTN_TEXT'] = 'Change Email' + else: + tokens['EMAIL_CHANGE_ACTION'] = '/action/preferences/email_change_request' + tokens['EMAIL_CHANGE_BTN_TEXT'] = 'Submit Request' + + if not settings.is_single_user(): + account = config_utils.get_account_by_id(session.get('account_id', '')) + requested = (account or {}).get('requested_email', '') + else: + requested = '' if requested: tokens['PENDING_EMAIL_BAR'] = ( f'
' diff --git a/docker/routlin-dash/app/session_interface.py b/docker/routlin-dash/app/session_interface.py index c730e45..99d2c07 100644 --- a/docker/routlin-dash/app/session_interface.py +++ b/docker/routlin-dash/app/session_interface.py @@ -134,3 +134,97 @@ class SqliteSessionInterface(SessionInterface): secure=self.get_cookie_secure(app), samesite=self.get_cookie_samesite(app), ) + + +class SingleUserSessionInterface(SessionInterface): + def __init__(self, db_path): + self.db_path = db_path + + def _connect(self): + con = sqlite3.connect(self.db_path, timeout=5) + con.execute('PRAGMA journal_mode=WAL') + con.row_factory = sqlite3.Row + return con + + def open_session(self, app, request): + name = app.config.get('SESSION_COOKIE_NAME', 'session') + sid = request.cookies.get(name) + if not sid: + return SqliteSession(sid=str(uuid.uuid4()), new=True) + try: + con = self._connect() + row = con.execute('SELECT * FROM sessions WHERE session_id=?', (sid,)).fetchone() + con.close() + if row: + prefs = json.loads(row['preferences_json'] or '{}') + flashes = json.loads(row['flashes_json'] or '[]') + data = {'_flashes': flashes} + if row['logged_in']: + tz = str(row['timezone'] or '') or _settings.get_host_timezone() + data.update({ + 'email_address': _settings.get_initial_manager_email(), + 'access_level': 'manager', + 'timezone': tz, + 'apply_changes_immediately': bool(prefs.get('apply_changes_immediately', False)), + '_permanent': True, + 'logged_in': True, + }) + return SqliteSession(data, sid=sid, new=False) + except Exception: + pass + return SqliteSession(sid=sid, new=False) + + def save_session(self, app, session, response): + name = app.config.get('SESSION_COOKIE_NAME', 'session') + domain = self.get_cookie_domain(app) + path = self.get_cookie_path(app) + + if not session and session.modified and not session.new: + try: + con = self._connect() + con.execute('DELETE FROM sessions WHERE session_id=?', (session.sid,)) + con.commit() + con.close() + except Exception: + pass + response.delete_cookie(name, domain=domain, path=path) + return + + prefs = json.dumps({ + 'apply_changes_immediately': bool(session.get('apply_changes_immediately', False)), + }) + flashes_json = json.dumps(session.get('_flashes', [])) + now = int(time.time()) + logged_in = 1 if session.get('logged_in') else 0 + + try: + con = self._connect() + con.execute( + '''INSERT INTO sessions + (session_id, logged_in, timezone, preferences_json, + flashes_json, session_started_ts, last_seen_ts) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(session_id) DO UPDATE SET + logged_in=excluded.logged_in, + timezone=excluded.timezone, + preferences_json=excluded.preferences_json, + flashes_json=excluded.flashes_json, + last_seen_ts=excluded.last_seen_ts''', + (session.sid, logged_in, + session.get('timezone', ''), + prefs, flashes_json, now, now) + ) + con.commit() + con.close() + except Exception: + pass + + response.set_cookie( + name, session.sid, + expires=self.get_expiration_time(app, session), + httponly=self.get_cookie_httponly(app), + domain=domain, + path=path, + secure=self.get_cookie_secure(app), + samesite=self.get_cookie_samesite(app), + ) diff --git a/docker/routlin-dash/app/settings.py b/docker/routlin-dash/app/settings.py index c8a87ae..404a110 100644 --- a/docker/routlin-dash/app/settings.py +++ b/docker/routlin-dash/app/settings.py @@ -78,6 +78,14 @@ def get_initial_manager_email(): return str(cfg.get('initial_manager_email') or os.environ.get('INITIAL_MANAGER_EMAIL', '')).strip().lower() +def is_single_user(): + return 'initial_manager_password' in _load_app_config() + + +def get_initial_manager_password_hash(): + return _load_app_config().get('initial_manager_password', '') + + def get_credentials_key(): """Return a Fernet-compatible key derived from the credentials_key in app_config.json (or CREDENTIALS_KEY env var as fallback), or None if not set. SHA-256 hashes the raw diff --git a/routlin/install.py b/routlin/install.py index 1f9f6bd..059dffc 100644 --- a/routlin/install.py +++ b/routlin/install.py @@ -327,11 +327,6 @@ def setup_docker_compose(reuse_config=False): print(" Dashboard container started.") return - print() - print(" SMTP is used to send email verification codes for new accounts.") - print(" (Gmail users: use an App Password, not your account password.)") - print() - manager_email = prompt_str("Initial manager account email") while not manager_email or "@" not in manager_email: print(" Please enter a valid email address.") @@ -345,23 +340,55 @@ def setup_docker_compose(reuse_config=False): credentials_key = _sec.token_urlsafe(48) print(f" Generated key: {credentials_key}") - smtp_host = prompt_str("SMTP host", default="smtp.gmail.com") - smtp_port = prompt_str("SMTP port", default="587") - smtp_user = prompt_str("SMTP username (email)") - smtp_password = prompt_str("SMTP password", secret=True) - smtp_from = prompt_str("SMTP From address", default=smtp_user) + print() + multi_user = prompt_yn( + f"Enable multi-user access? (requires SMTP to send account verification emails)", + default="n" + ) - app_config = { - "initial_manager_email": manager_email, - "credentials_key": credentials_key, - "smtp": { - "host": smtp_host, - "port": int(smtp_port), - "user": smtp_user, - "password": smtp_password, - "from": smtp_from, - }, - } + if multi_user: + print() + print(" (Gmail users: use an App Password, not your account password.)") + print() + smtp_host = prompt_str("SMTP host", default="smtp.gmail.com") + smtp_port = prompt_str("SMTP port", default="587") + smtp_user = prompt_str("SMTP username (email)") + smtp_password = prompt_str("SMTP password", secret=True) + smtp_from = prompt_str("SMTP From address", default=smtp_user) + app_config = { + "initial_manager_email": manager_email, + "credentials_key": credentials_key, + "smtp": { + "host": smtp_host, + "port": int(smtp_port), + "user": smtp_user, + "password": smtp_password, + "from": smtp_from, + }, + } + else: + print() + print(f" Single-user mode: only {manager_email} can log in.") + print(f" Password must be at least 8 characters.") + print() + while True: + import getpass as _gp + pw = _gp.getpass(f" Password for {manager_email}: ") + pw2 = _gp.getpass(f" Confirm password: ") + if pw != pw2: + print(" Passwords do not match. Try again.") + continue + if len(pw) < 8: + print(" Password must be at least 8 characters.") + continue + break + import bcrypt as _bcrypt + pw_hash = _bcrypt.hashpw(pw.encode('utf-8'), _bcrypt.gensalt()).decode('utf-8') + app_config = { + "initial_manager_email": manager_email, + "credentials_key": credentials_key, + "initial_manager_password": pw_hash, + } APP_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) APP_CONFIG_FILE.write_text(json.dumps(app_config, indent=2) + "\n")