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 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
]

View file

@ -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

View file

@ -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)

View file

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

View file

@ -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")

View file

@ -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")

View file

@ -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)