You've already forked Mailu
mirror of
https://github.com/Mailu/Mailu.git
synced 2025-11-27 22:18:22 +02:00
fix permanent sessions. hash uid using SECRET_KEY
clean session in redis only once when starting
This commit is contained in:
@@ -10,6 +10,8 @@ import hashlib
|
|||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from multiprocessing import Value
|
||||||
|
|
||||||
from mailu import limiter
|
from mailu import limiter
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
@@ -87,10 +89,10 @@ class RedisStore:
|
|||||||
raise KeyError(key)
|
raise KeyError(key)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def put(self, key, value, ttl_secs=None):
|
def put(self, key, value, ttl=None):
|
||||||
""" save item to store. """
|
""" save item to store. """
|
||||||
if ttl_secs:
|
if ttl:
|
||||||
self.redis.setex(key, int(ttl_secs), value)
|
self.redis.setex(key, int(ttl), value)
|
||||||
else:
|
else:
|
||||||
self.redis.set(key, value)
|
self.redis.set(key, value)
|
||||||
|
|
||||||
@@ -171,6 +173,11 @@ class MailuSession(CallbackDict, SessionMixin):
|
|||||||
|
|
||||||
CallbackDict.__init__(self, initial, _on_update)
|
CallbackDict.__init__(self, initial, _on_update)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def saved(self):
|
||||||
|
""" this reflects if the session was saved. """
|
||||||
|
return self._key is not None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sid(self):
|
def sid(self):
|
||||||
""" this reflects the session's id. """
|
""" this reflects the session's id. """
|
||||||
@@ -181,9 +188,7 @@ class MailuSession(CallbackDict, SessionMixin):
|
|||||||
def destroy(self):
|
def destroy(self):
|
||||||
""" destroy session for security reasons. """
|
""" destroy session for security reasons. """
|
||||||
|
|
||||||
if self._key is not None:
|
self.delete()
|
||||||
self.app.session_store.delete(self._key)
|
|
||||||
self._key = None
|
|
||||||
|
|
||||||
self._uid = None
|
self._uid = None
|
||||||
self._sid = None
|
self._sid = None
|
||||||
@@ -191,28 +196,28 @@ class MailuSession(CallbackDict, SessionMixin):
|
|||||||
|
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
self.modified = False
|
self.modified = True
|
||||||
self.new = False
|
self.new = False
|
||||||
|
|
||||||
def regenerate(self):
|
def regenerate(self):
|
||||||
""" generate new id for session to avoid `session fixation`. """
|
""" generate new id for session to avoid `session fixation`. """
|
||||||
|
|
||||||
if self._key is not None:
|
self.delete()
|
||||||
self.app.session_store.delete(self._key)
|
|
||||||
self._key = None
|
|
||||||
|
|
||||||
self._sid = None
|
self._sid = None
|
||||||
self._created = self.app.session_config.gen_created()
|
self._created = self.app.session_config.gen_created()
|
||||||
|
|
||||||
self.modified = True
|
self.modified = True
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
""" Delete stored session. """
|
||||||
|
if self.saved:
|
||||||
|
self.app.session_store.delete(self._key)
|
||||||
|
self._key = None
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
""" Save session to store. """
|
""" Save session to store. """
|
||||||
|
|
||||||
# don't save if session was destroyed or is not modified
|
|
||||||
if self._created is None or not self.modified:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# set uid from dict data
|
# set uid from dict data
|
||||||
if self._uid is None:
|
if self._uid is None:
|
||||||
self._uid = self.app.session_config.gen_uid(self.get('user_id', ''))
|
self._uid = self.app.session_config.gen_uid(self.get('user_id', ''))
|
||||||
@@ -221,25 +226,18 @@ class MailuSession(CallbackDict, SessionMixin):
|
|||||||
if self._sid is None:
|
if self._sid is None:
|
||||||
self._sid = self.app.session_config.gen_sid()
|
self._sid = self.app.session_config.gen_sid()
|
||||||
|
|
||||||
# set created if permanent state changed
|
|
||||||
if self.permanent:
|
|
||||||
if self._created:
|
|
||||||
self._created = b''
|
|
||||||
elif not self._created:
|
|
||||||
self._created = self.app.session_config.gen_created()
|
|
||||||
|
|
||||||
# get new session key
|
# get new session key
|
||||||
key = self.sid
|
key = self.sid
|
||||||
|
|
||||||
# delete old session if key has changed
|
# delete old session if key has changed
|
||||||
if key != self._key and self._key is not None:
|
if key != self._key:
|
||||||
self.app.session_store.delete(self._key)
|
self.delete()
|
||||||
|
|
||||||
# save session
|
# save session
|
||||||
self.app.session_store.put(
|
self.app.session_store.put(
|
||||||
key,
|
key,
|
||||||
pickle.dumps(dict(self)),
|
pickle.dumps(dict(self)),
|
||||||
None if self.permanent else self.app.permanent_session_lifetime.total_seconds()
|
self.app.permanent_session_lifetime.total_seconds()
|
||||||
)
|
)
|
||||||
|
|
||||||
self._key = key
|
self._key = key
|
||||||
@@ -247,8 +245,6 @@ class MailuSession(CallbackDict, SessionMixin):
|
|||||||
self.new = False
|
self.new = False
|
||||||
self.modified = False
|
self.modified = False
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
class MailuSessionConfig:
|
class MailuSessionConfig:
|
||||||
""" Stores sessions crypto config """
|
""" Stores sessions crypto config """
|
||||||
|
|
||||||
@@ -264,8 +260,9 @@ class MailuSessionConfig:
|
|||||||
|
|
||||||
hash_bytes = bits//8 + (bits%8>0)
|
hash_bytes = bits//8 + (bits%8>0)
|
||||||
time_bytes = 4 # 32 bit timestamp for now
|
time_bytes = 4 # 32 bit timestamp for now
|
||||||
|
shaker = hashlib.shake_256 if bits>128 else hashlib.shake_128
|
||||||
|
|
||||||
self._shake_fn = hashlib.shake_256 if bits>128 else hashlib.shake_128
|
self._shaker = shaker(want_bytes(app.config.get('SECRET_KEY', '')))
|
||||||
self._hash_len = hash_bytes
|
self._hash_len = hash_bytes
|
||||||
self._hash_b64 = len(self._encode(bytes(hash_bytes)))
|
self._hash_b64 = len(self._encode(bytes(hash_bytes)))
|
||||||
self._key_min = 2*self._hash_b64
|
self._key_min = 2*self._hash_b64
|
||||||
@@ -277,13 +274,15 @@ class MailuSessionConfig:
|
|||||||
|
|
||||||
def gen_uid(self, uid):
|
def gen_uid(self, uid):
|
||||||
""" Generate hashed user id part of session key. """
|
""" Generate hashed user id part of session key. """
|
||||||
return self._encode(self._shake_fn(want_bytes(uid)).digest(self._hash_len))
|
shaker = self._shaker.copy()
|
||||||
|
shaker.update(want_bytes(uid))
|
||||||
|
return self._encode(shaker.digest(self._hash_len))
|
||||||
|
|
||||||
def gen_created(self, now=None):
|
def gen_created(self, now=None):
|
||||||
""" Generate base64 representation of creation time. """
|
""" Generate base64 representation of creation time. """
|
||||||
return self._encode(int(now or time.time()).to_bytes(8, byteorder='big').lstrip(b'\0'))
|
return self._encode(int(now or time.time()).to_bytes(8, byteorder='big').lstrip(b'\0'))
|
||||||
|
|
||||||
def parse_key(self, key, app=None, now=None):
|
def parse_key(self, key, app=None, validate=False, now=None):
|
||||||
""" Split key into sid, uid and creation time. """
|
""" Split key into sid, uid and creation time. """
|
||||||
|
|
||||||
if not (isinstance(key, bytes) and self._key_min <= len(key) <= self._key_max):
|
if not (isinstance(key, bytes) and self._key_min <= len(key) <= self._key_max):
|
||||||
@@ -299,13 +298,12 @@ class MailuSessionConfig:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# validate creation time when requested or store does not support ttl
|
# validate creation time when requested or store does not support ttl
|
||||||
if now is not None or not app.session_store.has_ttl:
|
if validate or not app.session_store.has_ttl:
|
||||||
|
if now is None:
|
||||||
|
now = int(time.time())
|
||||||
created = int.from_bytes(created, byteorder='big')
|
created = int.from_bytes(created, byteorder='big')
|
||||||
if created > 0:
|
if not (created < now < created + app.permanent_session_lifetime.total_seconds()):
|
||||||
if now is None:
|
return None
|
||||||
now = int(time.time())
|
|
||||||
if created < now < created + app.permanent_session_lifetime.total_seconds():
|
|
||||||
return None
|
|
||||||
|
|
||||||
return (uid, sid, crt)
|
return (uid, sid, crt)
|
||||||
|
|
||||||
@@ -328,17 +326,40 @@ class MailuSessionInterface(SessionInterface):
|
|||||||
def save_session(self, app, session, response):
|
def save_session(self, app, session, response):
|
||||||
""" Save modified session. """
|
""" Save modified session. """
|
||||||
|
|
||||||
if session.save():
|
# If the session is modified to be empty, remove the cookie.
|
||||||
# session saved. update cookie
|
# If the session is empty, return without setting the cookie.
|
||||||
response.set_cookie(
|
if not session:
|
||||||
key=app.config['SESSION_COOKIE_NAME'],
|
if session.modified:
|
||||||
value=session.sid,
|
session.delete()
|
||||||
expires=self.get_expiration_time(app, session),
|
response.delete_cookie(
|
||||||
path=self.get_cookie_path(app),
|
app.session_cookie_name,
|
||||||
domain=self.get_cookie_domain(app),
|
domain=self.get_cookie_domain(app),
|
||||||
secure=app.config['SESSION_COOKIE_SECURE'],
|
path=self.get_cookie_path(app),
|
||||||
httponly=app.config['SESSION_COOKIE_HTTPONLY']
|
)
|
||||||
)
|
return
|
||||||
|
|
||||||
|
# Add a "Vary: Cookie" header if the session was accessed
|
||||||
|
if session.accessed:
|
||||||
|
response.vary.add('Cookie')
|
||||||
|
|
||||||
|
# TODO: set cookie from time to time to prevent expiration in browser
|
||||||
|
# also update expire in redis
|
||||||
|
|
||||||
|
if not self.should_set_cookie(app, session):
|
||||||
|
return
|
||||||
|
|
||||||
|
# save session and update cookie
|
||||||
|
session.save()
|
||||||
|
response.set_cookie(
|
||||||
|
app.session_cookie_name,
|
||||||
|
session.sid,
|
||||||
|
expires=self.get_expiration_time(app, session),
|
||||||
|
httponly=self.get_cookie_httponly(app),
|
||||||
|
domain=self.get_cookie_domain(app),
|
||||||
|
path=self.get_cookie_path(app),
|
||||||
|
secure=self.get_cookie_secure(app),
|
||||||
|
samesite=self.get_cookie_samesite(app)
|
||||||
|
)
|
||||||
|
|
||||||
class MailuSessionExtension:
|
class MailuSessionExtension:
|
||||||
""" Server side session handling """
|
""" Server side session handling """
|
||||||
@@ -352,36 +373,29 @@ class MailuSessionExtension:
|
|||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for key in app.session_store.list():
|
for key in app.session_store.list():
|
||||||
if not app.session_config.parse_key(key, app, now):
|
if not app.session_config.parse_key(key, app, validate=True, now=now):
|
||||||
app.session_store.delete(key)
|
app.session_store.delete(key)
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def prune_sessions(uid=None, keep_permanent=False, keep=None, app=None):
|
def prune_sessions(uid=None, keep=None, app=None):
|
||||||
""" Remove sessions
|
""" Remove sessions
|
||||||
uid: remove all sessions (NONE) or sessions belonging to a specific user
|
uid: remove all sessions (NONE) or sessions belonging to a specific user
|
||||||
keep_permanent: also delete permanent sessions?
|
|
||||||
keep: keep listed sessions
|
keep: keep listed sessions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
keep = keep or set()
|
keep = keep or set()
|
||||||
app = app or flask.current_app
|
app = app or flask.current_app
|
||||||
now = int(time.time())
|
|
||||||
|
|
||||||
prefix = None if uid is None else app.session_config.gen_uid(uid)
|
prefix = None if uid is None else app.session_config.gen_uid(uid)
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for key in app.session_store.list(prefix):
|
for key in app.session_store.list(prefix):
|
||||||
if key in keep:
|
if key not in keep:
|
||||||
continue
|
app.session_store.delete(key)
|
||||||
if keep_permanent:
|
count += 1
|
||||||
if parsed := app.session_config.parse_key(key, app, now):
|
|
||||||
if not parsed[2]:
|
|
||||||
continue
|
|
||||||
app.session_store.delete(key)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@@ -398,14 +412,18 @@ class MailuSessionExtension:
|
|||||||
redis.StrictRedis().from_url(app.config['SESSION_STORAGE_URL'])
|
redis.StrictRedis().from_url(app.config['SESSION_STORAGE_URL'])
|
||||||
)
|
)
|
||||||
|
|
||||||
# clean expired sessions on first use in case lifetime was changed
|
# clean expired sessions oonce on first use in case lifetime was changed
|
||||||
def cleaner():
|
def cleaner():
|
||||||
MailuSessionExtension.cleanup_sessions(app)
|
with cleaned.get_lock():
|
||||||
|
if not cleaned.value:
|
||||||
|
cleaned.value = True
|
||||||
|
flask.current_app.logger.error('cleaning')
|
||||||
|
MailuSessionExtension.cleanup_sessions(app)
|
||||||
|
|
||||||
# TODO: hmm. this will clean once per gunicorn worker
|
|
||||||
app.before_first_request(cleaner)
|
app.before_first_request(cleaner)
|
||||||
|
|
||||||
app.session_config = MailuSessionConfig(app)
|
app.session_config = MailuSessionConfig(app)
|
||||||
app.session_interface = MailuSessionInterface()
|
app.session_interface = MailuSessionInterface()
|
||||||
|
|
||||||
|
cleaned = Value('i', False)
|
||||||
session = MailuSessionExtension()
|
session = MailuSessionExtension()
|
||||||
|
|||||||
Reference in New Issue
Block a user