Preserve user login sessions in json file across runs.

This commit is contained in:
voussoir 2025-11-14 00:20:16 -08:00
parent f1d3319d36
commit 7f930c3bce
2 changed files with 100 additions and 17 deletions

View file

@ -12,6 +12,7 @@ from voussoirkit import bytestring
from voussoirkit import configlayers from voussoirkit import configlayers
from voussoirkit import flasktools from voussoirkit import flasktools
from voussoirkit import pathclass from voussoirkit import pathclass
from voussoirkit import timetools
from voussoirkit import vlogging from voussoirkit import vlogging
import etiquette import etiquette
@ -44,6 +45,7 @@ TEMPLATE_DIR = root_dir.with_child('templates')
STATIC_DIR = root_dir.with_child('static') STATIC_DIR = root_dir.with_child('static')
FAVICON_PATH = STATIC_DIR.with_child('favicon.png') FAVICON_PATH = STATIC_DIR.with_child('favicon.png')
SERVER_CONFIG_FILENAME = 'etiquette_flask_config.json' SERVER_CONFIG_FILENAME = 'etiquette_flask_config.json'
SESSIONS_STATE_FILENAME = 'etiquette_flask_sessions.json'
site = flask.Flask( site = flask.Flask(
__name__, __name__,
@ -61,6 +63,7 @@ site.jinja_env.lstrip_blocks = True
jinja_filters.register_all(site) jinja_filters.register_all(site)
site.localhost_only = False site.localhost_only = False
# state_file will be set later
session_manager = sessions.SessionManager(maxlen=10000) session_manager = sessions.SessionManager(maxlen=10000)
file_etag_manager = client_caching.FileEtagManager( file_etag_manager = client_caching.FileEtagManager(
maxlen=10000, maxlen=10000,
@ -308,6 +311,7 @@ def init_photodb(*args, **kwargs):
global P global P
P = etiquette.photodb.PhotoDB.closest_photodb(*args, **kwargs) P = etiquette.photodb.PhotoDB.closest_photodb(*args, **kwargs)
load_config() load_config()
load_sessions()
def load_config() -> None: def load_config() -> None:
log.debug('Loading server config file.') log.debug('Loading server config file.')
@ -321,6 +325,30 @@ def load_config() -> None:
if needs_rewrite: if needs_rewrite:
save_config() save_config()
def load_sessions():
state_file = P.data_directory.with_child(SESSIONS_STATE_FILENAME)
session_manager.state_file = state_file
if not state_file.exists:
return
log.debug('Loading sessions from state file')
j = json.loads(state_file.read('r'))
for session in j:
if session['userid'] is None:
user = None
else:
try:
user = P.get_user(id=session['userid'])
except etiquette.exceptions.NoSuchUser:
continue
session = sessions.Session(
session_manager=session_manager,
user=user,
token=session['token'],
ip_address=session['ip_address'],
user_agent=session['user_agent'],
last_activity=timetools.fromtimestamp(session['last_activity']),
)
def save_config() -> None: def save_config() -> None:
log.debug('Saving server config file.') log.debug('Saving server config file.')
config_file = P.data_directory.with_child(SERVER_CONFIG_FILENAME) config_file = P.data_directory.with_child(SERVER_CONFIG_FILENAME)

View file

@ -1,18 +1,26 @@
import datetime
import flask; from flask import request import flask; from flask import request
import functools import functools
import json
import random
import werkzeug.datastructures import werkzeug.datastructures
from voussoirkit import cacheclass from voussoirkit import cacheclass
from voussoirkit import flasktools from voussoirkit import flasktools
from voussoirkit import passwordy
from voussoirkit import timetools from voussoirkit import timetools
from voussoirkit import vlogging
log = vlogging.getLogger(__name__, 'sessions')
import etiquette import etiquette
SESSION_MAX_AGE = 86400 RNG = random.SystemRandom()
def _generate_token(length=32): SESSION_MAX_AGE = 86400
return passwordy.random_hex(length=length) SAVE_STATE_INTERVAL = 60
def _generate_token() -> str:
return str(RNG.getrandbits(128))
def _normalize_token(token): def _normalize_token(token):
if isinstance(token, flasktools.REQUEST_TYPES): if isinstance(token, flasktools.REQUEST_TYPES):
@ -31,8 +39,10 @@ def _normalize_token(token):
return token return token
class SessionManager: class SessionManager:
def __init__(self, maxlen=None): def __init__(self, maxlen=None, state_file=None):
self.sessions = cacheclass.Cache(maxlen=maxlen) self.sessions = cacheclass.Cache(maxlen=maxlen)
self.last_activity = timetools.now()
self.last_save_state = timetools.now()
def _before_request(self, request): def _before_request(self, request):
# Inject new token so the function doesn't know the difference # Inject new token so the function doesn't know the difference
@ -51,8 +61,7 @@ class SessionManager:
try: try:
session = self.get(request) session = self.get(request)
except KeyError: except KeyError:
session = Session(request, user=None) session = Session.from_request(session_manager=self, request=request, user=None)
self.add(session)
else: else:
session.maintain() session.maintain()
@ -78,6 +87,7 @@ class SessionManager:
return response return response
def add(self, session): def add(self, session):
session.session_manager = self
self.sessions[session.token] = session self.sessions[session.token] = session
def clear(self): def clear(self):
@ -86,11 +96,7 @@ class SessionManager:
def get(self, request): def get(self, request):
token = _normalize_token(request) token = _normalize_token(request)
session = self.sessions[token] session = self.sessions[token]
invalid = ( if session.expired():
request.remote_addr != session.ip_address or
session.expired()
)
if invalid:
self.remove(token) self.remove(token)
raise KeyError(token) raise KeyError(token)
return session return session
@ -110,6 +116,13 @@ class SessionManager:
return wrapped return wrapped
def maintain(self):
now = timetools.now()
self.last_activity = now
state_age = now - self.last_save_state
if state_age.seconds > SAVE_STATE_INTERVAL:
self.save_state()
def remove(self, token): def remove(self, token):
token = _normalize_token(token) token = _normalize_token(token)
try: try:
@ -117,13 +130,45 @@ class SessionManager:
except KeyError: except KeyError:
pass pass
def save_state(self):
log.debug('Saving sessions state.')
j = [session.jsonify() for session in self.sessions.values() if not session.expired()]
j = json.dumps(j)
self.state_file.write('w', j)
self.last_save_state = timetools.now()
class Session: class Session:
def __init__(self, request, user): def __init__(
self.token = _normalize_token(request) self,
*,
session_manager,
token,
ip_address,
user_agent,
user,
last_activity=None,
):
self.session_manager = session_manager
self.token = token
self.user = user self.user = user
self.ip_address = request.remote_addr self.ip_address = ip_address
self.user_agent = request.headers.get('User-Agent', '') self.user_agent = user_agent
self.last_activity = timetools.now() if last_activity is None:
self.last_activity = timetools.now()
else:
self.last_activity = last_activity
self.session_manager.add(self)
self.session_manager.maintain()
@classmethod
def from_request(cls, *, session_manager, request, user):
return cls(
session_manager=session_manager,
token=_normalize_token(request),
user=user,
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent', ''),
)
def __repr__(self): def __repr__(self):
if self.user: if self.user:
@ -136,5 +181,15 @@ class Session:
age = now - self.last_activity age = now - self.last_activity
return age.seconds > SESSION_MAX_AGE return age.seconds > SESSION_MAX_AGE
def jsonify(self):
return {
'userid': (self.user.id) if self.user else None,
'token': self.token,
'ip_address': self.ip_address,
'user_agent': self.user_agent,
'last_activity': self.last_activity.timestamp(),
}
def maintain(self): def maintain(self):
self.last_activity = timetools.now() self.last_activity = timetools.now()
self.session_manager.maintain()