Skip to content

Commit

Permalink
[SDESK-7372] New Auth mechanism (superdesk#2729)
Browse files Browse the repository at this point in the history
* Move types into separate module

* Base auth system

* Basic auth rules

* Superdesk auth

* fix validation: Support ResourceModel inheritence

* Expose sync interface in resource service

* Allow custom strings in field validators

* fix tests: disable auth

* remove print statements

* fix get_endpoint_for_current_request with no request

* impove processing of intrinsic_auth_rule

* move more request processing to core

* fix cyclic import

* change Spawn Test Instance job name

* fix response handling tuples

* fix cursor types

* fix type issue with elastic cursor
  • Loading branch information
MarkLark86 authored Oct 16, 2024
1 parent c3f2923 commit 904fe88
Show file tree
Hide file tree
Showing 29 changed files with 1,060 additions and 509 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test-instance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: "Spawn Test Instance"
on: [push, pull_request]

jobs:
pytest:
test_instance:
runs-on: ubuntu-latest

strategy:
Expand Down
2 changes: 1 addition & 1 deletion superdesk/celery_app/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from kombu.serialization import register

from superdesk.core import json
from superdesk.core.web.types import WSGIApp
from superdesk.core.types import WSGIApp


CELERY_SERIALIZER_NAME = "context-aware/json"
Expand Down
3 changes: 2 additions & 1 deletion superdesk/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
# at https://www.sourcefabric.org/superdesk/license

from quart import json
from .app import get_app_config, get_current_app, get_current_async_app
from .app import get_app_config, get_current_app, get_current_async_app, get_current_auth


__all__ = [
"get_current_app",
"get_current_async_app",
"json",
"get_app_config",
"get_current_auth",
]
27 changes: 26 additions & 1 deletion superdesk/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from typing import Dict, List, Optional, Any, cast
import importlib

from .web import WSGIApp
from superdesk.core.types import WSGIApp
from .auth.user_auth import UserAuthProtocol


def get_app_config(key: str, default: Optional[Any] = None) -> Optional[Any]:
Expand Down Expand Up @@ -44,6 +45,8 @@ class SuperdeskAsyncApp:

resources: "Resources"

auth: UserAuthProtocol

def __init__(self, wsgi: WSGIApp):
self._running = False
self._imported_modules = {}
Expand All @@ -52,6 +55,7 @@ def __init__(self, wsgi: WSGIApp):
self.mongo = MongoResources(self)
self.elastic = ElasticResources(self)
self.resources = Resources(self)
self.auth = self.load_auth_module()
self._store_app()

@property
Expand All @@ -65,6 +69,23 @@ def get_module_list(self) -> List["Module"]:

return sorted(self._imported_modules.values(), key=lambda x: x.priority, reverse=True)

def load_auth_module(self) -> UserAuthProtocol:
auth_module_config = cast(
str, self.wsgi.config.get("ASYNC_AUTH_CLASS", "superdesk.core.auth.token_auth:TokenAuthorization")
)
try:
module_path, module_attribute = auth_module_config.split(":")
except ValueError as error:
raise RuntimeError(f"Invalid config ASYNC_AUTH_MODULE={auth_module_config}: {error}")

imported_module = importlib.import_module(module_path)
auth_class = getattr(imported_module, module_attribute)

if not issubclass(auth_class, UserAuthProtocol):
raise RuntimeError(f"Invalid config ASYNC_AUTH_MODULE={auth_module_config}, invalid auth type {auth_class}")

return auth_class()

def _load_modules(self, paths: List[str | tuple[str, dict]]):
for path in paths:
config: dict = {}
Expand Down Expand Up @@ -208,6 +229,10 @@ def get_current_async_app() -> SuperdeskAsyncApp:
raise RuntimeError("Superdesk app is not running")


def get_current_auth() -> UserAuthProtocol:
return get_current_async_app().auth


_global_app: Optional[SuperdeskAsyncApp] = None


Expand Down
Empty file added superdesk/core/auth/__init__.py
Empty file.
24 changes: 24 additions & 0 deletions superdesk/core/auth/rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typing import Any

from superdesk.core.types import Request
from superdesk.errors import SuperdeskApiError


async def login_required_auth_rule(request: Request) -> None:
if request.user is None:
raise SuperdeskApiError.unauthorizedError()

return None


async def endpoint_intrinsic_auth_rule(request: Request) -> Any | None:
methods = ["authorize", f"authorize_{request.method.lower()}"]
for method_name in methods:
intrinsic_auth = getattr(request.endpoint, method_name, None)

if intrinsic_auth:
response = await intrinsic_auth(request)
if response is not None:
return response

return None
88 changes: 88 additions & 0 deletions superdesk/core/auth/token_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from typing import Any, cast
from datetime import timedelta

from superdesk.core import get_app_config
from superdesk.core.types import Request
from superdesk import get_resource_service
from superdesk.errors import SuperdeskApiError
from superdesk.resource_fields import LAST_UPDATED, ID_FIELD
from superdesk.utc import utcnow

from .user_auth import UserAuthProtocol


class TokenAuthorization(UserAuthProtocol):
async def authenticate(self, request: Request):
token = request.get_header("Authorization")
new_session = True
if token:
token = token.strip()
if token.lower().startswith(("token", "bearer")):
token = token.split(" ")[1] if " " in token else ""
else:
token = request.storage.session.get("session_token")
new_session = False

if not token:
await self.stop_session(request)
raise SuperdeskApiError.unauthorizedError()

# Check provided token is valid
auth_service = get_resource_service("auth")
auth_token = auth_service.find_one(token=token, req=None)

if not auth_token:
await self.stop_session(request)
raise SuperdeskApiError.unauthorizedError()

user_service = get_resource_service("users")
user_id = str(auth_token["user"])
user = user_service.find_one(req=None, _id=user_id)

if not user:
await self.stop_session(request)
raise SuperdeskApiError.unauthorizedError()

if new_session:
await self.start_session(request, user, auth_token=auth_token)
else:
await self.continue_session(request, user)

async def start_session(self, request: Request, user: dict[str, Any], **kwargs) -> None:
auth_token: str | None = kwargs.pop("auth_token", None)
if not auth_token:
await self.stop_session(request)
raise SuperdeskApiError.unauthorizedError()

request.storage.session.set("session_token", auth_token)
await super().start_session(request, user, **kwargs)

async def continue_session(self, request: Request, user: dict[str, Any], **kwargs) -> None:
auth_token = request.storage.session.get("session_token")

if not auth_token:
await self.stop_session(request)
raise SuperdeskApiError.unauthorizedError()

user_service = get_resource_service("users")
request.storage.request.set("user", user)
request.storage.request.set("role", user_service.get_role(user))
request.storage.request.set("auth", auth_token)
request.storage.request.set("auth_value", auth_token["user"])

if request.method in ("POST", "PUT", "PATCH") or (request.method == "GET" and not request.get_url_arg("auto")):
now = utcnow()
auth_updated = False
session_update_seconds = cast(int, get_app_config("SESSION_UPDATE_SECONDS", 30))
if auth_token[LAST_UPDATED] + timedelta(seconds=session_update_seconds) < now:
auth_service = get_resource_service("auth")
auth_service.update_session({LAST_UPDATED: now})
auth_updated = True
if auth_updated or not request.storage.request.get("last_activity_at"):
user_service.system_update(user[ID_FIELD], {"last_activity_at": now, "_updated": now}, user)

await super().continue_session(request, user, **kwargs)

def get_current_user(self, request: Request) -> dict[str, Any] | None:
user = request.storage.request.get("user")
return user
44 changes: 44 additions & 0 deletions superdesk/core/auth/user_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from typing import Any, cast

from superdesk.errors import SuperdeskApiError
from superdesk.core.types import Request, AuthRule


class UserAuthProtocol:
async def authenticate(self, request: Request) -> Any | None:
raise SuperdeskApiError.unauthorizedError()

async def authorize(self, request: Request) -> Any | None:
endpoint_rules = request.endpoint.get_auth_rules()
if endpoint_rules is False:
# This is a public facing endpoint
# Meaning Authentication & Authorization is disabled
return None
elif isinstance(endpoint_rules, dict):
endpoint_rules = cast(list[AuthRule], endpoint_rules.get(request.method) or [])

from .rules import login_required_auth_rule, endpoint_intrinsic_auth_rule

default_rules: list[AuthRule] = [
login_required_auth_rule,
endpoint_intrinsic_auth_rule,
]

for rule in default_rules + (endpoint_rules or []):
response = await rule(request)
if response is not None:
return response

return None

async def start_session(self, request: Request, user: Any, **kwargs) -> None:
await self.continue_session(request, user, **kwargs)

async def continue_session(self, request: Request, user: Any, **kwargs) -> None:
request.user = user

async def stop_session(self, request: Request) -> None:
pass

def get_current_user(self, request: Request) -> Any | None:
raise NotImplementedError()
2 changes: 1 addition & 1 deletion superdesk/core/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from dataclasses import dataclass

from .config import ConfigModel
from .web import Endpoint, EndpointGroup
from superdesk.core.types import Endpoint, EndpointGroup


@dataclass
Expand Down
13 changes: 7 additions & 6 deletions superdesk/core/resources/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@

from motor.motor_asyncio import AsyncIOMotorCollection, AsyncIOMotorCursor

from .model import ResourceModel

ResourceModelType = TypeVar("ResourceModelType", bound="ResourceModel")

ResourceModelType = TypeVar("ResourceModelType", bound=ResourceModel)


class ResourceCursorAsync(Generic[ResourceModelType]):
Expand Down Expand Up @@ -63,7 +65,9 @@ def get_model_instance(self, data: Dict[str, Any]):
return self.data_class.from_dict(data)


class ElasticsearchResourceCursorAsync(ResourceCursorAsync):
class ElasticsearchResourceCursorAsync(ResourceCursorAsync[ResourceModelType], Generic[ResourceModelType]):
_index: int
hits: dict[str, Any]
no_hits = {"hits": {"total": 0, "hits": []}}

def __init__(self, data_class: Type[ResourceModelType], hits=None):
Expand Down Expand Up @@ -113,7 +117,7 @@ def extra(self, response: Dict[str, Any]):
response["_aggregations"] = self.hits["aggregations"]


class MongoResourceCursorAsync(ResourceCursorAsync):
class MongoResourceCursorAsync(ResourceCursorAsync[ResourceModelType], Generic[ResourceModelType]):
def __init__(
self,
data_class: Type[ResourceModelType],
Expand All @@ -137,6 +141,3 @@ async def next_raw(self) -> Optional[Dict[str, Any]]:

async def count(self):
return await self.collection.count_documents(self.lookup)


from .model import ResourceModel # noqa: E402
10 changes: 9 additions & 1 deletion superdesk/core/resources/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,15 @@ async def _run_async_validators_from_model_class(
if field_name_stack is None:
field_name_stack = []

annotations = get_annotations(model_instance.__class__)
model_class = model_instance.__class__

try:
annotations = {}
for base_class in reversed(model_class.__mro__):
if base_class != ResourceModel and issubclass(base_class, ResourceModel):
annotations.update(get_annotations(base_class))
except (TypeError, AttributeError):
annotations = get_annotations(model_class)

for field_name, annotation in annotations.items():
value = getattr(model_instance, field_name)
Expand Down
17 changes: 14 additions & 3 deletions superdesk/core/resources/resource_rest_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,20 @@

from superdesk.core import json
from superdesk.core.app import get_current_async_app
from superdesk.core.types import SearchRequest, SearchArgs, VersionParam
from superdesk.core.types import (
SearchRequest,
SearchArgs,
VersionParam,
AuthConfig,
HTTP_METHOD,
Request,
Response,
RestGetResponse,
)
from superdesk.errors import SuperdeskApiError
from superdesk.resource_fields import STATUS, STATUS_OK, ITEMS

from ..web.types import HTTP_METHOD, Request, Response, RestGetResponse
from ..web.rest_endpoints import RestEndpoints, ItemRequestViewArgs
from superdesk.core.web import RestEndpoints, ItemRequestViewArgs

from .model import ResourceConfig, ResourceModel
from .validators import convert_pydantic_validation_error_for_response
Expand Down Expand Up @@ -77,6 +85,8 @@ class RestEndpointConfig:
#: This will prepend this resources URL with the URL of the parent resource item
parent_links: list[RestParentLink] | None = None

auth: AuthConfig = None


def get_id_url_type(data_class: type[ResourceModel]) -> str:
"""Get the URL param type for the ID field for route registration"""
Expand Down Expand Up @@ -116,6 +126,7 @@ def __init__(
resource_methods=endpoint_config.resource_methods,
item_methods=endpoint_config.item_methods,
id_param_type=endpoint_config.id_param_type or get_id_url_type(resource_config.data_class),
auth=endpoint_config.auth,
)

def get_resource_url(self) -> str:
Expand Down
Loading

0 comments on commit 904fe88

Please sign in to comment.