Development

This commit is contained in:
Matthew Grotke 2026-06-10 13:16:28 -04:00
parent 19be151c70
commit f5722f3c7b
8 changed files with 178 additions and 90 deletions

View file

@ -7,9 +7,5 @@ bp = Blueprint('accountlogout', __name__)
@bp.route('/action/accountlogout/logout', methods=['POST']) @bp.route('/action/accountlogout/logout', methods=['POST'])
@auth.require_level('viewer') @auth.require_level('viewer')
def logout(): def logout():
sid = session.get('session_id', '')
if sid:
import config_utils as _cu
_cu.record_session_logout(sid)
session.clear() session.clear()
return redirect('/overview') return redirect('/overview')

View file

@ -610,72 +610,6 @@ def revert_group_chain(group_uuid):
return errors, succeeded, failed return errors, succeeded, failed
# Sessions DB =======================================================
def _open_sessions_db():
import sqlite3 as _sq, time as _t
con = _sq.connect(SESSIONS_DB, timeout=5)
con.execute('PRAGMA journal_mode=WAL')
con.executescript('''
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
email TEXT NOT NULL,
access_level TEXT NOT NULL DEFAULT '',
logged_in_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
''')
con.commit()
return con
def record_session_login(session_id, email, access_level):
import time as _t
try:
con = _open_sessions_db()
now = int(_t.time())
con.execute(
'INSERT OR REPLACE INTO sessions(session_id, email, access_level, logged_in_at, last_seen) VALUES(?,?,?,?,?)',
(session_id, email, access_level, now, now)
)
con.commit()
con.close()
except Exception:
pass
def record_session_logout(session_id):
try:
con = _open_sessions_db()
con.execute('DELETE FROM sessions WHERE session_id=?', (session_id,))
con.commit()
con.close()
except Exception:
pass
def record_session_activity(session_id):
import time as _t
try:
con = _open_sessions_db()
con.execute('UPDATE sessions SET last_seen=? WHERE session_id=?', (int(_t.time()), session_id))
con.commit()
con.close()
except Exception:
pass
def get_active_sessions():
import time as _t
cutoff = int(_t.time()) - 31 * 86400
try:
con = _open_sessions_db()
rows = con.execute(
'SELECT email, access_level, logged_in_at, last_seen FROM sessions WHERE last_seen > ? ORDER BY last_seen DESC',
(cutoff,)
).fetchall()
con.close()
return rows
except Exception:
return []
# Misc ============================================================== # Misc ==============================================================
def run_apply(): def run_apply():

View file

@ -29,8 +29,11 @@ from pages.captiveportal.action import bp as captiveportal_bp
from action_accountlogout import bp as accountlogout_bp from action_accountlogout import bp as accountlogout_bp
from api_apply_health import bp as api_apply_health_bp from api_apply_health import bp as api_apply_health_bp
from session_interface import SqliteSessionInterface
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24)) app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
app.session_interface = SqliteSessionInterface(config_utils.SESSIONS_DB)
# Static www/ serving ================================================= # Static www/ serving =================================================
@ -77,9 +80,6 @@ def serve_view(page_name):
view_req = view_def.get('client_requirement') view_req = view_def.get('client_requirement')
level = factory.client_level() level = factory.client_level()
sid = session.get('session_id', '')
if sid and level > 0:
config_utils.record_session_activity(sid)
if not factory.passes(view_req, level): if not factory.passes(view_req, level):
return redirect('/overview' if level > 0 else '/accountlogin') return redirect('/overview' if level > 0 else '/accountlogin')

View file

@ -56,10 +56,4 @@ def form_login():
session['apply_changes_immediately'] = False session['apply_changes_immediately'] = False
session.permanent = True session.permanent = True
import uuid as _uuid
sid = str(_uuid.uuid4())
session['session_id'] = sid
import config_utils as _cu
_cu.record_session_login(sid, account['email_address'], account.get('access_level', 'viewer'))
return redirect('/overview') return redirect('/overview')

View file

@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from flask import Blueprint, request, session, redirect, flash from flask import Blueprint, request, session, redirect, flash
import json, re import json, os, re, sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
import auth import auth
import config_utils import config_utils
@ -100,6 +100,24 @@ def accounts_edit():
return redirect(f'/{_PAGE}') 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.commit()
con.close()
flash('Session invalidated.', 'success')
except Exception:
flash('Failed to invalidate session.', 'error')
return redirect(f'/{_PAGE}')
@bp.route('/action/accountmanage/accounts_delete', methods=['POST']) @bp.route('/action/accountmanage/accounts_delete', methods=['POST'])
@auth.require_level('manager') @auth.require_level('manager')
def accounts_delete(): def accounts_delete():
@ -118,7 +136,10 @@ def accounts_delete():
target = accounts[row_index] target = accounts[row_index]
if target.get('email_address', '').lower() == session.get('email_address', '').lower(): 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') flash('You cannot remove your own account.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')

View file

@ -1,4 +1,5 @@
import json import json
import sqlite3
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
import config_utils import config_utils
@ -14,28 +15,53 @@ def _fmt_ts(ts):
def _active_sessions_table(): def _active_sessions_table():
rows = config_utils.get_active_sessions() try:
no_data = '<p class="text-muted" style="margin:0">No active sessions.</p>' con = sqlite3.connect(config_utils.SESSIONS_DB, timeout=5)
rows = con.execute(
'SELECT session_id, email, access_level, created_at, last_seen'
' FROM sessions ORDER BY last_seen DESC'
).fetchall()
con.close()
except Exception:
rows = []
if not rows: if not rows:
return no_data return '<p class="text-muted" style="margin:0">No active sessions.</p>'
now = int(time.time()) now = int(time.time())
trs = '' trs = ''
for email, access_level, logged_in_at, last_seen in rows: for sid, email, access_level, created_at, last_seen in rows:
ago = config_utils.relative_time(int(last_seen), now) online = (now - int(last_seen)) < 300
badge = (
'<span class="badge badge-enabled">Online</span>'
if online else
'<span class="badge badge-disabled">Offline</span>'
)
btn = (
f'<form method="post" action="/action/accountmanage/session_invalidate"'
f' style="display:inline;margin:0">'
f'<input type="hidden" name="session_id" value="{factory.e(sid)}">'
f'<button type="submit" class="btn btn-danger btn-sm">Invalidate</button>'
f'</form>'
)
trs += ( trs += (
f'<tr>' f'<tr>'
f'<td class="table-cell">{factory.e(email)}</td>' f'<td class="table-cell">{factory.e(email)}</td>'
f'<td class="table-cell">{factory.e(access_level)}</td>' f'<td class="table-cell">{factory.e(access_level)}</td>'
f'<td class="table-cell">{_fmt_ts(logged_in_at)}</td>' f'<td class="table-cell">{badge}</td>'
f'<td class="table-cell">{factory.e(ago)} ago</td>' f'<td class="table-cell">{_fmt_ts(created_at)}</td>'
f'<td class="table-cell">{_fmt_ts(last_seen)}</td>'
f'<td class="table-cell">{btn}</td>'
f'</tr>' f'</tr>'
) )
return ( return (
'<table class="data-table"><thead><tr>' '<table class="data-table"><thead><tr>'
'<th class="table-header">Email</th>' '<th class="table-header">Email</th>'
'<th class="table-header">Access Level</th>' '<th class="table-header">Access Level</th>'
'<th class="table-header">Status</th>'
'<th class="table-header">Logged In</th>' '<th class="table-header">Logged In</th>'
'<th class="table-header">Last Seen</th>' '<th class="table-header">Last Seen</th>'
'<th class="table-header"></th>'
'</tr></thead><tbody>' + trs + '</tbody></table>' '</tr></thead><tbody>' + trs + '</tbody></table>'
) )

View file

@ -0,0 +1,118 @@
import json
import sqlite3
import time
import uuid
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict
class SqliteSession(CallbackDict, SessionMixin):
def __init__(self, initial=None, sid=None, new=False):
def on_update(self):
self.modified = True
CallbackDict.__init__(self, initial or {}, on_update)
self.sid = sid
self.new = new
self.modified = False
class SqliteSessionInterface(SessionInterface):
def __init__(self, db_path):
self.db_path = db_path
self._init_db()
def _connect(self):
con = sqlite3.connect(self.db_path, timeout=5)
con.execute('PRAGMA journal_mode=WAL')
return con
def _init_db(self):
try:
con = self._connect()
con.execute('''
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
email TEXT NOT NULL DEFAULT '',
access_level TEXT NOT NULL DEFAULT '',
data_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
)
''')
con.commit()
con.close()
except Exception:
pass
def open_session(self, app, request):
name = app.config.get('SESSION_COOKIE_NAME', 'session')
sid = request.cookies.get(name)
if sid:
try:
con = self._connect()
row = con.execute(
'SELECT data_json FROM sessions WHERE session_id=?', (sid,)
).fetchone()
con.close()
if row:
return SqliteSession(json.loads(row[0]), sid=sid, new=False)
except Exception:
pass
return SqliteSession(sid=str(uuid.uuid4()), new=True)
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:
if 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
now = int(time.time())
email = session.get('email_address', '')
level = session.get('access_level', '')
try:
con = self._connect()
if session.new:
if not session.modified:
con.close()
return
con.execute(
'INSERT INTO sessions(session_id,email,access_level,data_json,created_at,last_seen)'
' VALUES(?,?,?,?,?,?)',
(session.sid, email, level, json.dumps(dict(session)), now, now)
)
elif session.modified:
con.execute(
'UPDATE sessions SET email=?,access_level=?,data_json=?,last_seen=? WHERE session_id=?',
(email, level, json.dumps(dict(session)), now, session.sid)
)
else:
con.execute(
'UPDATE sessions SET last_seen=? WHERE session_id=?',
(now, session.sid)
)
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),
)

View file

@ -25,6 +25,5 @@ services:
- SMTP_USER=grotek.industries@gmail.com - SMTP_USER=grotek.industries@gmail.com
- SMTP_PASSWORD=lfhrygyuwvlaczaw - SMTP_PASSWORD=lfhrygyuwvlaczaw
- SMTP_FROM=grotek.industries@gmail.com - SMTP_FROM=grotek.industries@gmail.com
- LICENSE=asdf
- DEV_MODE=true - DEV_MODE=true
restart: unless-stopped restart: unless-stopped