mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
Feature: Reverse proxy authentication (#455)
- Changes the auth settings to support more than two auth types - Added a proxy auth type with settings for user and optionally group headers - Added a global middleware `proxy_auth_middleware` to handle proxy auth (it does nothing if any other auth mode is set) - Added support for proxy auth to `get_auth_mode`, `login_required`, `api_login/out`, and `api_auth_check` - Added a backend check to make protect the API for settings when admin is required --------- Co-authored-by: Joshua Tag Howard <git@jthoward.dev> Co-authored-by: Alex <alex.bilbie1@gmail.com>
This commit is contained in:
co-authored by
Joshua Tag Howard
Alex
parent
475ae420e5
commit
0d7a12ca7c
@@ -0,0 +1,183 @@
|
||||
"""Unit tests for proxy auth middleware and admin access checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _as_response(result: Any):
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
resp, status = result
|
||||
resp.status_code = status
|
||||
return resp
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
class TestProxyAuthMiddleware:
|
||||
def test_skips_for_non_proxy_mode(self, main_module):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with main_module.app.test_request_context("/api/search"):
|
||||
result = main_module.proxy_auth_middleware()
|
||||
assert result is None
|
||||
assert "user_id" not in main_module.session
|
||||
|
||||
def test_skips_health_endpoint(self, main_module):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
|
||||
with main_module.app.test_request_context("/api/health"):
|
||||
result = main_module.proxy_auth_middleware()
|
||||
assert result is None
|
||||
|
||||
def test_allows_auth_check_without_header(self, main_module):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={"PROXY_AUTH_USER_HEADER": "X-Auth-User"},
|
||||
):
|
||||
with main_module.app.test_request_context("/api/auth/check"):
|
||||
result = main_module.proxy_auth_middleware()
|
||||
assert result is None
|
||||
assert "user_id" not in main_module.session
|
||||
|
||||
def test_sets_session_from_header(self, main_module):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={
|
||||
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
|
||||
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": False,
|
||||
},
|
||||
):
|
||||
with main_module.app.test_request_context(
|
||||
"/api/search",
|
||||
headers={"X-Auth-User": "proxyuser"},
|
||||
):
|
||||
result = main_module.proxy_auth_middleware()
|
||||
assert result is None
|
||||
assert main_module.session.get("user_id") == "proxyuser"
|
||||
assert main_module.session.get("is_admin") is True
|
||||
assert main_module.session.permanent is False
|
||||
|
||||
def test_returns_401_when_header_missing_on_protected_path(self, main_module):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={"PROXY_AUTH_USER_HEADER": "X-Auth-User"},
|
||||
):
|
||||
with main_module.app.test_request_context("/api/search"):
|
||||
resp = _as_response(main_module.proxy_auth_middleware())
|
||||
data = resp.get_json()
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert "Authentication required" in (data.get("error") or "")
|
||||
|
||||
def test_admin_group_membership(self, main_module):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={
|
||||
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
|
||||
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": True,
|
||||
"PROXY_AUTH_ADMIN_GROUP_HEADER": "X-Auth-Groups",
|
||||
"PROXY_AUTH_ADMIN_GROUP_NAME": "admins",
|
||||
},
|
||||
):
|
||||
with main_module.app.test_request_context(
|
||||
"/api/search",
|
||||
headers={
|
||||
"X-Auth-User": "adminuser",
|
||||
"X-Auth-Groups": "users,admins,devs",
|
||||
},
|
||||
):
|
||||
result = main_module.proxy_auth_middleware()
|
||||
assert result is None
|
||||
assert main_module.session.get("is_admin") is True
|
||||
|
||||
|
||||
class TestLoginRequiredDecorator:
|
||||
@pytest.fixture
|
||||
def view(self):
|
||||
def _view():
|
||||
return {"success": True}, 200
|
||||
|
||||
return _view
|
||||
|
||||
def test_allows_no_auth(self, main_module, view):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with main_module.app.test_request_context("/api/search"):
|
||||
decorated = main_module.login_required(view)
|
||||
resp = decorated()
|
||||
|
||||
assert resp[0]["success"] is True
|
||||
|
||||
def test_blocks_when_not_authenticated(self, main_module, view):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with main_module.app.test_request_context("/api/search"):
|
||||
decorated = main_module.login_required(view)
|
||||
resp = _as_response(decorated())
|
||||
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_allows_authenticated(self, main_module, view):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with main_module.app.test_request_context("/api/search"):
|
||||
main_module.session["user_id"] = "user"
|
||||
decorated = main_module.login_required(view)
|
||||
resp = decorated()
|
||||
|
||||
assert resp[0]["success"] is True
|
||||
|
||||
def test_builtin_mode_does_not_apply_cwa_admin_setting(self, main_module, view):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={"CWA_RESTRICT_SETTINGS_TO_ADMIN": True},
|
||||
):
|
||||
with main_module.app.test_request_context("/api/settings/general"):
|
||||
main_module.session["user_id"] = "admin"
|
||||
decorated = main_module.login_required(view)
|
||||
resp = decorated()
|
||||
|
||||
assert resp[0]["success"] is True
|
||||
|
||||
def test_proxy_admin_restriction_blocks_non_admin(self, main_module, view):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": True},
|
||||
):
|
||||
with main_module.app.test_request_context("/api/settings/general"):
|
||||
main_module.session["user_id"] = "user"
|
||||
main_module.session["is_admin"] = False
|
||||
decorated = main_module.login_required(view)
|
||||
resp = _as_response(decorated())
|
||||
data = resp.get_json()
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "Admin access required" in (data.get("error") or "")
|
||||
|
||||
def test_cwa_admin_restriction_blocks_non_admin(self, main_module, view):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="cwa"):
|
||||
with patch(
|
||||
"shelfmark.core.settings_registry.load_config_file",
|
||||
return_value={"CWA_RESTRICT_SETTINGS_TO_ADMIN": True},
|
||||
):
|
||||
with main_module.app.test_request_context("/api/settings/general"):
|
||||
main_module.session["user_id"] = "user"
|
||||
main_module.session["is_admin"] = False
|
||||
decorated = main_module.login_required(view)
|
||||
resp = _as_response(decorated())
|
||||
|
||||
assert resp.status_code == 403
|
||||
Reference in New Issue
Block a user