#
# This file is part of Lodel 2 (https://github.com/OpenEdition)
#
# Copyright (C) 2015-2017 Cléo UMS-3287
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
#
## @package lodel.plugins.filesystem_session.main Main entry point of the plugin
import binascii
import datetime
import os
import pickle
import re
import time
from .filesystem_session import FileSystemSession
from lodel.context import LodelContext
LodelContext.expose_modules(globals(), {
'lodel.logger': 'logger',
'lodel.auth.exceptions': ['ClientAuthenticationFailure'],
'lodel.settings': ['Settings']})
__sessions = dict()
SESSION_TOKENSIZE = 150
##
# @brief generates a new session token
#
# @return str
#
# @warning The tokensize should absolutely be used as set! os.urandom function
# takes a number of bytes as a parameter, dividing it by 2 is an
# extremely dangerous idea as it drastically decrease the token expected
# entropy expected from the value set in configs.
# @remarks There is no valid reason for checking the generated token uniqueness:
# - checking for uniqueness is slow ;
# - keeping a dict with a few thousand keys of hundred bytes also is
# memory expensive ;
# - should the system get distributed while sharing session storage, there
# would be no reasonable way to efficiently check for uniqueness ;
# - sessions do have a really short life span, drastically reducing
# even more an already close to inexistent risk of collision. A 64 bits
# id would perfectly do the job, or to be really cautious, a 128 bits
# one (actual size of UUIDs) ;
# - if we are still willing to ensure uniqueness, then simply salt it
# with a counter, or a timestamp, and hash the whole thing with a
# cryptographically secured method such as sha-2 if we are paranoids
# and trying to avoid what will never happen, ever ;
# - sure, two hexadecimal characters is one byte long. Simply go for
# bit length, not chars length.
def generate_token():
token = binascii.hexlify(os.urandom(SESSION_TOKENSIZE//2))
if token in __sessions.keys():
token = generate_token()
return token.decode('utf-8')
##
# @brief checks the validity of a given session token
#
# @param token str
# @raise ClientAuthenticationFailure for invalid or not found session token
#
# @remarks It is useless to check the token size, unless urandom you don't
# trust in PRNG such as urandom.
# @remarks Linear key search...
# @remarks Consider renaming. The "validity of a session token" usually means
# that it is a active session token and/or that it was actually
# produced by the application (signed for exemple).
def check_token(token):
if len(token) != SESSION_TOKENSIZE:
raise ClientAuthenticationFailure("Invalid token string")
if token not in __sessions.keys():
raise ClientAuthenticationFailure("No session found for this token")
## @brief returns a session file path for a specific token
# @param token str
# @return str
def generate_file_path(token):
return os.path.abspath(os.path.join(Settings.sessions.directory, Settings.sessions.file_template) % token)
##
# @brief Retrieve the token from the file system
#
# @param filepath str
# @return str|None : returns the token or None if no token was found
#
# @remarks What is the purpose of the regex right here? There should be a way
# to avoid slow operations.
def get_token_from_filepath(filepath):
token_regex = re.compile(os.path.abspath(os.path.join(Settings.sessions.directory, Settings.sessions.file_template % '(?P.*)')))
token_search_result = token_regex.match(filepath)
if token_search_result is not None:
return token_search_result.groupdict()['token']
return None
##
# @brief Returns the session's last modification timestamp
#
# @param token str
# @return float
# @raise ValueError if the given token doesn't match with an existing session
#
# @remarks Consider renaming
# @warning Linear search in array, again. See @ref generate_token().
def get_session_last_modified(token):
if token in __sessions[token]:
return os.stat(__sessions[token]).st_mtime
else:
raise ValueError("The given token %s doesn't match with an existing session")
##
# @brief Starts a new session and returns a new token
#
# @return str : the new token
def start_session():
session = FileSystemSession(generate_token())
session.path = generate_file_path(session.token)
with open(session.path, 'wb') as session_file:
pickle.dump(session, session_file)
__sessions[session.token] = session.path
logger.debug("New session created")
return session.token
##
# @brief destroys a session given its token
#
# @param token str
def destroy_session(token):
check_token(token)
if os.path.isfile(__sessions[token]):
os.unlink(__sessions[token])
logger.debug("Session file for %s destroyed" % token)
del(__sessions[token])
logger.debug("Session %s unregistered" % token)
##
# @brief Restores a session's content
#
# @param token str
# @return FileSystemSession|None
def restore_session(token):
gc()
check_token(token)
logger.debug("Restoring session : %s" % token)
if os.path.isfile(__sessions[token]):
with open(__sessions[token], 'rb') as session_file:
session = pickle.load(session_file)
return session
else:
return None # raise FileNotFoundError("Session file not found for the token %s" % token)
## @brief saves the session's content to a file
# @param token str
# @param datas dict
def save_session(token, datas):
session = datas
if not isinstance(datas, FileSystemSession):
session = FileSystemSession(token)
session.path = generate_file_path(token)
session.update(datas)
with open(__sessions[token], 'wb') as session_file:
pickle.dump(session, session_file)
if token not in __sessions.keys():
__sessions[token] = session.path
logger.debug("Session %s saved" % token)
## @brief session store's garbage collector
#
# @remarks
def gc():
# Unregistered files in the session directory
session_files_directory = os.path.abspath(Settings.sessions.directory)
for session_file in [file_path for file_path in os.listdir(session_files_directory) if os.path.isfile(os.path.join(session_files_directory, file_path))]:
session_file_path = os.path.join(session_files_directory, session_file)
token = get_token_from_filepath(session_file_path)
if token is None or token not in __sessions.keys():
os.unlink(session_file_path)
logger.debug("Unregistered session file %s has been deleted" % session_file)
# Expired registered sessions
for token in __sessions.keys():
if os.path.isfile(__sessions[token]):
now_timestamp = time.mktime(datetime.datetime.now().timetuple())
if now_timestamp - get_session_last_modified(token) > Settings.sessions.expiration:
destroy_session(token)
logger.debug("Expired session %s has been destroyed" % token)
##
# @param token str
# @param key str
# @param value
def set_session_value(token, key, value):
session = restore_session(token)
session[key] = value
save_session(token, session)
##
# @param token str
# @param key str
def get_session_value(token, key):
session = restore_session(token)
return session[key]
##
# @brief deletes a session value
#
# @param token str
# @param key str
#
# @todo Should we add a save_session at the end of this method?
def del_session_value(token, key):
session = restore_session(token)
if key in session:
del(session[key])