mirror of
https://github.com/YunoHost/moulinette.git
synced 2024-09-03 20:06:31 +02:00
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:
commit
5440053d5f
7 changed files with 158 additions and 75 deletions
|
@ -10,6 +10,7 @@ from typing import List, Optional
|
|||
from time import time
|
||||
from collections import OrderedDict
|
||||
from importlib import import_module
|
||||
from functools import cache
|
||||
|
||||
from moulinette import m18n, Moulinette
|
||||
from moulinette.core import (
|
||||
|
@ -413,6 +414,9 @@ class ActionsMap:
|
|||
# Read actions map from yaml file
|
||||
actionsmap = read_yaml(actionsmap_yml)
|
||||
|
||||
if not actionsmap["_global"].get("cache", True):
|
||||
return actionsmap
|
||||
|
||||
# Delete old cache files
|
||||
for old_cache in glob.glob(f"{actionsmap_yml_dir}/.{actionsmap_yml_file}.*.pkl"):
|
||||
os.remove(old_cache)
|
||||
|
@ -456,6 +460,7 @@ class ActionsMap:
|
|||
self.extraparser = ExtraArgumentParser(top_parser.interface)
|
||||
self.parser = self._construct_parser(actionsmap, top_parser)
|
||||
|
||||
@cache
|
||||
def get_authenticator(self, auth_method):
|
||||
|
||||
if auth_method == "default":
|
||||
|
@ -534,7 +539,7 @@ class ActionsMap:
|
|||
full_action_name = "{}.{}.{}".format(namespace, category, action)
|
||||
|
||||
# Lock the moulinette for the namespace
|
||||
with MoulinetteLock(namespace, timeout):
|
||||
with MoulinetteLock(namespace, timeout, self.enable_lock):
|
||||
start = time()
|
||||
try:
|
||||
mod = __import__(
|
||||
|
@ -616,7 +621,7 @@ class ActionsMap:
|
|||
_global = actionsmap.pop("_global", {})
|
||||
|
||||
self.namespace = _global["namespace"]
|
||||
self.cookie_name = _global["cookie_name"]
|
||||
self.enable_lock = _global.get("lock", True)
|
||||
self.default_authentication = _global["authentication"][
|
||||
interface_type
|
||||
]
|
||||
|
|
|
@ -290,10 +290,11 @@ class MoulinetteLock:
|
|||
|
||||
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.timeout = timeout
|
||||
self.interval = interval
|
||||
self.enable_lock = enable_lock
|
||||
|
||||
self._lockfile = self.base_lockfile % namespace
|
||||
self._stale_checked = False
|
||||
|
@ -420,7 +421,7 @@ class MoulinetteLock:
|
|||
return False
|
||||
|
||||
def __enter__(self):
|
||||
if not self._locked:
|
||||
if self.enable_lock and not self._locked:
|
||||
self.acquire()
|
||||
return self
|
||||
|
||||
|
|
|
@ -29,7 +29,6 @@ from moulinette.interfaces import (
|
|||
JSONExtendedEncoder,
|
||||
)
|
||||
from moulinette.utils import log
|
||||
from moulinette.utils.text import random_ascii
|
||||
|
||||
logger = log.getLogger("moulinette.interface.api")
|
||||
|
||||
|
@ -83,9 +82,20 @@ class APIQueueHandler(logging.Handler):
|
|||
def __init__(self):
|
||||
logging.Handler.__init__(self)
|
||||
self.queues = LogQueues()
|
||||
# actionsmap is actually set during the interface's init ...
|
||||
self.actionsmap = None
|
||||
|
||||
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:
|
||||
queue = self.queues[s_id]
|
||||
except KeyError:
|
||||
|
@ -232,51 +242,7 @@ class _HTTPArgumentParser:
|
|||
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:
|
||||
|
||||
"""Actions map Bottle Plugin
|
||||
|
||||
Process relevant action for the request using the actions map and
|
||||
|
@ -294,7 +260,6 @@ class _ActionsMapPlugin:
|
|||
|
||||
self.actionsmap = actionsmap
|
||||
self.log_queues = log_queues
|
||||
Session.cookie_name = actionsmap.cookie_name
|
||||
|
||||
def setup(self, app):
|
||||
"""Setup plugin on the application
|
||||
|
@ -398,13 +363,10 @@ class _ActionsMapPlugin:
|
|||
credentials = request.params["credentials"]
|
||||
|
||||
profile = request.params.get("profile", self.actionsmap.default_authentication)
|
||||
|
||||
authenticator = self.actionsmap.get_authenticator(profile)
|
||||
|
||||
try:
|
||||
auth_info = authenticator.authenticate_credentials(credentials)
|
||||
session_infos = Session.get_infos(raise_if_no_session_exists=False)
|
||||
session_infos[profile] = auth_info
|
||||
auth_infos = authenticator.authenticate_credentials(credentials)
|
||||
except MoulinetteError as e:
|
||||
try:
|
||||
self.logout()
|
||||
|
@ -412,18 +374,14 @@ class _ActionsMapPlugin:
|
|||
pass
|
||||
raise HTTPResponse(e.strerror, 401)
|
||||
else:
|
||||
Session.set_infos(session_infos)
|
||||
authenticator.set_session_cookie(auth_infos)
|
||||
return m18n.g("logged_in")
|
||||
|
||||
# This is called before each time a route is going to be processed
|
||||
def authenticate(self, authenticator):
|
||||
|
||||
try:
|
||||
session_infos = Session.get_infos()[authenticator.name]
|
||||
|
||||
# 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...
|
||||
|
||||
session_infos = authenticator.get_session_cookie()
|
||||
except Exception:
|
||||
msg = m18n.g("authentication_required")
|
||||
raise HTTPResponse(msg, 401)
|
||||
|
@ -431,13 +389,17 @@ class _ActionsMapPlugin:
|
|||
return session_infos
|
||||
|
||||
def logout(self):
|
||||
|
||||
profile = request.params.get("profile", self.actionsmap.default_authentication)
|
||||
authenticator = self.actionsmap.get_authenticator(profile)
|
||||
|
||||
try:
|
||||
Session.get_infos()
|
||||
authenticator.get_session_cookie()
|
||||
except KeyError:
|
||||
raise HTTPResponse(m18n.g("not_logged_in"), 401)
|
||||
else:
|
||||
# Delete cookie and clean the session
|
||||
Session.delete_infos()
|
||||
authenticator.delete_session_cookie()
|
||||
return m18n.g("logged_out")
|
||||
|
||||
def messages(self):
|
||||
|
@ -446,7 +408,11 @@ class _ActionsMapPlugin:
|
|||
Retrieve the WebSocket stream and send to it each messages displayed by
|
||||
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:
|
||||
queue = self.log_queues[s_id]
|
||||
except KeyError:
|
||||
|
@ -514,9 +480,13 @@ class _ActionsMapPlugin:
|
|||
UPLOAD_DIR = None
|
||||
|
||||
# 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:
|
||||
s_id = Session.get_infos()["id"]
|
||||
s_id = authenticator.get_session_cookie()["id"]
|
||||
queue = self.log_queues[s_id]
|
||||
except MoulinetteAuthenticationError:
|
||||
pass
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
|
@ -524,7 +494,10 @@ class _ActionsMapPlugin:
|
|||
|
||||
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:
|
||||
queue = self.log_queues[s_id]
|
||||
except KeyError:
|
||||
|
@ -747,6 +720,7 @@ class Interface:
|
|||
handler = log.getHandlersByClass(APIQueueHandler, limit=1)
|
||||
if handler:
|
||||
log_queues = handler.queues
|
||||
handler.actionsmap = actionsmap
|
||||
|
||||
# TODO: Return OK to 'OPTIONS' xhr requests (l173)
|
||||
app = Bottle(autojson=True)
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
#############################
|
||||
_global:
|
||||
namespace: moulitest
|
||||
cookie_name: moulitest
|
||||
authentication:
|
||||
api: dummy
|
||||
cli: dummy
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger("moulinette.authenticator.dummy")
|
||||
logger = logging.getLogger("moulinette.authenticator.yoloswag")
|
||||
|
||||
# Dummy authenticator implementation
|
||||
|
||||
session_secret = random_ascii()
|
||||
|
||||
|
||||
class Authenticator(BaseAuthenticator):
|
||||
|
||||
|
@ -24,3 +27,52 @@ class Authenticator(BaseAuthenticator):
|
|||
raise MoulinetteError("invalid_password", raw_msg=True)
|
||||
|
||||
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")
|
||||
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger("moulinette.authenticator.yoloswag")
|
||||
|
||||
# Dummy authenticator implementation
|
||||
|
||||
session_secret = random_ascii()
|
||||
|
||||
|
||||
class Authenticator(BaseAuthenticator):
|
||||
|
||||
|
@ -24,3 +27,52 @@ class Authenticator(BaseAuthenticator):
|
|||
raise MoulinetteError("invalid_password", raw_msg=True)
|
||||
|
||||
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")
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ class TestAuthAPI:
|
|||
def test_login(self, moulinette_webapi):
|
||||
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):
|
||||
assert (
|
||||
|
@ -74,7 +74,7 @@ class TestAuthAPI:
|
|||
== "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):
|
||||
# C.f.
|
||||
|
@ -86,7 +86,7 @@ class TestAuthAPI:
|
|||
in self.login(moulinette_webapi, csrf=True, status=403).text
|
||||
)
|
||||
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):
|
||||
|
@ -99,7 +99,7 @@ class TestAuthAPI:
|
|||
def test_login_then_legit_request(self, moulinette_webapi):
|
||||
self.login(moulinette_webapi)
|
||||
|
||||
assert "session.moulitest" in moulinette_webapi.cookies
|
||||
assert "moulitest" in moulinette_webapi.cookies
|
||||
|
||||
assert (
|
||||
moulinette_webapi.get("/test-auth/default", status=200).text
|
||||
|
@ -124,7 +124,7 @@ class TestAuthAPI:
|
|||
def test_login_other_profile(self, moulinette_webapi):
|
||||
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):
|
||||
self.login(moulinette_webapi)
|
||||
|
|
Loading…
Add table
Reference in a new issue