diff --git a/Makefile b/Makefile index 658f265ab..e03353674 100644 --- a/Makefile +++ b/Makefile @@ -142,7 +142,13 @@ run-dev-otel run-bash-otel: | start-dev-otel run-dev-attach-otel # Build standalone development container (not usable inside the docker container) build-dev: - docker build --file=dev/Dockerfile.dev . --tag=openslides-backend-dev + docker build --file=dev/Dockerfile.dev . --target development --tag=openslides-backend-dev + +build-dev-fullstack: + DOCKER_BUILDKIT=1 docker build --file=dev/Dockerfile.dev . --target development-fullstack \ + --build-context pipauth=../openslides-auth-service/libraries/pip-auth \ + --build-context datastore=../openslides-datastore-service \ + --tag=openslides-backend-dev-fullstack rebuild-dev: - docker build --file=dev/Dockerfile.dev . --tag=openslides-backend-dev --no-cache + docker build --file=dev/Dockerfile.dev . --target development --tag=openslides-backend-dev --no-cache diff --git a/dev/Dockerfile.dev b/dev/Dockerfile.dev index 47a45a06f..3cc613229 100644 --- a/dev/Dockerfile.dev +++ b/dev/Dockerfile.dev @@ -1,13 +1,9 @@ -FROM python:3.10.13-slim-bookworm +FROM python:3.10.13-slim-bookworm as base RUN apt-get update && apt-get install --yes make git curl ncat vim bash-completion mime-support gcc libpq-dev libmagic1 WORKDIR /app -COPY requirements/ requirements/ -ARG REQUIREMENTS_FILE=requirements_development.txt -RUN . requirements/export_service_commits.sh && pip install --no-cache-dir --requirement requirements/$REQUIREMENTS_FILE - COPY dev/.bashrc . COPY dev/cleanup.sh . @@ -42,3 +38,17 @@ ENV DEFAULT_FROM_EMAIL noreply@example.com STOPSIGNAL SIGKILL ENTRYPOINT ["./entrypoint.sh"] CMD exec python -m debugpy --listen 0.0.0.0:5678 openslides_backend + +FROM base as development + +COPY requirements/ requirements/ +ARG REQUIREMENTS_FILE=requirements_development.txt +RUN . requirements/export_service_commits.sh && pip install --no-cache-dir --requirement requirements/$REQUIREMENTS_FILE + +FROM base as development-fullstack + +COPY --from=pipauth / /pip-auth +COPY --from=datastore / /openslides-datastore-service +COPY requirements/ requirements/ +ARG REQUIREMENTS_FILE=requirements_development_fullstack.txt +RUN pip install --no-cache-dir --requirement requirements/$REQUIREMENTS_FILE diff --git a/docs/Actions-Overview.md b/docs/Actions-Overview.md index 428e22626..382a6a729 100644 --- a/docs/Actions-Overview.md +++ b/docs/Actions-Overview.md @@ -231,7 +231,6 @@ A more general format description see in [Action-Service](https://github.com/Ope - [user.toggle_presence_by_number](actions/user.toggle_presence_by_number.md) - [user.update](actions/user.update.md) - [user.update_self](actions/user.update_self.md) -- [user.save_saml_account](actions/user.save_saml_account.md) - [meeting_user.create](actions/meeting_user.create.md) - [meeting_user.update](actions/meeting_user.update.md) - [meeting_user.delete](actions/meeting_user.delete.md) diff --git a/openslides_backend/action/actions/organization/update.py b/openslides_backend/action/actions/organization/update.py index 31eb69577..93ae972a5 100644 --- a/openslides_backend/action/actions/organization/update.py +++ b/openslides_backend/action/actions/organization/update.py @@ -13,7 +13,6 @@ from ...mixins.send_email_mixin import EmailCheckMixin, EmailSenderCheckMixin from ...util.default_schema import DefaultSchema from ...util.register import register_action -from ..user.save_saml_account import allowed_user_fields from ..user.update import UserUpdate @@ -48,36 +47,12 @@ class OrganizationUpdate( "limit_of_meetings", "limit_of_users", "url", - "saml_enabled", - "saml_login_button_text", - "saml_attr_mapping", - "saml_metadata_idp", - "saml_metadata_sp", - "saml_private_key", ) model = Organization() - saml_props = { - field: {**optional_str_schema, "max_length": 256} - for field in allowed_user_fields - } - saml_props["meeting"] = { - "type": ["object", "null"], - "properties": { - field: {**optional_str_schema, "max_length": 256} - for field in ("external_id", "external_group_id") - }, - "additionalProperties": False, - } schema = DefaultSchema(Organization()).get_update_schema( optional_properties=group_A_fields + group_B_fields, additional_optional_fields={ - "saml_attr_mapping": { - "type": ["object", "null"], - "properties": saml_props, - "required": ["saml_id"], - "additionalProperties": False, - }, }, ) check_email_field = "users_email_replyto" diff --git a/openslides_backend/action/actions/user/__init__.py b/openslides_backend/action/actions/user/__init__.py index 19443902c..6e49b5178 100644 --- a/openslides_backend/action/actions/user/__init__.py +++ b/openslides_backend/action/actions/user/__init__.py @@ -4,17 +4,10 @@ assign_meetings, create, delete, - forget_password, - forget_password_confirm, - generate_new_password, merge_together, participant_import, participant_json_upload, - reset_password_to_default, - save_saml_account, send_invitation_email, - set_password, - set_password_self, set_present, toggle_presence_by_number, update, diff --git a/openslides_backend/action/actions/user/create.py b/openslides_backend/action/actions/user/create.py index 090870035..49c5edbc2 100644 --- a/openslides_backend/action/actions/user/create.py +++ b/openslides_backend/action/actions/user/create.py @@ -16,7 +16,6 @@ from ...util.typing import ActionResultElement from ..meeting_user.mixin import CheckLockOutPermissionMixin from .create_update_permissions_mixin import CreateUpdatePermissionsMixin -from .password_mixins import SetPasswordMixin from .user_mixins import LimitOfUserMixin, UserMixin, UsernameMixin, check_gender_helper @@ -25,7 +24,6 @@ class UserCreate( EmailCheckMixin, CreateAction, CreateUpdatePermissionsMixin, - SetPasswordMixin, LimitOfUserMixin, UsernameMixin, CheckLockOutPermissionMixin, diff --git a/openslides_backend/action/actions/user/forget_password.py b/openslides_backend/action/actions/user/forget_password.py deleted file mode 100644 index 40de46e00..000000000 --- a/openslides_backend/action/actions/user/forget_password.py +++ /dev/null @@ -1,119 +0,0 @@ -from collections import defaultdict -from time import time -from typing import Any -from urllib.parse import quote - -from openslides_backend.shared.util import ONE_ORGANIZATION_FQID - -from ....i18n.translator import translate as _ -from ....models.models import User -from ....shared.exceptions import ActionException -from ....shared.filters import FilterOperator -from ...generics.update import UpdateAction -from ...mixins.send_email_mixin import EmailSettings, EmailUtils -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ...util.typing import ActionData - - -class format_dict(defaultdict): - def __missing__(self, key: str) -> str: - return f"'{key}'" - - -@register_action("user.forget_password") -class UserForgetPassword(UpdateAction): - """ - Action to send forget password mail(s). - """ - - model = User() - schema = DefaultSchema(User()).get_default_schema( - title="user forget password schema", - additional_required_fields={"email": {"type": "string"}}, - ) - skip_archived_meeting_check = True - - def get_updated_instances(self, action_data: ActionData) -> ActionData: - self.PW_FORGET_EMAIL_TEMPLATE = _( - """You are receiving this email because you have requested a new password for your OpenSlides account. - -Please open the following link and choose a new password: -{url}/login/forget-password-confirm?user_id={user_id}&token={token} - -The link will be valid for 10 minutes.""" - ) - self.PW_FORGET_EMAIL_SUBJECT = _("Reset your OpenSlides password") - for instance in action_data: - email = instance.pop("email") - - # check if email adress is valid - if not EmailUtils.check_email(EmailSettings.default_from_email): - raise ActionException( - "The server was configured improperly. Please contact your administrator." - ) - if not EmailUtils.check_email(email): - raise ActionException(f"'{email}' is not a valid email adress.") - - # search for users with email - filter_ = FilterOperator("email", "=", email) - results = self.datastore.filter( - self.model.collection, filter_, ["id", "username", "saml_id"] - ) - - organization = self.datastore.get( - ONE_ORGANIZATION_FQID, ["url"], lock_result=False - ) - url = organization.get("url", "") - - # try to send the mails. - try: - with EmailUtils.get_mail_connection() as mail_client: - for user in results.values(): - if user.get("saml_id"): - raise ActionException( - f"user {user['saml_id']} is a Single Sign On user and has no local OpenSlides password." - ) - username = user["username"] - ok, errors = EmailUtils.send_email_safe( - mail_client, - self.logger, - EmailSettings.default_from_email, - email, - self.PW_FORGET_EMAIL_SUBJECT + f": {username}", - self.get_email_body( - user["id"], - self.get_token(user["id"], email), - user["username"], - url, - ), - html=False, - ) - if ok: - yield {"id": user["id"], "last_email_sent": round(time())} - except ActionException as e: - self.logger.error(f"send mail action exception: {str(e)}") - raise - except Exception as e: - self.logger.error(f"General send mail exception: {str(e)}") - raise ActionException( - "The server was configured improperly. Please contact your administrator." - ) - - def get_token(self, user_id: int, email: str) -> str: - return quote(self.auth.create_authorization_token(user_id, email)) - - def get_email_body(self, user_id: int, token: str, username: str, url: str) -> str: - body_format = format_dict( - None, - { - "user_id": user_id, - "token": token, - "username": username, - "url": url, - }, - ) - return self.PW_FORGET_EMAIL_TEMPLATE.format_map(body_format) - - def check_permissions(self, instance: dict[str, Any]) -> None: - pass diff --git a/openslides_backend/action/actions/user/forget_password_confirm.py b/openslides_backend/action/actions/user/forget_password_confirm.py deleted file mode 100644 index d3badc837..000000000 --- a/openslides_backend/action/actions/user/forget_password_confirm.py +++ /dev/null @@ -1,63 +0,0 @@ -from collections.abc import Callable -from typing import Any -from urllib.parse import unquote - -from authlib.exceptions import InvalidCredentialsException - -from openslides_backend.action.util.typing import ActionData - -from ....models.models import User -from ....shared.exceptions import ActionException -from ....shared.schema import required_id_schema -from ...generics.update import UpdateAction -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from .password_mixins import ClearSessionsMixin - - -@register_action("user.forget_password_confirm") -class UserForgetPasswordConfirm(UpdateAction, ClearSessionsMixin): - """ - Action to set a forgotten password. - """ - - model = User() - schema = DefaultSchema(User()).get_default_schema( - title="user forget password confirm schema", - additional_required_fields={ - "new_password": {"type": "string"}, - "user_id": required_id_schema, - "authorization_token": {"type": "string"}, - }, - ) - skip_archived_meeting_check = True - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - user_id = instance.pop("user_id") - user = self.datastore.get(f"user/{user_id}", ["saml_id"], lock_result=False) - new_password = instance.pop("new_password") - if user.get("saml_id"): - raise ActionException( - f"user {user['saml_id']} is a Single Sign On user and has no local OpenSlides password." - ) - token = instance.pop("authorization_token") - self.check_token(user_id, token) - instance["id"] = user_id - instance["password"] = self.auth.hash(new_password) - return instance - - def check_token(self, user_id: int, token: str) -> None: - try: - if not self.auth.verify_authorization_token(user_id, unquote(token)): - raise ActionException("Failed to verify token.") - except InvalidCredentialsException: - raise ActionException("Failed to verify token.") - - def check_permissions(self, instance: dict[str, Any]) -> None: - pass - - def get_on_success(self, action_data: ActionData) -> Callable[[], None] | None: - def on_success() -> None: - self.auth.clear_all_sessions() - - return on_success diff --git a/openslides_backend/action/actions/user/generate_new_password.py b/openslides_backend/action/actions/user/generate_new_password.py deleted file mode 100644 index 5c2117875..000000000 --- a/openslides_backend/action/actions/user/generate_new_password.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Any - -from ....action.generics.update import UpdateAction -from ....action.mixins.archived_meeting_check_mixin import CheckForArchivedMeetingMixin -from ....models.models import User -from ....permissions.management_levels import OrganizationManagementLevel -from ....permissions.permissions import Permissions -from ....shared.mixins.user_scope_mixin import UserScopeMixin -from ...util.crypto import get_random_password -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from .password_mixins import ClearSessionsMixin, SetPasswordMixin - - -@register_action("user.generate_new_password") -class UserGenerateNewPassword( - SetPasswordMixin, - CheckForArchivedMeetingMixin, - UserScopeMixin, - ClearSessionsMixin, - UpdateAction, -): - model = User() - schema = DefaultSchema(User()).get_update_schema() - permission = OrganizationManagementLevel.CAN_MANAGE_USERS - - def check_permissions(self, instance: dict[str, Any]) -> None: - self.check_permissions_for_scope( - instance["id"], meeting_permission=Permissions.User.CAN_UPDATE - ) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - """ - Generates new password and call the super code. - """ - instance["password"] = get_random_password() - instance["set_as_default"] = True - self.set_password(instance) - return instance diff --git a/openslides_backend/action/actions/user/password_mixins.py b/openslides_backend/action/actions/user/password_mixins.py deleted file mode 100644 index 55275463e..000000000 --- a/openslides_backend/action/actions/user/password_mixins.py +++ /dev/null @@ -1,51 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from openslides_backend.shared.patterns import fqid_from_collection_and_id - -from ....shared.exceptions import ActionException -from ...action import Action -from ...util.typing import ActionData - - -class SetPasswordMixin(Action): - def reset_password(self, instance: dict[str, Any]) -> None: - instance["password"] = instance["default_password"] - self.set_password(instance) - - def set_password(self, instance: dict[str, Any]) -> None: - """ - Hashes the password given in the instance (which is assumed to be plain text) and sets it as - the default password if `set_as_default` is True in the instance. - """ - if "meta_new" not in instance: - user = self.datastore.get( - fqid_from_collection_and_id("user", instance["id"]), - ["saml_id"], - lock_result=False, - ) - else: - user = instance - if user.get("saml_id"): - raise ActionException( - f"user {user['saml_id']} is a Single Sign On user and has no local OpenSlides password." - ) - - password = instance.pop("password") - instance["password"] = self.auth.hash(password) - if instance.pop("set_as_default", False): - instance["default_password"] = password - - -class ClearSessionsMixin(Action): - """Adds an on_success method to the action that clears all sessions.""" - - def get_on_success(self, action_data: ActionData) -> Callable[[], None] | None: - def on_success() -> None: - self.auth.clear_all_sessions() - - # only clear session if the user changed his own password - if any(instance["id"] == self.user_id for instance in action_data): - return on_success - else: - return None diff --git a/openslides_backend/action/actions/user/reset_password_to_default.py b/openslides_backend/action/actions/user/reset_password_to_default.py deleted file mode 100644 index 04315ab25..000000000 --- a/openslides_backend/action/actions/user/reset_password_to_default.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Any - -from ....action.mixins.archived_meeting_check_mixin import CheckForArchivedMeetingMixin -from ....models.models import User -from ....permissions.management_levels import OrganizationManagementLevel -from ....permissions.permissions import Permissions -from ....shared.exceptions import ActionException -from ....shared.mixins.user_scope_mixin import UserScopeMixin -from ....shared.patterns import fqid_from_collection_and_id -from ...generics.update import UpdateAction -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from .password_mixins import ClearSessionsMixin - - -class UserResetPasswordToDefaultMixin( - UpdateAction, CheckForArchivedMeetingMixin, ClearSessionsMixin -): - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - """ - Gets the default_password and reset password. - """ - instance = super().update_instance(instance) - user = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["id"]), - ["default_password", "saml_id"], - lock_result=False, - ) - if user.get("saml_id"): - raise ActionException( - f"user {user['saml_id']} is a Single Sign On user and has no local OpenSlides password." - ) - default_password = self.auth.hash(str(user.get("default_password"))) - instance["password"] = default_password - return instance - - -@register_action("user.reset_password_to_default") -class UserResetPasswordToDefaultAction( - UserResetPasswordToDefaultMixin, - UserScopeMixin, -): - """ - Action to reset a password to default of a user. - """ - - model = User() - schema = DefaultSchema(User()).get_update_schema() - permission = OrganizationManagementLevel.CAN_MANAGE_USERS - - def check_permissions(self, instance: dict[str, Any]) -> None: - self.check_permissions_for_scope( - instance["id"], meeting_permission=Permissions.User.CAN_UPDATE - ) diff --git a/openslides_backend/action/actions/user/save_saml_account.py b/openslides_backend/action/actions/user/save_saml_account.py deleted file mode 100644 index c41156b20..000000000 --- a/openslides_backend/action/actions/user/save_saml_account.py +++ /dev/null @@ -1,226 +0,0 @@ -from collections.abc import Iterable -from typing import Any, cast - -import fastjsonschema - -from openslides_backend.shared.util import ONE_ORGANIZATION_FQID - -from ....models.models import User -from ....shared.exceptions import ActionException -from ....shared.filters import And, FilterOperator -from ....shared.interfaces.event import Event -from ....shared.schema import schema_version -from ....shared.typing import Schema -from ...mixins.meeting_user_helper import get_meeting_user -from ...mixins.send_email_mixin import EmailCheckMixin -from ...mixins.singular_action_mixin import SingularActionMixin -from ...util.action_type import ActionType -from ...util.register import register_action -from ...util.typing import ActionData, ActionResultElement -from .create import UserCreate -from .update import UserUpdate -from .user_mixins import UsernameMixin - -allowed_user_fields = [ - "saml_id", - "title", - "first_name", - "last_name", - "email", - "gender", - "pronoun", - "is_active", - "is_physical_person", -] - - -@register_action("user.save_saml_account", action_type=ActionType.STACK_INTERNAL) -class UserSaveSamlAccount( - EmailCheckMixin, - UsernameMixin, - SingularActionMixin, -): - """ - Internal action to save (create or update) a saml account. - It should be called from the auth service. - """ - - user: dict[str, Any] = {} - saml_attr_mapping: dict[str, str] - check_email_field = "email" - model = User() - schema: Schema = {} - skip_archived_meeting_check = True - - def validate_instance(self, instance: dict[str, Any]) -> None: - organization = self.datastore.get( - ONE_ORGANIZATION_FQID, - ["saml_enabled", "saml_attr_mapping"], - lock_result=False, - ) - if not organization.get("saml_enabled"): - raise ActionException( - "SingleSignOn is not enabled in OpenSlides configuration" - ) - self.saml_attr_mapping = organization.get("saml_attr_mapping", {}) - if not self.saml_attr_mapping or not isinstance(self.saml_attr_mapping, dict): - raise ActionException( - "SingleSignOn field attributes are not configured in OpenSlides" - ) - self.schema = { - "$schema": schema_version, - "title": "create saml account schema", - "type": "object", - "properties": { - payload_field: { - "oneOf": [ - (type_def := self.model.get_field(model_field).get_schema()), - { - "type": "array", - "items": type_def, - "minItems": 1 if model_field == "saml_id" else 0, - }, - ] - } - for model_field, payload_field in self.saml_attr_mapping.items() - if model_field in allowed_user_fields - }, - "required": [self.saml_attr_mapping["saml_id"]], - "additionalProperties": True, - } - try: - fastjsonschema.validate(self.schema, instance) - except fastjsonschema.JsonSchemaException as exception: - raise ActionException(exception.message) - - def validate_fields(self, instance_old: dict[str, Any]) -> dict[str, Any]: - """ - Transforms the payload fields into model fields, removes the possible array-wrapped format - """ - instance: dict[str, Any] = dict() - for model_field, payload_field in self.saml_attr_mapping.items(): - if ( - isinstance(payload_field, str) - and payload_field in instance_old - and model_field in allowed_user_fields - ): - value = ( - tx[0] - if isinstance((tx := instance_old[payload_field]), list) and len(tx) - else tx - ) - if value not in (None, []): - instance[model_field] = value - - return super().validate_fields(instance) - - def prepare_action_data(self, action_data: ActionData) -> ActionData: - """Necessary to prevent id reservation in CreateAction's prepare_action_data""" - return action_data - - def check_permissions(self, instance: dict[str, Any]) -> None: - pass - - def base_update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - meeting_id, group_id = self.check_for_group_add() - users = self.datastore.filter( - "user", - FilterOperator("saml_id", "=", instance["saml_id"]), - ["id", *allowed_user_fields], - ) - if len(users) == 1: - self.user = next(iter(users.values())) - instance["id"] = (user_id := cast(int, self.user["id"])) - if meeting_id and group_id: - meeting_user = get_meeting_user( - self.datastore, meeting_id, user_id, ["id", "group_ids"] - ) - if meeting_user: - old_group_ids = meeting_user["group_ids"] - if group_id not in old_group_ids: - instance["meeting_id"] = meeting_id - instance["group_ids"] = old_group_ids + [group_id] - else: - instance["meeting_id"] = meeting_id - instance["group_ids"] = [group_id] - - instance = { - k: v for k, v in instance.items() if k == "id" or v != self.user.get(k) - } - if len(instance) > 1: - self.execute_other_action(UserUpdate, [instance]) - elif len(users) == 0: - instance = self.set_defaults(instance) - if group_id: - instance["meeting_id"] = meeting_id - instance["group_ids"] = [group_id] - self.execute_other_action(UserCreate, [instance]) - else: - ActionException( - f"More than one existing user found in database with saml_id {instance['saml_id']}" - ) - return instance - - def create_events(self, instance: dict[str, Any]) -> Iterable[Event]: - """ - delegated to execute_other_actions - """ - return [] - - def create_action_result_element( - self, instance: dict[str, Any] - ) -> ActionResultElement | None: - return {"user_id": instance["id"]} - - def set_defaults(self, instance: dict[str, Any]) -> dict[str, Any]: - if "is_active" not in instance: - instance["is_active"] = True - if "is_physical_person" not in instance: - instance["is_physical_person"] = True - instance["can_change_own_password"] = False - instance["username"] = self.generate_usernames([instance.get("saml_id", "")])[0] - return instance - - def check_for_group_add(self) -> tuple[int, int] | tuple[None, None]: - NoneResult = (None, None) - if not ( - meeting_info := cast(dict, self.saml_attr_mapping.get("meeting")) - ) or not (external_id := meeting_info.get("external_id")): - return NoneResult - - meetings = self.datastore.filter( - collection="meeting", - filter=FilterOperator("external_id", "=", external_id), - mapped_fields=["id", "default_group_id"], - ) - if len(meetings) == 1: - meeting = next(iter(meetings.values())) - group_id = meeting["default_group_id"] - else: - self.logger.warning( - f"save_saml_account found {len(meetings)} meetings with external_id '{external_id}'" - ) - return NoneResult - if external_group_id := meeting_info.get("external_group_id"): - groups = self.datastore.filter( - collection="group", - filter=And( - [ - FilterOperator("external_id", "=", external_group_id), - FilterOperator("meeting_id", "=", meeting.get("id")), - ] - ), - mapped_fields=["id"], - ) - if len(groups) == 1: - group_id = next(iter(groups.keys())) - else: - self.logger.warning( - f"save_saml_account found no group in meeting '{external_id}' for '{external_group_id}', but use default_group of meeting" - ) - if not group_id: - self.logger.warning( - f"save_saml_account found no group in meeting '{external_id}' for '{external_group_id}'" - ) - return NoneResult - return meeting.get("id"), group_id diff --git a/openslides_backend/action/actions/user/set_password.py b/openslides_backend/action/actions/user/set_password.py deleted file mode 100644 index 6b856c4d6..000000000 --- a/openslides_backend/action/actions/user/set_password.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Any - -from ....action.generics.update import UpdateAction -from ....action.mixins.archived_meeting_check_mixin import CheckForArchivedMeetingMixin -from ....models.models import User -from ....permissions.management_levels import OrganizationManagementLevel -from ....permissions.permissions import Permissions -from ....shared.mixins.user_scope_mixin import UserScopeMixin -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from .password_mixins import ClearSessionsMixin, SetPasswordMixin - - -@register_action("user.set_password") -class UserSetPasswordAction( - SetPasswordMixin, - UserScopeMixin, - CheckForArchivedMeetingMixin, - ClearSessionsMixin, - UpdateAction, -): - """ - Action to set the password and default_pasword. - """ - - model = User() - schema = DefaultSchema(User()).get_update_schema( - required_properties=["password"], - additional_optional_fields={"set_as_default": {"type": "boolean"}}, - ) - history_information = "Password changed" - permission = OrganizationManagementLevel.CAN_MANAGE_USERS - - def check_permissions(self, instance: dict[str, Any]) -> None: - self.check_permissions_for_scope( - instance["id"], meeting_permission=Permissions.User.CAN_UPDATE - ) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - self.set_password(instance) - return instance diff --git a/openslides_backend/action/actions/user/set_password_self.py b/openslides_backend/action/actions/user/set_password_self.py deleted file mode 100644 index fe7732730..000000000 --- a/openslides_backend/action/actions/user/set_password_self.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Any - -from ....action.mixins.archived_meeting_check_mixin import CheckForArchivedMeetingMixin -from ....models.models import User -from ....shared.exceptions import ActionException, PermissionDenied -from ....shared.patterns import fqid_from_collection_and_id -from ...generics.update import UpdateAction -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from .password_mixins import ClearSessionsMixin - - -@register_action("user.set_password_self") -class UserSetPasswordSelf( - UpdateAction, CheckForArchivedMeetingMixin, ClearSessionsMixin -): - """ - Action to update the own password. - """ - - model = User() - schema = DefaultSchema(User()).get_default_schema( - additional_required_fields={ - "old_password": {"type": "string", "minLength": 1}, - "new_password": {"type": "string", "minLength": 1}, - } - ) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - old_pw = instance.pop("old_password") - new_pw = instance.pop("new_password") - - db_instance = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, self.user_id), - ["password", "saml_id"], - lock_result=False, - ) - if db_instance.get("saml_id"): - raise ActionException( - f"user {db_instance['saml_id']} is a Single Sign On user and has no local OpenSlides password." - ) - if not self.auth.is_equal(old_pw, db_instance["password"]): - raise ActionException("Wrong password") - - instance["password"] = self.auth.hash(new_pw) - return instance - - def check_permissions(self, instance: dict[str, Any]) -> None: - self.assert_not_anonymous() - instance["id"] = self.user_id - user = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, self.user_id), - ["can_change_own_password"], - lock_result=False, - ) - if not user.get("can_change_own_password"): - raise PermissionDenied("You cannot change your password.") diff --git a/openslides_backend/http/views/action_view.py b/openslides_backend/http/views/action_view.py index ac9de5955..bb2a56838 100644 --- a/openslides_backend/http/views/action_view.py +++ b/openslides_backend/http/views/action_view.py @@ -1,7 +1,9 @@ import binascii +import json from base64 import b64decode from pathlib import Path +from os_authlib.message_bus import MessageBus from ...action.action_handler import ActionHandler from ...action.action_worker import handle_action_in_worker_thread from ...i18n.translator import Translator @@ -27,6 +29,10 @@ class ActionView(BaseView): ActionHandler after retrieving request user id. """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.message_bus = MessageBus() + @route(["handle_request", "handle_separately"]) def action_route(self, request: Request) -> RouteResponse: self.logger.debug("Start dispatching action request.") @@ -84,6 +90,33 @@ def health_route(self, request: Request) -> RouteResponse: def info_route(self, request: Request) -> RouteResponse: return {"healthinfo": {"actions": dict(ActionHandler.get_health_info())}}, None + @route("logout", method="POST", json=False) + def backchannel_logout(self, request: Request) -> RouteResponse: + # topic '', field 'sessionId', value sessionId + self.logger.debug("Received logout request") + try: + logout_token = request.form.get("logout_token") + if not logout_token: + self.logger.error("Missing logout_token") + raise ServerError("Missing logout_token") + + # Verify and decode the logout token + decoded_token = self.services.authentication().auth_handler.verify_logout_token(logout_token) + if decoded_token is None: + return AuthenticationException("Invalid logout token") + + # Extract the session ID (sid) from the token + session_id = decoded_token.get("sid") + if not session_id: + return AuthenticationException("Missing session ID (sid) in logout token") + + self.logger.debug(f"Session ID to terminate: {session_id}") + self.message_bus.redis.xadd("logout", {"sessionId": session_id}) + + return { "success": True }, None + except json.JSONDecodeError: + return ServerError("Invalid JSON payload", status=400) + @route("version", method="GET", json=False) def version_route(self, _: Request) -> RouteResponse: with open(VERSION_PATH) as file: diff --git a/openslides_backend/http/views/base_view.py b/openslides_backend/http/views/base_view.py index 9948d1656..cfca9ff2d 100644 --- a/openslides_backend/http/views/base_view.py +++ b/openslides_backend/http/views/base_view.py @@ -4,7 +4,7 @@ from re import Pattern from typing import Any, Optional -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME from werkzeug.exceptions import BadRequest as WerkzeugBadRequest from ...shared.exceptions import View400Exception @@ -109,14 +109,18 @@ def dispatch(self, request: Request) -> RouteResponse: if route_options["json"]: # Check mimetype and parse JSON body. The result is cached in request.json if not request.is_json: + self.logger.debug(f"Wrong media type {request.content_type}. Use 'Content-Type: application/json' instead.") raise View400Exception( "Wrong media type. Use 'Content-Type: application/json' instead." ) try: + self.logger.debug(f"Unpacking JSON.") request_body = request.get_json() except WerkzeugBadRequest as exception: + self.logger.debug(f"Request contains invalid JSON.") raise View400Exception(exception.description) self.logger.debug(f"Request contains JSON: {request_body}.") + self.logger.debug(f"Executing handler.") return func(request) raise NotFound() diff --git a/openslides_backend/presenter/presenter.py b/openslides_backend/presenter/presenter.py index bb4bd2864..8d6ff04d8 100644 --- a/openslides_backend/presenter/presenter.py +++ b/openslides_backend/presenter/presenter.py @@ -1,7 +1,7 @@ from collections.abc import Callable import fastjsonschema -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME from fastjsonschema import JsonSchemaException from ..http.request import Request diff --git a/openslides_backend/services/auth/adapter.py b/openslides_backend/services/auth/adapter.py index c95dcd598..a1198aff3 100644 --- a/openslides_backend/services/auth/adapter.py +++ b/openslides_backend/services/auth/adapter.py @@ -1,6 +1,6 @@ from urllib import parse -from authlib import ( +from os_authlib import ( ANONYMOUS_USER, AUTHORIZATION_HEADER, AuthenticateException, @@ -8,11 +8,10 @@ AuthorizationException, InvalidCredentialsException, ) - +from .interface import AuthenticationService +from ..shared.authenticated_service import AuthenticatedService from ...shared.exceptions import AuthenticationException from ...shared.interfaces.logging import LoggingModule -from ..shared.authenticated_service import AuthenticatedService -from .interface import AuthenticationService class AuthenticationHTTPAdapter(AuthenticationService, AuthenticatedService): @@ -34,9 +33,7 @@ def authenticate(self) -> tuple[int, str | None]: f"Start request to authentication service with the following data: access_token: {self.access_token}, cookie: {self.refresh_id}" ) try: - return self.auth_handler.authenticate( - self.access_token, parse.unquote(self.refresh_id) - ) + return self.auth_handler.authenticate(self.access_token) except (AuthenticateException, InvalidCredentialsException) as e: self.logger.debug(f"Error in auth service: {e.message}") raise AuthenticationException(e.message) @@ -50,13 +47,6 @@ def is_equal(self, toHash: str, toCompare: str) -> bool: def is_anonymous(self, user_id: int) -> bool: return user_id == ANONYMOUS_USER - def create_authorization_token(self, user_id: int, email: str) -> str: - try: - response = self.auth_handler.create_authorization_token(user_id, email) - except AuthenticateException as e: - raise AuthenticationException(e.message) - return response.headers.get(AUTHORIZATION_HEADER, "") - def verify_authorization_token(self, user_id: int, token: str) -> bool: try: found_user_id, _ = self.auth_handler.verify_authorization_token(token) diff --git a/openslides_backend/services/auth/interface.py b/openslides_backend/services/auth/interface.py index df16cedd2..eba3404d3 100644 --- a/openslides_backend/services/auth/interface.py +++ b/openslides_backend/services/auth/interface.py @@ -1,6 +1,6 @@ from typing import Any, Protocol -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME # noqa +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME # noqa from ..shared.authenticated_service import AuthenticatedServiceInterface diff --git a/openslides_backend/services/shared/authenticated_service.py b/openslides_backend/services/shared/authenticated_service.py index 7534f689a..3d225e001 100644 --- a/openslides_backend/services/shared/authenticated_service.py +++ b/openslides_backend/services/shared/authenticated_service.py @@ -1,7 +1,7 @@ from abc import abstractmethod from typing import Protocol -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME class AuthenticatedServiceInterface(Protocol): diff --git a/requirements/export_service_commits.sh b/requirements/export_service_commits.sh index 8e62af532..3cc76e86e 100755 --- a/requirements/export_service_commits.sh +++ b/requirements/export_service_commits.sh @@ -1,3 +1,3 @@ #!/bin/bash export DATASTORE_COMMIT_HASH=46ee759b96507ed62c7dfe95972cbae4a826b2e2 -export AUTH_COMMIT_HASH=c12b89bd2bcefc6868fcc5cd3d57c23d04bd47b0 +export AUTH_COMMIT_HASH=01940817cf3435a7c20b4bcca076317f15f7fe23 diff --git a/requirements/partial/requirements_development.txt b/requirements/partial/requirements_development.txt index 3d3b9af5f..66bfd85f3 100644 --- a/requirements/partial/requirements_development.txt +++ b/requirements/partial/requirements_development.txt @@ -1,7 +1,7 @@ aiosmtpd==1.4.6 autoflake==2.3.1 black==24.8.0 -debugpy==1.8.5 +debugpy==1.8.6 flake8==7.1.1 isort==5.13.2 mypy==1.11.2 diff --git a/requirements/partial/requirements_packaged_services.txt b/requirements/partial/requirements_packaged_services.txt index f91652ad6..676825fe6 100644 --- a/requirements/partial/requirements_packaged_services.txt +++ b/requirements/partial/requirements_packaged_services.txt @@ -1,2 +1,2 @@ git+https://github.com/OpenSlides/openslides-datastore-service.git@${DATASTORE_COMMIT_HASH} -git+https://github.com/OpenSlides/openslides-auth-service.git@${AUTH_COMMIT_HASH}#egg=authlib&subdirectory=auth/libraries/pip-auth +git+https://github.com/kryptance/openslides-auth-service.git@${AUTH_COMMIT_HASH}#egg=os_authlib&subdirectory=libraries/pip-auth diff --git a/requirements/requirements_development_fullstack.txt b/requirements/requirements_development_fullstack.txt new file mode 100644 index 000000000..b5f3cd3bd --- /dev/null +++ b/requirements/requirements_development_fullstack.txt @@ -0,0 +1,4 @@ +-r partial/requirements_production.txt +-r partial/requirements_development.txt +-e /pip-auth +-e /openslides-datastore-service diff --git a/tests/util.py b/tests/util.py index 01f88f428..ce7d2ce2d 100644 --- a/tests/util.py +++ b/tests/util.py @@ -2,7 +2,7 @@ from typing import Any, TypedDict, cast import simplejson as json -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME, AuthenticateException +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME, AuthenticateException from werkzeug.test import Client as WerkzeugClient from werkzeug.test import TestResponse from werkzeug.wrappers import Response as BaseResponse