Merge pull request #311 from YunoHost/moar_session_management_changes

api: Move cookie session management logic to the authenticator for more flexibility
This commit is contained in:
Alexandre Aubin 2022-01-11 12:58:28 +01:00 committed by GitHub
commit 5440053d5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 158 additions and 75 deletions

View file

@ -10,6 +10,7 @@ from typing import List, Optional
from time import time from time import time
from collections import OrderedDict from collections import OrderedDict
from importlib import import_module from importlib import import_module
from functools import cache
from moulinette import m18n, Moulinette from moulinette import m18n, Moulinette
from moulinette.core import ( from moulinette.core import (
@ -413,6 +414,9 @@ class ActionsMap:
# Read actions map from yaml file # Read actions map from yaml file
actionsmap = read_yaml(actionsmap_yml) actionsmap = read_yaml(actionsmap_yml)
if not actionsmap["_global"].get("cache", True):
return actionsmap
# Delete old cache files # Delete old cache files
for old_cache in glob.glob(f"{actionsmap_yml_dir}/.{actionsmap_yml_file}.*.pkl"): for old_cache in glob.glob(f"{actionsmap_yml_dir}/.{actionsmap_yml_file}.*.pkl"):
os.remove(old_cache) os.remove(old_cache)
@ -456,6 +460,7 @@ class ActionsMap:
self.extraparser = ExtraArgumentParser(top_parser.interface) self.extraparser = ExtraArgumentParser(top_parser.interface)
self.parser = self._construct_parser(actionsmap, top_parser) self.parser = self._construct_parser(actionsmap, top_parser)
@cache
def get_authenticator(self, auth_method): def get_authenticator(self, auth_method):
if auth_method == "default": if auth_method == "default":
@ -534,7 +539,7 @@ class ActionsMap:
full_action_name = "{}.{}.{}".format(namespace, category, action) full_action_name = "{}.{}.{}".format(namespace, category, action)
# Lock the moulinette for the namespace # Lock the moulinette for the namespace
with MoulinetteLock(namespace, timeout): with MoulinetteLock(namespace, timeout, self.enable_lock):
start = time() start = time()
try: try:
mod = __import__( mod = __import__(
@ -616,7 +621,7 @@ class ActionsMap:
_global = actionsmap.pop("_global", {}) _global = actionsmap.pop("_global", {})
self.namespace = _global["namespace"] self.namespace = _global["namespace"]
self.cookie_name = _global["cookie_name"] self.enable_lock = _global.get("lock", True)
self.default_authentication = _global["authentication"][ self.default_authentication = _global["authentication"][
interface_type interface_type
] ]

View file

@ -290,10 +290,11 @@ class MoulinetteLock:
base_lockfile = "/var/run/moulinette_%s.lock" base_lockfile = "/var/run/moulinette_%s.lock"
def __init__(self, namespace, timeout=None, interval=0.5): def __init__(self, namespace, timeout=None, enable_lock=True, interval=0.5):
self.namespace = namespace self.namespace = namespace
self.timeout = timeout self.timeout = timeout
self.interval = interval self.interval = interval
self.enable_lock = enable_lock
self._lockfile = self.base_lockfile % namespace self._lockfile = self.base_lockfile % namespace
self._stale_checked = False self._stale_checked = False
@ -420,7 +421,7 @@ class MoulinetteLock:
return False return False
def __enter__(self): def __enter__(self):
if not self._locked: if self.enable_lock and not self._locked:
self.acquire() self.acquire()
return self return self

View file

@ -29,7 +29,6 @@ from moulinette.interfaces import (
JSONExtendedEncoder, JSONExtendedEncoder,
) )
from moulinette.utils import log from moulinette.utils import log
from moulinette.utils.text import random_ascii
logger = log.getLogger("moulinette.interface.api") logger = log.getLogger("moulinette.interface.api")
@ -83,9 +82,20 @@ class APIQueueHandler(logging.Handler):
def __init__(self): def __init__(self):
logging.Handler.__init__(self) logging.Handler.__init__(self)
self.queues = LogQueues() self.queues = LogQueues()
# actionsmap is actually set during the interface's init ...
self.actionsmap = None
def emit(self, record): def emit(self, record):
s_id = Session.get_infos(raise_if_no_session_exists=False)["id"]
# Prevent triggering this function while moulinette
# is being initialized with --debug
if not self.actionsmap or len(request.cookies) == 0:
return
profile = request.params.get("profile", self.actionsmap.default_authentication)
authenticator = self.actionsmap.get_authenticator(profile)
s_id = authenticator.get_session_cookie(raise_if_no_session_exists=False)["id"]
try: try:
queue = self.queues[s_id] queue = self.queues[s_id]
except KeyError: except KeyError:
@ -232,51 +242,7 @@ class _HTTPArgumentParser:
raise MoulinetteValidationError(message, raw_msg=True) raise MoulinetteValidationError(message, raw_msg=True)
class Session:
secret = random_ascii()
cookie_name = None # This is later set to the actionsmap name
@staticmethod
def set_infos(infos):
assert isinstance(infos, dict)
response.set_cookie(
f"session.{Session.cookie_name}",
infos,
secure=True,
secret=Session.secret,
httponly=True,
# samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions
)
@staticmethod
def get_infos(raise_if_no_session_exists=True):
try:
infos = request.get_cookie(
f"session.{Session.cookie_name}", secret=Session.secret, default={}
)
except Exception:
if not raise_if_no_session_exists:
return {"id": random_ascii()}
raise MoulinetteAuthenticationError("unable_authenticate")
if "id" not in infos:
infos["id"] = random_ascii()
return infos
@staticmethod
def delete_infos():
response.set_cookie(f"session.{Session.cookie_name}", "", max_age=-1)
response.delete_cookie(f"session.{Session.cookie_name}")
class _ActionsMapPlugin: class _ActionsMapPlugin:
"""Actions map Bottle Plugin """Actions map Bottle Plugin
Process relevant action for the request using the actions map and Process relevant action for the request using the actions map and
@ -294,7 +260,6 @@ class _ActionsMapPlugin:
self.actionsmap = actionsmap self.actionsmap = actionsmap
self.log_queues = log_queues self.log_queues = log_queues
Session.cookie_name = actionsmap.cookie_name
def setup(self, app): def setup(self, app):
"""Setup plugin on the application """Setup plugin on the application
@ -398,13 +363,10 @@ class _ActionsMapPlugin:
credentials = request.params["credentials"] credentials = request.params["credentials"]
profile = request.params.get("profile", self.actionsmap.default_authentication) profile = request.params.get("profile", self.actionsmap.default_authentication)
authenticator = self.actionsmap.get_authenticator(profile) authenticator = self.actionsmap.get_authenticator(profile)
try: try:
auth_info = authenticator.authenticate_credentials(credentials) auth_infos = authenticator.authenticate_credentials(credentials)
session_infos = Session.get_infos(raise_if_no_session_exists=False)
session_infos[profile] = auth_info
except MoulinetteError as e: except MoulinetteError as e:
try: try:
self.logout() self.logout()
@ -412,18 +374,14 @@ class _ActionsMapPlugin:
pass pass
raise HTTPResponse(e.strerror, 401) raise HTTPResponse(e.strerror, 401)
else: else:
Session.set_infos(session_infos) authenticator.set_session_cookie(auth_infos)
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):
try: try:
session_infos = Session.get_infos()[authenticator.name] session_infos = authenticator.get_session_cookie()
# Here, maybe we want to re-authenticate the session via the authenticator
# For example to check that the username authenticated is still in the admin group...
except Exception: except Exception:
msg = m18n.g("authentication_required") msg = m18n.g("authentication_required")
raise HTTPResponse(msg, 401) raise HTTPResponse(msg, 401)
@ -431,13 +389,17 @@ class _ActionsMapPlugin:
return session_infos return session_infos
def logout(self): def logout(self):
profile = request.params.get("profile", self.actionsmap.default_authentication)
authenticator = self.actionsmap.get_authenticator(profile)
try: try:
Session.get_infos() authenticator.get_session_cookie()
except KeyError: except KeyError:
raise HTTPResponse(m18n.g("not_logged_in"), 401) raise HTTPResponse(m18n.g("not_logged_in"), 401)
else: else:
# Delete cookie and clean the session # Delete cookie and clean the session
Session.delete_infos() authenticator.delete_session_cookie()
return m18n.g("logged_out") return m18n.g("logged_out")
def messages(self): def messages(self):
@ -446,7 +408,11 @@ class _ActionsMapPlugin:
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 = Session.get_infos()["id"]
profile = request.params.get("profile", self.actionsmap.default_authentication)
authenticator = self.actionsmap.get_authenticator(profile)
s_id = authenticator.get_session_cookie()["id"]
try: try:
queue = self.log_queues[s_id] queue = self.log_queues[s_id]
except KeyError: except KeyError:
@ -514,9 +480,13 @@ class _ActionsMapPlugin:
UPLOAD_DIR = None UPLOAD_DIR = None
# Close opened WebSocket by putting StopIteration in the queue # Close opened WebSocket by putting StopIteration in the queue
profile = request.params.get("profile", self.actionsmap.default_authentication)
authenticator = self.actionsmap.get_authenticator(profile)
try: try:
s_id = Session.get_infos()["id"] s_id = authenticator.get_session_cookie()["id"]
queue = self.log_queues[s_id] queue = self.log_queues[s_id]
except MoulinetteAuthenticationError:
pass
except KeyError: except KeyError:
pass pass
else: else:
@ -524,7 +494,10 @@ class _ActionsMapPlugin:
def display(self, message, style="info"): def display(self, message, style="info"):
s_id = Session.get_infos(raise_if_no_session_exists=False)["id"] profile = request.params.get("profile", self.actionsmap.default_authentication)
authenticator = self.actionsmap.get_authenticator(profile)
s_id = authenticator.get_session_cookie(raise_if_no_session_exists=False)["id"]
try: try:
queue = self.log_queues[s_id] queue = self.log_queues[s_id]
except KeyError: except KeyError:
@ -747,6 +720,7 @@ class Interface:
handler = log.getHandlersByClass(APIQueueHandler, limit=1) handler = log.getHandlersByClass(APIQueueHandler, limit=1)
if handler: if handler:
log_queues = handler.queues log_queues = handler.queues
handler.actionsmap = actionsmap
# TODO: Return OK to 'OPTIONS' xhr requests (l173) # TODO: Return OK to 'OPTIONS' xhr requests (l173)
app = Bottle(autojson=True) app = Bottle(autojson=True)

View file

@ -4,7 +4,6 @@
############################# #############################
_global: _global:
namespace: moulitest namespace: moulitest
cookie_name: moulitest
authentication: authentication:
api: dummy api: dummy
cli: dummy cli: dummy

View file

@ -1,13 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
from moulinette.core import MoulinetteError from moulinette.utils.text import random_ascii
from moulinette.core import MoulinetteError, MoulinetteAuthenticationError
from moulinette.authentication import BaseAuthenticator from moulinette.authentication import BaseAuthenticator
logger = logging.getLogger("moulinette.authenticator.dummy") logger = logging.getLogger("moulinette.authenticator.yoloswag")
# Dummy authenticator implementation # Dummy authenticator implementation
session_secret = random_ascii()
class Authenticator(BaseAuthenticator): class Authenticator(BaseAuthenticator):
@ -24,3 +27,52 @@ class Authenticator(BaseAuthenticator):
raise MoulinetteError("invalid_password", raw_msg=True) raise MoulinetteError("invalid_password", raw_msg=True)
return return
def set_session_cookie(self, infos):
from bottle import response
assert isinstance(infos, dict)
# This allows to generate a new session id or keep the existing one
current_infos = self.get_session_cookie(raise_if_no_session_exists=False)
new_infos = {"id": current_infos["id"]}
new_infos.update(infos)
response.set_cookie(
"moulitest",
new_infos,
secure=True,
secret=session_secret,
httponly=True,
# samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions
)
def get_session_cookie(self, raise_if_no_session_exists=True):
from bottle import request
try:
infos = request.get_cookie(
"moulitest", secret=session_secret, default={}
)
except Exception:
if not raise_if_no_session_exists:
return {"id": random_ascii()}
raise MoulinetteAuthenticationError("unable_authenticate")
if not infos and raise_if_no_session_exists:
raise MoulinetteAuthenticationError("unable_authenticate")
if "id" not in infos:
infos["id"] = random_ascii()
return infos
def delete_session_cookie(self):
from bottle import response
response.set_cookie("moulitest", "", max_age=-1)
response.delete_cookie("moulitest")

View file

@ -1,13 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
from moulinette.core import MoulinetteError from moulinette.utils.text import random_ascii
from moulinette.core import MoulinetteError, MoulinetteAuthenticationError
from moulinette.authentication import BaseAuthenticator from moulinette.authentication import BaseAuthenticator
logger = logging.getLogger("moulinette.authenticator.yoloswag") logger = logging.getLogger("moulinette.authenticator.yoloswag")
# Dummy authenticator implementation # Dummy authenticator implementation
session_secret = random_ascii()
class Authenticator(BaseAuthenticator): class Authenticator(BaseAuthenticator):
@ -24,3 +27,52 @@ class Authenticator(BaseAuthenticator):
raise MoulinetteError("invalid_password", raw_msg=True) raise MoulinetteError("invalid_password", raw_msg=True)
return return
def set_session_cookie(self, infos):
from bottle import response
assert isinstance(infos, dict)
# This allows to generate a new session id or keep the existing one
current_infos = self.get_session_cookie(raise_if_no_session_exists=False)
new_infos = {"id": current_infos["id"]}
new_infos.update(infos)
response.set_cookie(
"moulitest",
new_infos,
secure=True,
secret=session_secret,
httponly=True,
# samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions
)
def get_session_cookie(self, raise_if_no_session_exists=True):
from bottle import request
try:
infos = request.get_cookie(
"moulitest", secret=session_secret, default={}
)
except Exception:
if not raise_if_no_session_exists:
return {"id": random_ascii()}
raise MoulinetteAuthenticationError("unable_authenticate")
if not infos and raise_if_no_session_exists:
raise MoulinetteAuthenticationError("unable_authenticate")
if "id" not in infos:
infos["id"] = random_ascii()
return infos
def delete_session_cookie(self):
from bottle import response
response.set_cookie("moulitest", "", max_age=-1)
response.delete_cookie("moulitest")

View file

@ -66,7 +66,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.moulitest" in moulinette_webapi.cookies assert "moulitest" in moulinette_webapi.cookies
def test_login_bad_password(self, moulinette_webapi): def test_login_bad_password(self, moulinette_webapi):
assert ( assert (
@ -74,7 +74,7 @@ class TestAuthAPI:
== "invalid_password" == "invalid_password"
) )
assert "session.moulitest" not in moulinette_webapi.cookies assert "moulitest" 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.
@ -86,7 +86,7 @@ class TestAuthAPI:
in self.login(moulinette_webapi, csrf=True, status=403).text in self.login(moulinette_webapi, csrf=True, status=403).text
) )
assert not any( assert not any(
c.name == "session.moulitest" for c in moulinette_webapi.cookiejar c.name == "moulitest" 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):
@ -99,7 +99,7 @@ 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 assert "moulitest" in moulinette_webapi.cookies
assert ( assert (
moulinette_webapi.get("/test-auth/default", status=200).text moulinette_webapi.get("/test-auth/default", status=200).text
@ -124,7 +124,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.moulitest" in moulinette_webapi.cookies assert "moulitest" in moulinette_webapi.cookies
def test_login_wrong_profile(self, moulinette_webapi): def test_login_wrong_profile(self, moulinette_webapi):
self.login(moulinette_webapi) self.login(moulinette_webapi)