[wip] Try to simplify authentication code more, properly differentiate credentials authentication vs. session authentication, trash the 'msignals' mess

This commit is contained in:
Alexandre Aubin 2021-06-19 17:16:19 +02:00
parent f792166581
commit 6310ef5b6e
10 changed files with 62 additions and 228 deletions

View file

@ -2,7 +2,6 @@
from moulinette.core import ( from moulinette.core import (
MoulinetteError, MoulinetteError,
MoulinetteSignals,
Moulinette18n, Moulinette18n,
) )
from moulinette.globals import init_moulinette_env from moulinette.globals import init_moulinette_env
@ -31,14 +30,12 @@ __all__ = [
"api", "api",
"cli", "cli",
"m18n", "m18n",
"msignals",
"env", "env",
"init_interface", "init_interface",
"MoulinetteError", "MoulinetteError",
] ]
msignals = MoulinetteSignals()
msettings = dict() msettings = dict()
m18n = Moulinette18n() m18n = Moulinette18n()
@ -116,9 +113,7 @@ def cli(args, top_parser, output_as=None, timeout=None):
try: try:
load_only_category = args[0] if args and not args[0].startswith("-") else None load_only_category = args[0] if args and not args[0].startswith("-") else None
Cli(top_parser=top_parser, load_only_category=load_only_category).run( Cli(top_parser=top_parser, load_only_category=load_only_category).run(args, output_as=output_as, timeout=timeout)
args, output_as=output_as, timeout=timeout
)
except MoulinetteError as e: except MoulinetteError as e:
import logging import logging

View file

@ -11,7 +11,7 @@ from time import time
from collections import OrderedDict from collections import OrderedDict
from importlib import import_module from importlib import import_module
from moulinette import m18n, msignals from moulinette import m18n, msettings
from moulinette.cache import open_cachefile from moulinette.cache import open_cachefile
from moulinette.globals import init_moulinette_env from moulinette.globals import init_moulinette_env
from moulinette.core import ( from moulinette.core import (
@ -98,7 +98,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 msignals.display(m18n.n(message)) return self.iface.display(m18n.n(message))
@classmethod @classmethod
def validate(klass, value, arg_name): def validate(klass, value, arg_name):
@ -135,7 +135,7 @@ class AskParameter(_ExtraParameter):
try: try:
# Ask for the argument value # Ask for the argument value
return msignals.prompt(m18n.n(message)) return self.iface.prompt(m18n.n(message))
except NotImplementedError: except NotImplementedError:
return arg_value return arg_value
@ -173,7 +173,7 @@ class PasswordParameter(AskParameter):
try: try:
# Ask for the password # Ask for the password
return msignals.prompt(m18n.n(message), True, True) return self.iface.prompt(m18n.n(message), True, True)
except NotImplementedError: except NotImplementedError:
return arg_value return arg_value
@ -500,7 +500,7 @@ class ActionsMap(object):
return return
authenticator = self.get_authenticator(auth_method) authenticator = self.get_authenticator(auth_method)
if not msignals.authenticate(authenticator): if not msettings['interface'].authenticate(authenticator):
raise MoulinetteAuthenticationError("authentication_required_long") raise MoulinetteAuthenticationError("authentication_required_long")
def process(self, args, timeout=None, **kwargs): def process(self, args, timeout=None, **kwargs):

View file

@ -5,6 +5,7 @@ import logging
import hashlib import hashlib
import hmac import hmac
from moulinette.utils.text import random_ascii
from moulinette.cache import open_cachefile, get_cachedir, cachefile_exists from moulinette.cache import open_cachefile, get_cachedir, cachefile_exists
from moulinette.core import MoulinetteError, MoulinetteAuthenticationError from moulinette.core import MoulinetteError, MoulinetteAuthenticationError
@ -32,11 +33,11 @@ class BaseAuthenticator(object):
# Virtual methods # Virtual methods
# Each authenticator classes must implement these methods. # Each authenticator classes must implement these methods.
def authenticate_credentials(self, credentials=None, store_session=False): def authenticate_credentials(self, credentials, store_session=False):
try: try:
# Attempt to authenticate # Attempt to authenticate
self.authenticate(credentials) self._authenticate_credentials(credentials)
except MoulinetteError: except MoulinetteError:
raise raise
except Exception as e: except Exception as e:
@ -57,7 +58,6 @@ class BaseAuthenticator(object):
else: else:
logger.debug("session has been stored") logger.debug("session has been stored")
def _authenticate_credentials(self, credentials=None): def _authenticate_credentials(self, credentials=None):
"""Attempt to authenticate """Attempt to authenticate
@ -91,7 +91,7 @@ class BaseAuthenticator(object):
with self._open_sessionfile(session_id, "w") as f: with self._open_sessionfile(session_id, "w") as f:
f.write(hash_) f.write(hash_)
def authenticate_session(s_id, s_token): def authenticate_session(self, s_id, s_token):
try: try:
# Attempt to authenticate # Attempt to authenticate
self._authenticate_session(s_id, s_token) self._authenticate_session(s_id, s_token)

View file

@ -270,101 +270,6 @@ class Moulinette18n(object):
return self._namespaces[self._current_namespace].key_exists(key) return self._namespaces[self._current_namespace].key_exists(key)
class MoulinetteSignals(object):
"""Signals connector for the moulinette
Allow to easily connect signals from the moulinette to handlers. A
signal is emitted by calling the relevant method which call the
handler.
For the moment, a return value can be requested by a signal to its
connected handler - make them not real-signals.
Keyword arguments:
- kwargs -- A dict of {signal: handler} to connect
"""
def __init__(self, **kwargs):
# Initialize handlers
for s in self.signals:
self.clear_handler(s)
# Iterate over signals to connect
for s, h in kwargs.items():
self.set_handler(s, h)
def set_handler(self, signal, handler):
"""Set the handler for a signal"""
if signal not in self.signals:
logger.error("unknown signal '%s'", signal)
return
setattr(self, "_%s" % signal, handler)
def clear_handler(self, signal):
"""Clear the handler of a signal"""
if signal not in self.signals:
logger.error("unknown signal '%s'", signal)
return
setattr(self, "_%s" % signal, self._notimplemented)
# Signals definitions
"""The list of available signals"""
signals = {"authenticate", "prompt", "display"}
def authenticate(self, authenticator):
if hasattr(authenticator, "is_authenticated"):
return authenticator.is_authenticated
# self._authenticate corresponds to the stuff defined with
# msignals.set_handler("authenticate", ...) per interface...
return self._authenticate(authenticator)
def prompt(self, message, is_password=False, confirm=False, color="blue"):
"""Prompt for a value
Prompt the interface for a parameter value which is a password
if 'is_password' and must be confirmed if 'confirm'.
Is is called when a parameter value is needed and when the
current interface should allow user interaction (e.g. to parse
extra parameter 'ask' in the cli).
Keyword arguments:
- message -- The message to display
- is_password -- True if the parameter is a password
- confirm -- True if the value must be confirmed
- color -- Color to use for the prompt ...
Returns:
The collected value
"""
return self._prompt(message, is_password, confirm, color=color)
def display(self, message, style="info"):
"""Display a message
Display a message with a given style to the user.
It is called when a message should be printed to the user if the
current interface allows user interaction (e.g. print a success
message to the user).
Keyword arguments:
- message -- The message to display
- style -- The type of the message. Possible values are:
info, success, warning
"""
try:
self._display(message, style)
except NotImplementedError:
pass
@staticmethod
def _notimplemented(*args, **kwargs):
raise NotImplementedError("this signal is not handled")
# Moulinette core classes ---------------------------------------------- # Moulinette core classes ----------------------------------------------

View file

@ -37,7 +37,6 @@ class BaseActionsMapParser(object):
def __init__(self, parent=None, **kwargs): def __init__(self, parent=None, **kwargs):
if not parent: if not parent:
logger.debug("initializing base actions map parser for %s", self.interface) logger.debug("initializing base actions map parser for %s", self.interface)
msettings["interface"] = self.interface
# Virtual properties # Virtual properties
# Each parser classes must implement these properties. # Each parser classes must implement these properties.
@ -167,27 +166,6 @@ class BaseActionsMapParser(object):
return namespace return namespace
class BaseInterface(object):
"""Moulinette's base Interface
Each interfaces must implement an Interface class derived from this
class which must overrides virtual properties and methods.
It is used to provide a user interface for an actions map.
Keyword arguments:
- actionsmap -- The ActionsMap instance to connect to
"""
# TODO: Add common interface methods and try to standardize default ones
def __init__(self, actionsmap):
raise NotImplementedError(
"derived class '%s' must override this method" % self.__class__.__name__
)
# Argument parser ------------------------------------------------------ # Argument parser ------------------------------------------------------

View file

@ -13,12 +13,11 @@ from geventwebsocket import WebSocketError
from bottle import request, response, Bottle, HTTPResponse from bottle import request, response, Bottle, HTTPResponse
from bottle import abort from bottle import abort
from moulinette import msignals, m18n, env from moulinette import m18n, env, msettings
from moulinette.actionsmap import ActionsMap from moulinette.actionsmap import ActionsMap
from moulinette.core import MoulinetteError, MoulinetteValidationError from moulinette.core import MoulinetteError, MoulinetteValidationError
from moulinette.interfaces import ( from moulinette.interfaces import (
BaseActionsMapParser, BaseActionsMapParser,
BaseInterface,
ExtendedArgumentParser, ExtendedArgumentParser,
) )
from moulinette.utils import log from moulinette.utils import log
@ -226,9 +225,6 @@ class _ActionsMapPlugin(object):
api = 2 api = 2
def __init__(self, actionsmap, log_queues={}): def __init__(self, actionsmap, log_queues={}):
# Connect signals to handlers
msignals.set_handler("authenticate", self._do_authenticate)
msignals.set_handler("display", self._do_display)
self.actionsmap = actionsmap self.actionsmap = actionsmap
self.log_queues = log_queues self.log_queues = log_queues
@ -253,6 +249,10 @@ class _ActionsMapPlugin(object):
except KeyError: except KeyError:
raise HTTPResponse("Missing credentials parameter", 400) 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( kwargs["profile"] = request.POST.get(
"profile", self.actionsmap.default_authentication "profile", self.actionsmap.default_authentication
) )
@ -361,12 +361,6 @@ class _ActionsMapPlugin(object):
""" """
authenticator = self.actionsmap.get_authenticator(profile) authenticator = self.actionsmap.get_authenticator(profile)
##################################################################
# Case 1 : credentials were provided #
# We want to validate that the credentials are right #
# Then save the session id/token, and return then in the cookies #
##################################################################
try: try:
s_id, s_token = authenticator.authenticate_credentials(credentials, store_session=True) s_id, s_token = authenticator.authenticate_credentials(credentials, store_session=True)
except MoulinetteError as e: except MoulinetteError as e:
@ -398,14 +392,8 @@ class _ActionsMapPlugin(object):
) )
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 _do_authenticate(self, authenticator): def authenticate(self, authenticator):
"""Process the authentication
Handle the core.MoulinetteSignals.authenticate signal.
"""
s_id = request.get_cookie("session.id") s_id = request.get_cookie("session.id")
try: try:
@ -419,7 +407,6 @@ class _ActionsMapPlugin(object):
else: else:
authenticator.authenticate_session(s_id, s_token) authenticator.authenticate_session(s_id, s_token)
def logout(self, profile): def logout(self, profile):
"""Log out from an authenticator profile """Log out from an authenticator profile
@ -465,9 +452,7 @@ class _ActionsMapPlugin(object):
"""Listen to the messages WebSocket stream """Listen to the messages WebSocket stream
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 core.MoulinetteSignals.display signal. They are JSON encoded as a the display method. They are JSON encoded as a dict { style: message }.
dict { style: message }.
""" """
s_id = request.get_cookie("session.id") s_id = request.get_cookie("session.id")
try: try:
@ -536,14 +521,8 @@ class _ActionsMapPlugin(object):
else: else:
queue.put(StopIteration) queue.put(StopIteration)
# Signals handlers def display(self, message, style):
def _do_display(self, message, style):
"""Display a message
Handle the core.MoulinetteSignals.display signal.
"""
s_id = request.get_cookie("session.id") s_id = request.get_cookie("session.id")
try: try:
queue = self.log_queues[s_id] queue = self.log_queues[s_id]
@ -557,6 +536,8 @@ class _ActionsMapPlugin(object):
# populate the new message in the queue # populate the new message in the queue
sleep(0) sleep(0)
def prompt(self, *args, **kwargs):
raise NotImplementedError("Prompt is not implemented for this interface")
# HTTP Responses ------------------------------------------------------- # HTTP Responses -------------------------------------------------------
@ -734,7 +715,7 @@ class ActionsMapParser(BaseActionsMapParser):
return key return key
class Interface(BaseInterface): class Interface:
"""Application Programming Interface for the moulinette """Application Programming Interface for the moulinette
@ -749,12 +730,13 @@ class Interface(BaseInterface):
""" """
def __init__(self, routes={}, log_queues=None): type = "api"
def __init__(self, routes={}):
actionsmap = ActionsMap(ActionsMapParser()) actionsmap = ActionsMap(ActionsMapParser())
# Attempt to retrieve log queues from an APIQueueHandler # Attempt to retrieve log queues from an APIQueueHandler
if log_queues is None:
handler = log.getHandlersByClass(APIQueueHandler, limit=1) handler = log.getHandlersByClass(APIQueueHandler, limit=1)
if handler: if handler:
log_queues = handler.queues log_queues = handler.queues
@ -786,11 +768,12 @@ class Interface(BaseInterface):
app.install(filter_csrf) app.install(filter_csrf)
app.install(apiheader) app.install(apiheader)
app.install(api18n) app.install(api18n)
app.install(_ActionsMapPlugin(actionsmap, log_queues)) actionsmapplugin = _ActionsMapPlugin(actionsmap, log_queues)
app.install(actionsmapplugin)
# Append default routes self.authenticate = actionsmapplugin.authenticate
# app.route(['/api', '/api/<category:re:[a-z]+>'], method='GET', self.display = actionsmapplugin.display
# callback=self.doc, skip=['actionsmap']) self.prompt = actionsmapplugin.prompt
# Append additional routes # Append additional routes
# TODO: Add optional authentication to those routes? # TODO: Add optional authentication to those routes?
@ -799,6 +782,8 @@ class Interface(BaseInterface):
self._app = app self._app = app
msettings["interface"] = self
def run(self, host="localhost", port=80): def run(self, host="localhost", port=80):
"""Run the moulinette """Run the moulinette
@ -810,6 +795,7 @@ class Interface(BaseInterface):
- port -- Server port to bind to - port -- Server port to bind to
""" """
logger.debug( logger.debug(
"starting the server instance in %s:%d", "starting the server instance in %s:%d",
host, host,
@ -832,25 +818,3 @@ class Interface(BaseInterface):
if e.args[0] == errno.EADDRINUSE: if e.args[0] == errno.EADDRINUSE:
raise MoulinetteError("server_already_running") raise MoulinetteError("server_already_running")
raise MoulinetteError(error_message) raise MoulinetteError(error_message)
# Routes handlers
def doc(self, category=None):
"""
Get API documentation for a category (all by default)
Keyword argument:
category -- Name of the category
"""
DATA_DIR = env()["DATA_DIR"]
if category is None:
with open("%s/../doc/resources.json" % DATA_DIR) as f:
return f.read()
try:
with open("%s/../doc/%s.json" % (DATA_DIR, category)) as f:
return f.read()
except IOError:
return None

View file

@ -11,12 +11,11 @@ from datetime import date, datetime
import argcomplete import argcomplete
from moulinette import msignals, m18n from moulinette import m18n, msettings
from moulinette.actionsmap import ActionsMap from moulinette.actionsmap import ActionsMap
from moulinette.core import MoulinetteError, MoulinetteValidationError from moulinette.core import MoulinetteError, MoulinetteValidationError
from moulinette.interfaces import ( from moulinette.interfaces import (
BaseActionsMapParser, BaseActionsMapParser,
BaseInterface,
ExtendedArgumentParser, ExtendedArgumentParser,
) )
from moulinette.utils import log from moulinette.utils import log
@ -450,7 +449,7 @@ class ActionsMapParser(BaseActionsMapParser):
return ret return ret
class Interface(BaseInterface): class Interface:
"""Command-line Interface for the moulinette """Command-line Interface for the moulinette
@ -462,22 +461,20 @@ class Interface(BaseInterface):
""" """
type = "cli"
def __init__(self, top_parser=None, load_only_category=None): def __init__(self, top_parser=None, load_only_category=None):
# Set user locale # Set user locale
m18n.set_locale(get_locale()) m18n.set_locale(get_locale())
# Connect signals to handlers
msignals.set_handler("display", self._do_display)
if os.isatty(1):
msignals.set_handler("authenticate", self._do_authenticate)
msignals.set_handler("prompt", self._do_prompt)
self.actionsmap = ActionsMap( self.actionsmap = ActionsMap(
ActionsMapParser(top_parser=top_parser), ActionsMapParser(top_parser=top_parser),
load_only_category=load_only_category, load_only_category=load_only_category,
) )
msettings["interface"] = self
def run(self, args, output_as=None, timeout=None): def run(self, args, output_as=None, timeout=None):
"""Run the moulinette """Run the moulinette
@ -493,15 +490,13 @@ class Interface(BaseInterface):
- timeout -- Number of seconds before this command will timeout because it can't acquire the lock (meaning that another command is currently running), by default there is no timeout and the command will wait until it can get the lock - timeout -- Number of seconds before this command will timeout because it can't acquire the lock (meaning that another command is currently running), by default there is no timeout and the command will wait until it can get the lock
""" """
if output_as and output_as not in ["json", "plain", "none"]: if output_as and output_as not in ["json", "plain", "none"]:
raise MoulinetteValidationError("invalid_usage") raise MoulinetteValidationError("invalid_usage")
# auto-complete # auto-complete
argcomplete.autocomplete(self.actionsmap.parser._parser) argcomplete.autocomplete(self.actionsmap.parser._parser)
# Set handler for authentication
msignals.set_handler("authenticate", self._do_authenticate)
try: try:
ret = self.actionsmap.process(args, timeout=timeout) ret = self.actionsmap.process(args, timeout=timeout)
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
@ -524,32 +519,26 @@ class Interface(BaseInterface):
else: else:
print(ret) print(ret)
# Signals handlers def authenticate(self, authenticator):
def _do_authenticate(self, authenticator):
"""Process the authentication
Handle the core.MoulinetteSignals.authenticate signal.
"""
# Hmpf we have no-use case in yunohost anymore where we need to auth # Hmpf we have no-use case in yunohost anymore where we need to auth
# because everything is run as root ... # because everything is run as root ...
# I guess we could imagine some yunohost-independant use-case where # I guess we could imagine some yunohost-independant use-case where
# moulinette is used to create a CLI for non-root user that needs to # moulinette is used to create a CLI for non-root user that needs to
# auth somehow but hmpf -.- # auth somehow but hmpf -.-
msg = m18n.g("password") msg = m18n.g("password")
credentials = self._do_prompt(msg, True, False, color="yellow") credentials = self.prompt(msg, True, False, color="yellow")
return authenticator.authenticate_credentials(credentials=credentials) return authenticator.authenticate_credentials(credentials=credentials)
def _do_prompt(self, message, is_password, confirm, color="blue"): def prompt(self, message, is_password, confirm, color="blue"):
"""Prompt for a value """Prompt for a value
Handle the core.MoulinetteSignals.prompt signal.
Keyword arguments: Keyword arguments:
- color -- The color to use for prompting message - color -- The color to use for prompting message
""" """
if not os.isatty(1):
raise MoulinetteError("No a tty, can't do interactive prompts", raw_msg=True)
if is_password: if is_password:
prompt = lambda m: getpass.getpass(colorize(m18n.g("colon", m), color)) prompt = lambda m: getpass.getpass(colorize(m18n.g("colon", m), color))
else: else:
@ -563,11 +552,9 @@ class Interface(BaseInterface):
return value return value
def _do_display(self, message, style): def display(self, message, style):
"""Display a message """Display a message
Handle the core.MoulinetteSignals.display signal.
""" """
if style == "success": if style == "success":
print("{} {}".format(colorize(m18n.g("success"), "green"), message)) print("{} {}".format(colorize(m18n.g("success"), "green"), message))

View file

@ -18,7 +18,7 @@ class Authenticator(BaseAuthenticator):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
pass pass
def authenticate(self, credentials=None): def _authenticate_credentials(self, credentials=None):
if not credentials == self.name: if not credentials == self.name:
raise MoulinetteError("invalid_password") raise MoulinetteError("invalid_password")

View file

@ -18,7 +18,7 @@ class Authenticator(BaseAuthenticator):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
pass pass
def authenticate(self, credentials=None): def _authenticate_credentials(self, credentials=None):
if not credentials == self.name: if not credentials == self.name:
raise MoulinetteError("invalid_password") raise MoulinetteError("invalid_password")

View file

@ -17,7 +17,12 @@ from moulinette import m18n
@pytest.fixture @pytest.fixture
def iface(): def iface():
return "iface" class DummyInterface:
def prompt():
pass
return DummyInterface()
def test_comment_parameter_bad_bool_value(iface, caplog): def test_comment_parameter_bad_bool_value(iface, caplog):
@ -67,10 +72,10 @@ def test_ask_parameter(iface, mocker):
arg = ask("foobar", "a", "a") arg = ask("foobar", "a", "a")
assert arg == "a" assert arg == "a"
from moulinette.core import Moulinette18n, MoulinetteSignals from moulinette.core import Moulinette18n
mocker.patch.object(Moulinette18n, "n", return_value="awesome_test") mocker.patch.object(Moulinette18n, "n", return_value="awesome_test")
mocker.patch.object(MoulinetteSignals, "prompt", return_value="awesome_test") mocker.patch.object(iface, "prompt", return_value="awesome_test")
arg = ask("foobar", "a", None) arg = ask("foobar", "a", None)
assert arg == "awesome_test" assert arg == "awesome_test"
@ -80,10 +85,10 @@ def test_password_parameter(iface, mocker):
arg = ask("foobar", "a", "a") arg = ask("foobar", "a", "a")
assert arg == "a" assert arg == "a"
from moulinette.core import Moulinette18n, MoulinetteSignals from moulinette.core import Moulinette18n
mocker.patch.object(Moulinette18n, "n", return_value="awesome_test") mocker.patch.object(Moulinette18n, "n", return_value="awesome_test")
mocker.patch.object(MoulinetteSignals, "prompt", return_value="awesome_test") mocker.patch.object(iface, "prompt", return_value="awesome_test")
arg = ask("foobar", "a", None) arg = ask("foobar", "a", None)
assert arg == "awesome_test" assert arg == "awesome_test"