mirror of
https://github.com/YunoHost/moulinette.git
synced 2024-09-03 20:06:31 +02:00
Further attempt to simplify authentication management code + get rid of msignals madness
This commit is contained in:
parent
6310ef5b6e
commit
ebdb1e22ee
10 changed files with 144 additions and 287 deletions
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"argument_required": "Argument '{argument}' is required",
|
"argument_required": "Argument '{argument}' is required",
|
||||||
"authentication_required": "Authentication required",
|
"authentication_required": "Authentication required",
|
||||||
"authentication_required_long": "Authentication is required to perform this action",
|
|
||||||
"colon": "{}: ",
|
"colon": "{}: ",
|
||||||
"confirm": "Confirm {prompt}",
|
"confirm": "Confirm {prompt}",
|
||||||
"deprecated_command": "'{prog} {command}' is deprecated and will be removed in the future",
|
"deprecated_command": "'{prog} {command}' is deprecated and will be removed in the future",
|
||||||
|
|
|
@ -40,6 +40,13 @@ msettings = dict()
|
||||||
m18n = Moulinette18n()
|
m18n = Moulinette18n()
|
||||||
|
|
||||||
|
|
||||||
|
def prompt(**kwargs):
|
||||||
|
return msettings["interface"].prompt(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def display(**kwargs):
|
||||||
|
return msettings["interface"].display(**kwargs)
|
||||||
|
|
||||||
# Package functions
|
# Package functions
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ from moulinette.core import (
|
||||||
MoulinetteAuthenticationError,
|
MoulinetteAuthenticationError,
|
||||||
MoulinetteValidationError,
|
MoulinetteValidationError,
|
||||||
)
|
)
|
||||||
from moulinette.interfaces import BaseActionsMapParser, GLOBAL_SECTION, TO_RETURN_PROP
|
from moulinette.interfaces import BaseActionsMapParser, TO_RETURN_PROP
|
||||||
from moulinette.utils.log import start_action_logging
|
from moulinette.utils.log import start_action_logging
|
||||||
|
|
||||||
logger = logging.getLogger("moulinette.actionsmap")
|
logger = logging.getLogger("moulinette.actionsmap")
|
||||||
|
@ -42,7 +42,6 @@ class _ExtraParameter(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, iface):
|
def __init__(self, iface):
|
||||||
# TODO: Add conn argument which contains authentification object
|
|
||||||
self.iface = iface
|
self.iface = iface
|
||||||
|
|
||||||
# Required variables
|
# Required variables
|
||||||
|
@ -98,7 +97,7 @@ class CommentParameter(_ExtraParameter):
|
||||||
def __call__(self, message, arg_name, arg_value):
|
def __call__(self, message, arg_name, arg_value):
|
||||||
if arg_value is None:
|
if arg_value is None:
|
||||||
return
|
return
|
||||||
return self.iface.display(m18n.n(message))
|
return msettings['interface'].display(m18n.n(message))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(klass, value, arg_name):
|
def validate(klass, value, arg_name):
|
||||||
|
@ -135,7 +134,7 @@ class AskParameter(_ExtraParameter):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Ask for the argument value
|
# Ask for the argument value
|
||||||
return self.iface.prompt(m18n.n(message))
|
return msettings['interface'].prompt(m18n.n(message))
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return arg_value
|
return arg_value
|
||||||
|
|
||||||
|
@ -173,7 +172,7 @@ class PasswordParameter(AskParameter):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Ask for the password
|
# Ask for the password
|
||||||
return self.iface.prompt(m18n.n(message), True, True)
|
return msettings['interface'].prompt(m18n.n(message), True, True)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return arg_value
|
return arg_value
|
||||||
|
|
||||||
|
@ -284,7 +283,7 @@ class ExtraArgumentParser(object):
|
||||||
def __init__(self, iface):
|
def __init__(self, iface):
|
||||||
self.iface = iface
|
self.iface = iface
|
||||||
self.extra = OrderedDict()
|
self.extra = OrderedDict()
|
||||||
self._extra_params = {GLOBAL_SECTION: {}}
|
self._extra_params = {"_global": {}}
|
||||||
|
|
||||||
# Append available extra parameters for the current interface
|
# Append available extra parameters for the current interface
|
||||||
for klass in extraparameters_list:
|
for klass in extraparameters_list:
|
||||||
|
@ -326,7 +325,7 @@ class ExtraArgumentParser(object):
|
||||||
Add extra parameters to apply on an action argument
|
Add extra parameters to apply on an action argument
|
||||||
|
|
||||||
Keyword arguments:
|
Keyword arguments:
|
||||||
- tid -- The tuple identifier of the action or GLOBAL_SECTION
|
- tid -- The tuple identifier of the action or _global
|
||||||
for global extra parameters
|
for global extra parameters
|
||||||
- arg_name -- The argument name
|
- arg_name -- The argument name
|
||||||
- parameters -- A dict of extra parameters with their values
|
- parameters -- A dict of extra parameters with their values
|
||||||
|
@ -349,7 +348,7 @@ class ExtraArgumentParser(object):
|
||||||
- args -- A dict of argument name associated to their value
|
- args -- A dict of argument name associated to their value
|
||||||
|
|
||||||
"""
|
"""
|
||||||
extra_args = OrderedDict(self._extra_params.get(GLOBAL_SECTION, {}))
|
extra_args = OrderedDict(self._extra_params.get("_global", {}))
|
||||||
extra_args.update(self._extra_params.get(tid, {}))
|
extra_args.update(self._extra_params.get(tid, {}))
|
||||||
|
|
||||||
# Iterate over action arguments with extra parameters
|
# Iterate over action arguments with extra parameters
|
||||||
|
@ -492,16 +491,15 @@ class ActionsMap(object):
|
||||||
else:
|
else:
|
||||||
return mod.Authenticator()
|
return mod.Authenticator()
|
||||||
|
|
||||||
def check_authentication_if_required(self, args, **kwargs):
|
def check_authentication_if_required(self, *args, **kwargs):
|
||||||
|
|
||||||
auth_method = self.parser.auth_method(args, **kwargs)
|
auth_method = self.parser.auth_method(*args, **kwargs)
|
||||||
|
|
||||||
if auth_method is None:
|
if auth_method is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
authenticator = self.get_authenticator(auth_method)
|
authenticator = self.get_authenticator(auth_method)
|
||||||
if not msettings['interface'].authenticate(authenticator):
|
msettings['interface'].authenticate(authenticator)
|
||||||
raise MoulinetteAuthenticationError("authentication_required_long")
|
|
||||||
|
|
||||||
def process(self, args, timeout=None, **kwargs):
|
def process(self, args, timeout=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -707,6 +705,7 @@ class ActionsMap(object):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.main_namespace = namespace
|
self.main_namespace = namespace
|
||||||
|
self.name = _global["name"]
|
||||||
self.default_authentication = _global["authentication"][
|
self.default_authentication = _global["authentication"][
|
||||||
interface_type
|
interface_type
|
||||||
]
|
]
|
||||||
|
|
|
@ -37,119 +37,11 @@ class BaseAuthenticator(object):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Attempt to authenticate
|
# Attempt to authenticate
|
||||||
self._authenticate_credentials(credentials)
|
auth_info = self._authenticate_credentials(credentials) or {}
|
||||||
except MoulinetteError:
|
except MoulinetteError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"authentication {self.name} failed because '{e}'")
|
logger.exception(f"authentication {self.name} failed because '{e}'")
|
||||||
raise MoulinetteAuthenticationError("unable_authenticate")
|
raise MoulinetteAuthenticationError("unable_authenticate")
|
||||||
|
|
||||||
# Store session for later using the provided (new) token if any
|
return auth_info
|
||||||
if store_session:
|
|
||||||
try:
|
|
||||||
s_id = random_ascii()
|
|
||||||
s_token = random_ascii()
|
|
||||||
self._store_session(s_id, s_token)
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
logger.exception(f"unable to store session because {e}")
|
|
||||||
else:
|
|
||||||
logger.debug("session has been stored")
|
|
||||||
|
|
||||||
def _authenticate_credentials(self, credentials=None):
|
|
||||||
"""Attempt to authenticate
|
|
||||||
|
|
||||||
Attempt to authenticate with given credentials. It should raise an
|
|
||||||
AuthenticationError exception if authentication fails.
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
- credentials -- A string containing the credentials to be used by the authenticator
|
|
||||||
|
|
||||||
"""
|
|
||||||
raise NotImplementedError(
|
|
||||||
"derived class '%s' must override this method" % self.__class__.__name__
|
|
||||||
)
|
|
||||||
|
|
||||||
def _open_sessionfile(self, session_id, mode="r"):
|
|
||||||
"""Open a session file for this instance in given mode"""
|
|
||||||
return open_cachefile(
|
|
||||||
"%s.asc" % session_id, mode, subdir="session/%s" % self.name
|
|
||||||
)
|
|
||||||
|
|
||||||
def _session_exists(self, session_id):
|
|
||||||
"""Check a session exists"""
|
|
||||||
return cachefile_exists("%s.asc" % session_id, subdir="session/%s" % self.name)
|
|
||||||
|
|
||||||
def _store_session(self, session_id, session_token):
|
|
||||||
"""Store a session to be able to use it later to reauthenticate"""
|
|
||||||
|
|
||||||
# We store a hash of the session_id and the session_token (the token is assumed to be secret)
|
|
||||||
to_hash = "{id}:{token}".format(id=session_id, token=session_token).encode()
|
|
||||||
hash_ = hashlib.sha256(to_hash).hexdigest()
|
|
||||||
with self._open_sessionfile(session_id, "w") as f:
|
|
||||||
f.write(hash_)
|
|
||||||
|
|
||||||
def authenticate_session(self, s_id, s_token):
|
|
||||||
try:
|
|
||||||
# Attempt to authenticate
|
|
||||||
self._authenticate_session(s_id, s_token)
|
|
||||||
except MoulinetteError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"authentication {self.name} failed because '{e}'")
|
|
||||||
raise MoulinetteAuthenticationError("unable_authenticate")
|
|
||||||
|
|
||||||
def _authenticate_session(self, session_id, session_token):
|
|
||||||
"""Checks session and token against the stored session token"""
|
|
||||||
if not self._session_exists(session_id):
|
|
||||||
raise MoulinetteAuthenticationError("session_expired")
|
|
||||||
try:
|
|
||||||
# FIXME : shouldn't we also add a check that this session file
|
|
||||||
# is not too old ? e.g. not older than 24 hours ? idk...
|
|
||||||
|
|
||||||
with self._open_sessionfile(session_id, "r") as f:
|
|
||||||
stored_hash = f.read()
|
|
||||||
except IOError as e:
|
|
||||||
logger.debug("unable to retrieve session", exc_info=1)
|
|
||||||
raise MoulinetteAuthenticationError("unable_retrieve_session", exception=e)
|
|
||||||
else:
|
|
||||||
#
|
|
||||||
# session_id (or just id) : This is unique id for the current session from the user. Not too important
|
|
||||||
# if this info gets stolen somehow. It is stored in the client's side (browser) using regular cookies.
|
|
||||||
#
|
|
||||||
# session_token (or just token) : This is a secret info, like some sort of ephemeral password,
|
|
||||||
# used to authenticate the session without the user having to retype the password all the time...
|
|
||||||
# - It is generated on our side during the initial auth of the user (which happens with the actual admin password)
|
|
||||||
# - It is stored on the client's side (browser) using (signed) cookies.
|
|
||||||
# - We also store it on our side in the form of a hash of {id}:{token} (c.f. _store_session).
|
|
||||||
# We could simply store the raw token, but hashing it is an additonal low-cost security layer
|
|
||||||
# in case this info gets exposed for some reason (e.g. bad file perms for reasons...)
|
|
||||||
#
|
|
||||||
# When the user comes back, we fetch the session_id and session_token from its cookies. Then we
|
|
||||||
# re-hash the {id}:{token} and compare it to the previously stored hash for this session_id ...
|
|
||||||
# It it matches, then the user is authenticated. Otherwise, the token is invalid.
|
|
||||||
#
|
|
||||||
to_hash = "{id}:{token}".format(id=session_id, token=session_token).encode()
|
|
||||||
hash_ = hashlib.sha256(to_hash).hexdigest()
|
|
||||||
|
|
||||||
if not hmac.compare_digest(hash_, stored_hash):
|
|
||||||
raise MoulinetteAuthenticationError("invalid_token")
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
def _clean_session(self, session_id):
|
|
||||||
"""Clean a session cache
|
|
||||||
|
|
||||||
Remove cache for the session 'session_id' and for this authenticator profile
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
- session_id -- The session id to clean
|
|
||||||
"""
|
|
||||||
sessiondir = get_cachedir("session")
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.remove(os.path.join(sessiondir, self.name, "%s.asc" % session_id))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ from moulinette.core import MoulinetteError
|
||||||
|
|
||||||
logger = logging.getLogger("moulinette.interface")
|
logger = logging.getLogger("moulinette.interface")
|
||||||
|
|
||||||
GLOBAL_SECTION = "_global"
|
# FIXME : are these even used for anything useful ...
|
||||||
TO_RETURN_PROP = "_to_return"
|
TO_RETURN_PROP = "_to_return"
|
||||||
CALLBACKS_PROP = "_callbacks"
|
CALLBACKS_PROP = "_callbacks"
|
||||||
|
|
||||||
|
@ -114,7 +114,7 @@ class BaseActionsMapParser(object):
|
||||||
"derived class '%s' must override this method" % self.__class__.__name__
|
"derived class '%s' must override this method" % self.__class__.__name__
|
||||||
)
|
)
|
||||||
|
|
||||||
def auth_method(self, args, **kwargs):
|
def auth_method(self, *args, **kwargs):
|
||||||
"""Check if authentication is required to run the requested action
|
"""Check if authentication is required to run the requested action
|
||||||
|
|
||||||
Keyword arguments:
|
Keyword arguments:
|
||||||
|
@ -156,7 +156,7 @@ class BaseActionsMapParser(object):
|
||||||
):
|
):
|
||||||
raise MoulinetteError("invalid_usage")
|
raise MoulinetteError("invalid_usage")
|
||||||
elif not tid:
|
elif not tid:
|
||||||
tid = GLOBAL_SECTION
|
tid = "_global"
|
||||||
|
|
||||||
# Prepare namespace
|
# Prepare namespace
|
||||||
if namespace is None:
|
if namespace is None:
|
||||||
|
|
|
@ -61,7 +61,7 @@ def filter_csrf(callback):
|
||||||
|
|
||||||
class LogQueues(dict):
|
class LogQueues(dict):
|
||||||
|
|
||||||
"""Map of session id to queue."""
|
"""Map of session ids to queue."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -78,9 +78,10 @@ class APIQueueHandler(logging.Handler):
|
||||||
self.queues = LogQueues()
|
self.queues = LogQueues()
|
||||||
|
|
||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
|
s_id = Session.get_infos()["id"]
|
||||||
sid = request.get_cookie("session.id")
|
sid = request.get_cookie("session.id")
|
||||||
try:
|
try:
|
||||||
queue = self.queues[sid]
|
queue = self.queues[s_id]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# Session is not initialized, abandon.
|
# Session is not initialized, abandon.
|
||||||
return
|
return
|
||||||
|
@ -209,6 +210,34 @@ class _HTTPArgumentParser(object):
|
||||||
raise MoulinetteValidationError(message, raw_msg=True)
|
raise MoulinetteValidationError(message, raw_msg=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Session():
|
||||||
|
|
||||||
|
secret = random_ascii()
|
||||||
|
actionsmap_name = None # This is later set to the actionsmap name
|
||||||
|
|
||||||
|
def set_infos(infos):
|
||||||
|
|
||||||
|
assert isinstance(infos, dict)
|
||||||
|
|
||||||
|
response.set_cookie(f"session.{Session.actionsmap_name}", infos, secure=True, secret=Session.secret)
|
||||||
|
|
||||||
|
def get_infos():
|
||||||
|
|
||||||
|
try:
|
||||||
|
infos = request.get_cookie(f"session.{Session.actionsmap_name}", secret=Session.secret, default={})
|
||||||
|
except Exception:
|
||||||
|
infos = {}
|
||||||
|
|
||||||
|
if "id" not in infos:
|
||||||
|
infos["id"] = random_ascii()
|
||||||
|
|
||||||
|
return infos
|
||||||
|
|
||||||
|
def delete_infos():
|
||||||
|
|
||||||
|
response.set_cookie(f"session.{Session.actionsmap_name}", "", max_age=-1)
|
||||||
|
|
||||||
|
|
||||||
class _ActionsMapPlugin(object):
|
class _ActionsMapPlugin(object):
|
||||||
|
|
||||||
"""Actions map Bottle Plugin
|
"""Actions map Bottle Plugin
|
||||||
|
@ -228,8 +257,7 @@ class _ActionsMapPlugin(object):
|
||||||
|
|
||||||
self.actionsmap = actionsmap
|
self.actionsmap = actionsmap
|
||||||
self.log_queues = log_queues
|
self.log_queues = log_queues
|
||||||
# TODO: Save and load secrets?
|
Session.actionsmap_name = actionsmap.name
|
||||||
self.secrets = {}
|
|
||||||
|
|
||||||
def setup(self, app):
|
def setup(self, app):
|
||||||
"""Setup plugin on the application
|
"""Setup plugin on the application
|
||||||
|
@ -240,36 +268,6 @@ class _ActionsMapPlugin(object):
|
||||||
- app -- The application instance
|
- app -- The application instance
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Login wrapper
|
|
||||||
def _login(callback):
|
|
||||||
def wrapper():
|
|
||||||
kwargs = {}
|
|
||||||
try:
|
|
||||||
kwargs["credentials"] = request.POST.credentials
|
|
||||||
except KeyError:
|
|
||||||
raise HTTPResponse("Missing credentials parameter", 400)
|
|
||||||
|
|
||||||
# Apparently even if the key doesn't exists, request.POST.foobar just returns empty string...
|
|
||||||
if not kwargs["credentials"]:
|
|
||||||
raise HTTPResponse("Missing credentials parameter", 400)
|
|
||||||
|
|
||||||
kwargs["profile"] = request.POST.get(
|
|
||||||
"profile", self.actionsmap.default_authentication
|
|
||||||
)
|
|
||||||
return callback(**kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
# Logout wrapper
|
|
||||||
def _logout(callback):
|
|
||||||
def wrapper():
|
|
||||||
kwargs = {}
|
|
||||||
kwargs["profile"] = request.POST.get(
|
|
||||||
"profile", self.actionsmap.default_authentication
|
|
||||||
)
|
|
||||||
return callback(**kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
# Append authentication routes
|
# Append authentication routes
|
||||||
app.route(
|
app.route(
|
||||||
|
@ -278,7 +276,6 @@ class _ActionsMapPlugin(object):
|
||||||
method="POST",
|
method="POST",
|
||||||
callback=self.login,
|
callback=self.login,
|
||||||
skip=["actionsmap"],
|
skip=["actionsmap"],
|
||||||
apply=_login,
|
|
||||||
)
|
)
|
||||||
app.route(
|
app.route(
|
||||||
"/logout",
|
"/logout",
|
||||||
|
@ -286,7 +283,6 @@ class _ActionsMapPlugin(object):
|
||||||
method="GET",
|
method="GET",
|
||||||
callback=self.logout,
|
callback=self.logout,
|
||||||
skip=["actionsmap"],
|
skip=["actionsmap"],
|
||||||
apply=_logout,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Append messages route
|
# Append messages route
|
||||||
|
@ -347,105 +343,60 @@ class _ActionsMapPlugin(object):
|
||||||
|
|
||||||
# Routes callbacks
|
# Routes callbacks
|
||||||
|
|
||||||
def login(self, credentials, profile):
|
def login(self):
|
||||||
"""Log in to an authenticator profile
|
"""Log in to an authenticator
|
||||||
|
|
||||||
Attempt to authenticate to a given authenticator profile and
|
Attempt to authenticate to the default authenticator and
|
||||||
register it with the current session - a new one will be created
|
register it with the current session - a new one will be created
|
||||||
if needed.
|
if needed.
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
- credentials -- Some credentials to use for login
|
|
||||||
- profile -- The authenticator profile name to log in
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
credentials = request.POST.credentials
|
||||||
|
# Apparently even if the key doesn't exists, request.POST.foobar just returns empty string...
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPResponse("Missing credentials parameter", 400)
|
||||||
|
|
||||||
|
profile = request.POST.profile
|
||||||
|
if not profile:
|
||||||
|
profile = self.actionsmap.default_authentication
|
||||||
|
|
||||||
authenticator = self.actionsmap.get_authenticator(profile)
|
authenticator = self.actionsmap.get_authenticator(profile)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s_id, s_token = authenticator.authenticate_credentials(credentials, store_session=True)
|
auth_info = authenticator.authenticate_credentials(credentials, store_session=True)
|
||||||
|
session_infos = Session.get_infos()
|
||||||
|
session_infos[profile] = auth_info
|
||||||
except MoulinetteError as e:
|
except MoulinetteError as e:
|
||||||
try:
|
try:
|
||||||
self.logout(profile)
|
self.logout()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# FIXME : replace with MoulinetteAuthenticationError !?
|
# FIXME : replace with MoulinetteAuthenticationError !?
|
||||||
raise HTTPResponse(e.strerror, 401)
|
raise HTTPResponse(e.strerror, 401)
|
||||||
else:
|
else:
|
||||||
# Save session id and token
|
Session.set_infos(session_infos)
|
||||||
|
|
||||||
# Create and save (in RAM) new cookie secret used to secure(=sign?) the cookie
|
|
||||||
self.secrets[s_id] = s_secret = random_ascii()
|
|
||||||
|
|
||||||
# Fetch current token per profile
|
|
||||||
try:
|
|
||||||
s_tokens = request.get_cookie("session.tokens", secret=s_secret) or {}
|
|
||||||
except Exception:
|
|
||||||
# Same as for session.id a few lines before
|
|
||||||
s_tokens = {}
|
|
||||||
|
|
||||||
# Update dicts with new values
|
|
||||||
s_tokens[profile] = s_token
|
|
||||||
|
|
||||||
response.set_cookie("session.id", s_id, secure=True)
|
|
||||||
response.set_cookie(
|
|
||||||
"session.tokens", {""}, secure=True, secret=s_secret
|
|
||||||
)
|
|
||||||
return m18n.g("logged_in")
|
return m18n.g("logged_in")
|
||||||
|
|
||||||
# This is called before each time a route is going to be processed
|
# This is called before each time a route is going to be processed
|
||||||
def authenticate(self, authenticator):
|
def authenticate(self, authenticator):
|
||||||
|
|
||||||
s_id = request.get_cookie("session.id")
|
|
||||||
try:
|
try:
|
||||||
s_secret = self.secrets[s_id]
|
session_infos = Session.get_infos()[authenticator.name]
|
||||||
s_token = request.get_cookie("session.tokens", secret=s_secret, default={})[
|
|
||||||
authenticator.name
|
|
||||||
]
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
msg = m18n.g("authentication_required")
|
msg = m18n.g("authentication_required")
|
||||||
raise HTTPResponse(msg, 401)
|
raise HTTPResponse(msg, 401)
|
||||||
else:
|
|
||||||
authenticator.authenticate_session(s_id, s_token)
|
|
||||||
|
|
||||||
def logout(self, profile):
|
return session_infos
|
||||||
"""Log out from an authenticator profile
|
|
||||||
|
|
||||||
Attempt to unregister a given profile - or all by default - from
|
def logout(self):
|
||||||
the current session.
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
- profile -- The authenticator profile name to log out
|
|
||||||
|
|
||||||
"""
|
|
||||||
# Retrieve session values
|
|
||||||
try:
|
try:
|
||||||
s_id = request.get_cookie("session.id") or None
|
Session.get_infos()
|
||||||
except:
|
|
||||||
# Super rare case where there are super weird cookie / cache issue
|
|
||||||
# Previous line throws a CookieError that creates a 500 error ...
|
|
||||||
# So let's catch it and just use None...
|
|
||||||
s_id = None
|
|
||||||
|
|
||||||
if s_id is not None:
|
|
||||||
|
|
||||||
# We check that there's a (signed) session.hash available
|
|
||||||
# for additional security ?
|
|
||||||
# (An attacker could not craft such signed hashed ? (FIXME : need to make sure of this))
|
|
||||||
try:
|
|
||||||
s_secret = self.secrets[s_id]
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
s_secret = {}
|
|
||||||
if profile not in request.get_cookie(
|
|
||||||
"session.tokens", secret=s_secret, default={}
|
|
||||||
):
|
|
||||||
raise HTTPResponse(m18n.g("not_logged_in"), 401)
|
raise HTTPResponse(m18n.g("not_logged_in"), 401)
|
||||||
else:
|
else:
|
||||||
del self.secrets[s_id]
|
|
||||||
authenticator = self.actionsmap.get_authenticator(profile)
|
|
||||||
authenticator._clean_session(s_id)
|
|
||||||
# TODO: Clean the session for profile only
|
|
||||||
# Delete cookie and clean the session
|
# Delete cookie and clean the session
|
||||||
response.set_cookie("session.tokens", "", max_age=-1)
|
Session.delete_infos()
|
||||||
return m18n.g("logged_out")
|
return m18n.g("logged_out")
|
||||||
|
|
||||||
def messages(self):
|
def messages(self):
|
||||||
|
@ -454,7 +405,7 @@ class _ActionsMapPlugin(object):
|
||||||
Retrieve the WebSocket stream and send to it each messages displayed by
|
Retrieve the WebSocket stream and send to it each messages displayed by
|
||||||
the display method. They are JSON encoded as a dict { style: message }.
|
the display method. They are JSON encoded as a dict { style: message }.
|
||||||
"""
|
"""
|
||||||
s_id = request.get_cookie("session.id")
|
s_id = Session.get_infos()["id"]
|
||||||
try:
|
try:
|
||||||
queue = self.log_queues[s_id]
|
queue = self.log_queues[s_id]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -515,7 +466,8 @@ class _ActionsMapPlugin(object):
|
||||||
finally:
|
finally:
|
||||||
# Close opened WebSocket by putting StopIteration in the queue
|
# Close opened WebSocket by putting StopIteration in the queue
|
||||||
try:
|
try:
|
||||||
queue = self.log_queues[request.get_cookie("session.id")]
|
s_id = Session.get_infos()["id"]
|
||||||
|
queue = self.log_queues[s_id]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
|
@ -523,7 +475,7 @@ class _ActionsMapPlugin(object):
|
||||||
|
|
||||||
def display(self, message, style):
|
def display(self, message, style):
|
||||||
|
|
||||||
s_id = request.get_cookie("session.id")
|
s_id = Sesson.get_infos()["id"]
|
||||||
try:
|
try:
|
||||||
queue = self.log_queues[s_id]
|
queue = self.log_queues[s_id]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -659,7 +611,7 @@ class ActionsMapParser(BaseActionsMapParser):
|
||||||
# Return the created parser
|
# Return the created parser
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
def auth_method(self, args, route, **kwargs):
|
def auth_method(self, _, route):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Retrieve the tid for the route
|
# Retrieve the tid for the route
|
||||||
|
|
|
@ -397,7 +397,7 @@ class ActionsMapParser(BaseActionsMapParser):
|
||||||
|
|
||||||
self.global_parser.add_argument(*names, **argument_options)
|
self.global_parser.add_argument(*names, **argument_options)
|
||||||
|
|
||||||
def auth_method(self, args, **kwargs):
|
def auth_method(self, args):
|
||||||
# FIXME? idk .. this try/except is duplicated from parse_args below
|
# FIXME? idk .. this try/except is duplicated from parse_args below
|
||||||
# Just to be able to obtain the tid
|
# Just to be able to obtain the tid
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
# Global parameters #
|
# Global parameters #
|
||||||
#############################
|
#############################
|
||||||
_global:
|
_global:
|
||||||
|
name: moulitest
|
||||||
authentication:
|
authentication:
|
||||||
api: dummy
|
api: dummy
|
||||||
cli: dummy
|
cli: dummy
|
||||||
|
|
|
@ -10,9 +10,8 @@ from moulinette.actionsmap import (
|
||||||
ActionsMap,
|
ActionsMap,
|
||||||
)
|
)
|
||||||
|
|
||||||
from moulinette.interfaces import GLOBAL_SECTION
|
|
||||||
from moulinette.core import MoulinetteError
|
from moulinette.core import MoulinetteError
|
||||||
from moulinette import m18n
|
from moulinette import m18n, msettings
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -74,6 +73,7 @@ def test_ask_parameter(iface, mocker):
|
||||||
|
|
||||||
from moulinette.core import Moulinette18n
|
from moulinette.core import Moulinette18n
|
||||||
|
|
||||||
|
msettings["interface"] = iface
|
||||||
mocker.patch.object(Moulinette18n, "n", return_value="awesome_test")
|
mocker.patch.object(Moulinette18n, "n", return_value="awesome_test")
|
||||||
mocker.patch.object(iface, "prompt", return_value="awesome_test")
|
mocker.patch.object(iface, "prompt", return_value="awesome_test")
|
||||||
arg = ask("foobar", "a", None)
|
arg = ask("foobar", "a", None)
|
||||||
|
@ -87,6 +87,7 @@ def test_password_parameter(iface, mocker):
|
||||||
|
|
||||||
from moulinette.core import Moulinette18n
|
from moulinette.core import Moulinette18n
|
||||||
|
|
||||||
|
msettings["interface"] = iface
|
||||||
mocker.patch.object(Moulinette18n, "n", return_value="awesome_test")
|
mocker.patch.object(Moulinette18n, "n", return_value="awesome_test")
|
||||||
mocker.patch.object(iface, "prompt", return_value="awesome_test")
|
mocker.patch.object(iface, "prompt", return_value="awesome_test")
|
||||||
arg = ask("foobar", "a", None)
|
arg = ask("foobar", "a", None)
|
||||||
|
@ -179,17 +180,17 @@ def test_extra_argument_parser_add_argument(iface):
|
||||||
assert extra_argument_parse._extra_params["Test"]["foo"]["ask"] == "lol"
|
assert extra_argument_parse._extra_params["Test"]["foo"]["ask"] == "lol"
|
||||||
|
|
||||||
extra_argument_parse = ExtraArgumentParser(iface)
|
extra_argument_parse = ExtraArgumentParser(iface)
|
||||||
extra_argument_parse.add_argument(GLOBAL_SECTION, "foo", {"ask": "lol"})
|
extra_argument_parse.add_argument("_global", "foo", {"ask": "lol"})
|
||||||
assert GLOBAL_SECTION in extra_argument_parse._extra_params
|
assert "_global" in extra_argument_parse._extra_params
|
||||||
assert "foo" in extra_argument_parse._extra_params[GLOBAL_SECTION]
|
assert "foo" in extra_argument_parse._extra_params["_global"]
|
||||||
assert "ask" in extra_argument_parse._extra_params[GLOBAL_SECTION]["foo"]
|
assert "ask" in extra_argument_parse._extra_params["_global"]["foo"]
|
||||||
assert extra_argument_parse._extra_params[GLOBAL_SECTION]["foo"]["ask"] == "lol"
|
assert extra_argument_parse._extra_params["_global"]["foo"]["ask"] == "lol"
|
||||||
|
|
||||||
|
|
||||||
def test_extra_argument_parser_add_argument_bad_arg(iface):
|
def test_extra_argument_parser_add_argument_bad_arg(iface):
|
||||||
extra_argument_parse = ExtraArgumentParser(iface)
|
extra_argument_parse = ExtraArgumentParser(iface)
|
||||||
with pytest.raises(MoulinetteError) as exception:
|
with pytest.raises(MoulinetteError) as exception:
|
||||||
extra_argument_parse.add_argument(GLOBAL_SECTION, "foo", {"ask": 1})
|
extra_argument_parse.add_argument("_global", "foo", {"ask": 1})
|
||||||
|
|
||||||
expected_msg = "unable to validate extra parameter '%s' for argument '%s': %s" % (
|
expected_msg = "unable to validate extra parameter '%s' for argument '%s': %s" % (
|
||||||
"ask",
|
"ask",
|
||||||
|
@ -199,23 +200,23 @@ def test_extra_argument_parser_add_argument_bad_arg(iface):
|
||||||
assert expected_msg in str(exception)
|
assert expected_msg in str(exception)
|
||||||
|
|
||||||
extra_argument_parse = ExtraArgumentParser(iface)
|
extra_argument_parse = ExtraArgumentParser(iface)
|
||||||
extra_argument_parse.add_argument(GLOBAL_SECTION, "foo", {"error": 1})
|
extra_argument_parse.add_argument("_global", "foo", {"error": 1})
|
||||||
|
|
||||||
assert GLOBAL_SECTION in extra_argument_parse._extra_params
|
assert "_global" in extra_argument_parse._extra_params
|
||||||
assert "foo" in extra_argument_parse._extra_params[GLOBAL_SECTION]
|
assert "foo" in extra_argument_parse._extra_params["_global"]
|
||||||
assert not len(extra_argument_parse._extra_params[GLOBAL_SECTION]["foo"])
|
assert not len(extra_argument_parse._extra_params["_global"]["foo"])
|
||||||
|
|
||||||
|
|
||||||
def test_extra_argument_parser_parse_args(iface, mocker):
|
def test_extra_argument_parser_parse_args(iface, mocker):
|
||||||
extra_argument_parse = ExtraArgumentParser(iface)
|
extra_argument_parse = ExtraArgumentParser(iface)
|
||||||
extra_argument_parse.add_argument(GLOBAL_SECTION, "foo", {"ask": "lol"})
|
extra_argument_parse.add_argument("_global", "foo", {"ask": "lol"})
|
||||||
extra_argument_parse.add_argument(GLOBAL_SECTION, "foo2", {"ask": "lol2"})
|
extra_argument_parse.add_argument("_global", "foo2", {"ask": "lol2"})
|
||||||
extra_argument_parse.add_argument(
|
extra_argument_parse.add_argument(
|
||||||
GLOBAL_SECTION, "bar", {"password": "lul", "ask": "lul"}
|
"_global", "bar", {"password": "lul", "ask": "lul"}
|
||||||
)
|
)
|
||||||
|
|
||||||
args = extra_argument_parse.parse_args(
|
args = extra_argument_parse.parse_args(
|
||||||
GLOBAL_SECTION, {"foo": 1, "foo2": ["a", "b", {"foobar": True}], "bar": "rab"}
|
"_global", {"foo": 1, "foo2": ["a", "b", {"foobar": True}], "bar": "rab"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "foo" in args
|
assert "foo" in args
|
||||||
|
@ -231,22 +232,32 @@ def test_extra_argument_parser_parse_args(iface, mocker):
|
||||||
def test_actions_map_api():
|
def test_actions_map_api():
|
||||||
from moulinette.interfaces.api import ActionsMapParser
|
from moulinette.interfaces.api import ActionsMapParser
|
||||||
|
|
||||||
amap = ActionsMap(ActionsMapParser())
|
parser = ActionsMapParser()
|
||||||
|
amap = ActionsMap(parser)
|
||||||
|
|
||||||
assert amap.main_namespace == "moulitest"
|
assert amap.main_namespace == "moulitest"
|
||||||
assert amap.default_authentication == "dummy"
|
assert amap.default_authentication == "dummy"
|
||||||
assert ("GET", "/test-auth/default") in amap.parser.routes
|
assert ("GET", "/test-auth/default") in amap.parser.routes
|
||||||
assert ("POST", "/test-auth/subcat/post") in amap.parser.routes
|
assert ("POST", "/test-auth/subcat/post") in amap.parser.routes
|
||||||
|
|
||||||
|
assert parser.auth_method(None, ("GET", "/test-auth/default")) == "dummy"
|
||||||
|
assert parser.auth_method(None, ("GET", "/test-auth/only-api")) == "dummy"
|
||||||
|
assert parser.auth_method(None, ("GET", "/test-auth/only-cli")) is None
|
||||||
|
|
||||||
amap.generate_cache("moulitest")
|
amap.generate_cache("moulitest")
|
||||||
|
|
||||||
amap = ActionsMap(ActionsMapParser())
|
parser = ActionsMapParser()
|
||||||
|
amap = ActionsMap(parser)
|
||||||
|
|
||||||
assert amap.main_namespace == "moulitest"
|
assert amap.main_namespace == "moulitest"
|
||||||
assert amap.default_authentication == "dummy"
|
assert amap.default_authentication == "dummy"
|
||||||
assert ("GET", "/test-auth/default") in amap.parser.routes
|
assert ("GET", "/test-auth/default") in amap.parser.routes
|
||||||
assert ("POST", "/test-auth/subcat/post") in amap.parser.routes
|
assert ("POST", "/test-auth/subcat/post") in amap.parser.routes
|
||||||
|
|
||||||
|
assert parser.auth_method(None, ("GET", "/test-auth/default")) == "dummy"
|
||||||
|
assert parser.auth_method(None, ("GET", "/test-auth/only-api")) == "dummy"
|
||||||
|
assert parser.auth_method(None, ("GET", "/test-auth/only-cli")) is None
|
||||||
|
|
||||||
|
|
||||||
def test_actions_map_import_error(mocker):
|
def test_actions_map_import_error(mocker):
|
||||||
from moulinette.interfaces.api import ActionsMapParser
|
from moulinette.interfaces.api import ActionsMapParser
|
||||||
|
@ -281,14 +292,16 @@ def test_actions_map_cli():
|
||||||
from moulinette.interfaces.cli import ActionsMapParser
|
from moulinette.interfaces.cli import ActionsMapParser
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(add_help=False)
|
top_parser = argparse.ArgumentParser(add_help=False)
|
||||||
parser.add_argument(
|
top_parser.add_argument(
|
||||||
"--debug",
|
"--debug",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
help="Log and print debug messages",
|
help="Log and print debug messages",
|
||||||
)
|
)
|
||||||
amap = ActionsMap(ActionsMapParser(top_parser=parser))
|
|
||||||
|
parser = ActionsMapParser(top_parser=top_parser)
|
||||||
|
amap = ActionsMap(parser)
|
||||||
|
|
||||||
assert amap.main_namespace == "moulitest"
|
assert amap.main_namespace == "moulitest"
|
||||||
assert amap.default_authentication == "dummy"
|
assert amap.default_authentication == "dummy"
|
||||||
|
@ -304,9 +317,14 @@ def test_actions_map_cli():
|
||||||
.choices
|
.choices
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert parser.auth_method(["testauth", "default"]) == "dummy"
|
||||||
|
assert parser.auth_method(["testauth", "only-api"]) is None
|
||||||
|
assert parser.auth_method(["testauth", "only-cli"]) == "dummy"
|
||||||
|
|
||||||
amap.generate_cache("moulitest")
|
amap.generate_cache("moulitest")
|
||||||
|
|
||||||
amap = ActionsMap(ActionsMapParser(top_parser=parser))
|
parser = ActionsMapParser(top_parser=top_parser)
|
||||||
|
amap = ActionsMap(parser)
|
||||||
|
|
||||||
assert amap.main_namespace == "moulitest"
|
assert amap.main_namespace == "moulitest"
|
||||||
assert amap.default_authentication == "dummy"
|
assert amap.default_authentication == "dummy"
|
||||||
|
@ -321,3 +339,7 @@ def test_actions_map_cli():
|
||||||
._actions[1]
|
._actions[1]
|
||||||
.choices
|
.choices
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert parser.auth_method(["testauth", "default"]) == "dummy"
|
||||||
|
assert parser.auth_method(["testauth", "only-api"]) is None
|
||||||
|
assert parser.auth_method(["testauth", "only-cli"]) == "dummy"
|
||||||
|
|
|
@ -11,6 +11,7 @@ class TestAuthAPI:
|
||||||
password = "dummy"
|
password = "dummy"
|
||||||
|
|
||||||
data = {"credentials": password}
|
data = {"credentials": password}
|
||||||
|
|
||||||
if profile:
|
if profile:
|
||||||
data["profile"] = profile
|
data["profile"] = profile
|
||||||
|
|
||||||
|
@ -67,13 +68,7 @@ class TestAuthAPI:
|
||||||
def test_login(self, moulinette_webapi):
|
def test_login(self, moulinette_webapi):
|
||||||
assert self.login(moulinette_webapi).text == "Logged in"
|
assert self.login(moulinette_webapi).text == "Logged in"
|
||||||
|
|
||||||
assert "session.id" in moulinette_webapi.cookies
|
assert "session.moulitest" in moulinette_webapi.cookies
|
||||||
assert "session.tokens" in moulinette_webapi.cookies
|
|
||||||
|
|
||||||
cache_session_default = os.environ["MOULINETTE_CACHE_DIR"] + "/session/dummy/"
|
|
||||||
assert moulinette_webapi.cookies["session.id"] + ".asc" in os.listdir(
|
|
||||||
cache_session_default
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_login_bad_password(self, moulinette_webapi):
|
def test_login_bad_password(self, moulinette_webapi):
|
||||||
assert (
|
assert (
|
||||||
|
@ -81,8 +76,7 @@ class TestAuthAPI:
|
||||||
== "Invalid password"
|
== "Invalid password"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "session.id" not in moulinette_webapi.cookies
|
assert "session.moulitest" not in moulinette_webapi.cookies
|
||||||
assert "session.tokens" not in moulinette_webapi.cookies
|
|
||||||
|
|
||||||
def test_login_csrf_attempt(self, moulinette_webapi):
|
def test_login_csrf_attempt(self, moulinette_webapi):
|
||||||
# C.f.
|
# C.f.
|
||||||
|
@ -93,8 +87,7 @@ class TestAuthAPI:
|
||||||
"CSRF protection"
|
"CSRF protection"
|
||||||
in self.login(moulinette_webapi, csrf=True, status=403).text
|
in self.login(moulinette_webapi, csrf=True, status=403).text
|
||||||
)
|
)
|
||||||
assert not any(c.name == "session.id" for c in moulinette_webapi.cookiejar)
|
assert not any(c.name == "session.moulitest" for c in moulinette_webapi.cookiejar)
|
||||||
assert not any(c.name == "session.tokens" for c in moulinette_webapi.cookiejar)
|
|
||||||
|
|
||||||
def test_login_then_legit_request_without_cookies(self, moulinette_webapi):
|
def test_login_then_legit_request_without_cookies(self, moulinette_webapi):
|
||||||
self.login(moulinette_webapi)
|
self.login(moulinette_webapi)
|
||||||
|
@ -106,6 +99,11 @@ class TestAuthAPI:
|
||||||
def test_login_then_legit_request(self, moulinette_webapi):
|
def test_login_then_legit_request(self, moulinette_webapi):
|
||||||
self.login(moulinette_webapi)
|
self.login(moulinette_webapi)
|
||||||
|
|
||||||
|
assert "session.moulitest" in moulinette_webapi.cookies
|
||||||
|
print("====================cookie")
|
||||||
|
print(moulinette_webapi.cookiejar)
|
||||||
|
print(moulinette_webapi.cookies)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
moulinette_webapi.get("/test-auth/default", status=200).text
|
moulinette_webapi.get("/test-auth/default", status=200).text
|
||||||
== '"some_data_from_default"'
|
== '"some_data_from_default"'
|
||||||
|
@ -121,11 +119,6 @@ class TestAuthAPI:
|
||||||
|
|
||||||
moulinette_webapi.get("/logout", status=200)
|
moulinette_webapi.get("/logout", status=200)
|
||||||
|
|
||||||
cache_session_default = os.environ["MOULINETTE_CACHE_DIR"] + "/session/dummy/"
|
|
||||||
assert not moulinette_webapi.cookies["session.id"] + ".asc" in os.listdir(
|
|
||||||
cache_session_default
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
moulinette_webapi.get("/test-auth/default", status=401).text
|
moulinette_webapi.get("/test-auth/default", status=401).text
|
||||||
== "Authentication required"
|
== "Authentication required"
|
||||||
|
@ -134,15 +127,7 @@ class TestAuthAPI:
|
||||||
def test_login_other_profile(self, moulinette_webapi):
|
def test_login_other_profile(self, moulinette_webapi):
|
||||||
self.login(moulinette_webapi, profile="yoloswag", password="yoloswag")
|
self.login(moulinette_webapi, profile="yoloswag", password="yoloswag")
|
||||||
|
|
||||||
assert "session.id" in moulinette_webapi.cookies
|
assert "session.moulitest" in moulinette_webapi.cookies
|
||||||
assert "session.tokens" in moulinette_webapi.cookies
|
|
||||||
|
|
||||||
cache_session_default = (
|
|
||||||
os.environ["MOULINETTE_CACHE_DIR"] + "/session/yoloswag/"
|
|
||||||
)
|
|
||||||
assert moulinette_webapi.cookies["session.id"] + ".asc" in os.listdir(
|
|
||||||
cache_session_default
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_login_wrong_profile(self, moulinette_webapi):
|
def test_login_wrong_profile(self, moulinette_webapi):
|
||||||
self.login(moulinette_webapi)
|
self.login(moulinette_webapi)
|
||||||
|
|
Loading…
Reference in a new issue