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'])
@auth.require_level('viewer')
def logout():
sid = session.get('session_id', '')
if sid:
import config_utils as _cu
_cu.record_session_logout(sid)
session.clear()
return redirect('/overview')

View file

@ -610,72 +610,6 @@ def revert_group_chain(group_uuid):
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 ==============================================================
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 api_apply_health import bp as api_apply_health_bp
from session_interface import SqliteSessionInterface
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 =================================================
@ -77,9 +80,6 @@ def serve_view(page_name):
view_req = view_def.get('client_requirement')
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):
return redirect('/overview' if level > 0 else '/accountlogin')

View file

@ -56,10 +56,4 @@ def form_login():
session['apply_changes_immediately'] = False
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')

View file

@ -1,6 +1,6 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash
import json, re
import json, os, re, sqlite3
from datetime import datetime, timezone
import auth
import config_utils
@ -100,6 +100,24 @@ def accounts_edit():
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'])
@auth.require_level('manager')
def accounts_delete():
@ -118,7 +136,10 @@ def accounts_delete():
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')
return redirect(f'/{_PAGE}')

View file

@ -1,4 +1,5 @@
import json
import sqlite3
import time
from datetime import datetime, timezone
import config_utils
@ -14,28 +15,53 @@ def _fmt_ts(ts):
def _active_sessions_table():
rows = config_utils.get_active_sessions()
no_data = '<p class="text-muted" style="margin:0">No active sessions.</p>'
try:
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:
return no_data
return '<p class="text-muted" style="margin:0">No active sessions.</p>'
now = int(time.time())
trs = ''
for email, access_level, logged_in_at, last_seen in rows:
ago = config_utils.relative_time(int(last_seen), now)
for sid, email, access_level, created_at, last_seen in rows:
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 += (
f'<tr>'
f'<td class="table-cell">{factory.e(email)}</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">{factory.e(ago)} ago</td>'
f'<td class="table-cell">{badge}</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>'
)
return (
'<table class="data-table"><thead><tr>'
'<th class="table-header">Email</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">Last Seen</th>'
'<th class="table-header"></th>'
'</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),
)