diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index f146442e4..a9e14b6e4 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -181,3 +181,12 @@ test-service: only: changes: - src/yunohost/service.py + +test-ldapauth: + extends: .test-stage + script: + - cd src/yunohost + - python3 -m pytest tests/test_ldapauth.py + only: + changes: + - src/yunohost/authenticators/*.py diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 00685b361..39a5f6758 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -33,18 +33,10 @@ # Global parameters # ############################# _global: - configuration: - authenticate: - - api - authenticator: - default: - vendor: ldap - help: admin_password - parameters: - uri: ldap://localhost:389 - base_dn: dc=yunohost,dc=org - user_rdn: cn=admin,dc=yunohost,dc=org - argument_auth: false + name: yunohost.admin + authentication: + api: ldap_admin + cli: null arguments: -v: full: --version @@ -1402,9 +1394,9 @@ tools: postinstall: action_help: YunoHost post-install api: POST /postinstall - configuration: + authentication: # We need to be able to run the postinstall without being authenticated, otherwise we can't run the postinstall - authenticate: false + api: null arguments: -d: full: --domain diff --git a/debian/control b/debian/control index 37eccb5dd..2e101dca3 100644 --- a/debian/control +++ b/debian/control @@ -14,7 +14,7 @@ Depends: ${python3:Depends}, ${misc:Depends} , python3-psutil, python3-requests, python3-dnspython, python3-openssl , python3-miniupnpc, python3-dbus, python3-jinja2 , python3-toml, python3-packaging, python3-publicsuffix, - , python3-zeroconf, + , python3-ldap, python3-zeroconf, , apt, apt-transport-https, apt-utils, dirmngr , php7.3-common, php7.3-fpm, php7.3-ldap, php7.3-intl , mariadb-server, php7.3-mysql diff --git a/locales/en.json b/locales/en.json index 3a5c8f054..8e3e8d839 100644 --- a/locales/en.json +++ b/locales/en.json @@ -372,6 +372,8 @@ "invalid_regex": "Invalid regex:'{regex}'", "ip6tables_unavailable": "You cannot play with ip6tables here. You are either in a container or your kernel does not support it", "iptables_unavailable": "You cannot play with iptables here. You are either in a container or your kernel does not support it", + "ldap_server_down": "Unable to reach LDAP server", + "ldap_server_is_down_restart_it": "The LDAP service is down, attempt to restart it...", "log_corrupted_md_file": "The YAML metadata file associated with logs is damaged: '{md_file}\nError: {error}'", "log_link_to_log": "Full log of this operation: '{desc}'", "log_help_to_get_log": "To view the log of the operation '{desc}', use the command 'yunohost log show {name}{name}'", @@ -479,6 +481,7 @@ "migrations_to_be_ran_manually": "Migration {id} has to be run manually. Please go to Tools → Migrations on the webadmin page, or run `yunohost tools migrations run`.", "not_enough_disk_space": "Not enough free space on '{path}'", "invalid_number": "Must be a number", + "invalid_password": "Invalid password", "operation_interrupted": "The operation was manually interrupted?", "packages_upgrade_failed": "Could not upgrade all the packages", "password_listed": "This password is among the most used passwords in the world. Please choose something more unique.", diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 490368a15..877ee9cdc 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -36,7 +36,7 @@ import urllib.parse import tempfile from collections import OrderedDict -from moulinette import msignals, m18n, msettings +from moulinette import Moulinette, m18n from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger from moulinette.utils.network import download_json @@ -649,7 +649,7 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False m18n.n("app_upgrade_failed", app=app_instance_name, error=error) ) failure_message_with_debug_instructions = operation_logger.error(error) - if msettings.get("interface") != "api": + if Moulinette.interface.type != "api": dump_app_log_extract_for_debugging(operation_logger) # Script got manually interrupted ... N.B. : KeyboardInterrupt does not inherit from Exception except (KeyboardInterrupt, EOFError): @@ -827,11 +827,11 @@ def app_install( def confirm_install(confirm): # Ignore if there's nothing for confirm (good quality app), if --force is used # or if request on the API (confirm already implemented on the API side) - if confirm is None or force or msettings.get("interface") == "api": + if confirm is None or force or Moulinette.interface.type == "api": return if confirm in ["danger", "thirdparty"]: - answer = msignals.prompt( + answer = Moulinette.prompt( m18n.n("confirm_app_install_" + confirm, answers="Yes, I understand"), color="red", ) @@ -839,7 +839,7 @@ def app_install( raise YunohostError("aborting") else: - answer = msignals.prompt( + answer = Moulinette.prompt( m18n.n("confirm_app_install_" + confirm, answers="Y/N"), color="yellow" ) if answer.upper() != "Y": @@ -1015,7 +1015,7 @@ def app_install( error = m18n.n("app_install_script_failed") logger.error(m18n.n("app_install_failed", app=app_id, error=error)) failure_message_with_debug_instructions = operation_logger.error(error) - if msettings.get("interface") != "api": + if Moulinette.interface.type != "api": dump_app_log_extract_for_debugging(operation_logger) # Script got manually interrupted ... N.B. : KeyboardInterrupt does not inherit from Exception except (KeyboardInterrupt, EOFError): @@ -2741,7 +2741,7 @@ class YunoHostArgumentFormatParser(object): ) try: - question.value = msignals.prompt( + question.value = Moulinette.prompt( text_for_user_input_in_cli, self.hide_user_input_in_prompt ) except NotImplementedError: diff --git a/src/yunohost/authenticators/ldap_admin.py b/src/yunohost/authenticators/ldap_admin.py new file mode 100644 index 000000000..dd6eec03e --- /dev/null +++ b/src/yunohost/authenticators/ldap_admin.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- + +import os +import logging +import ldap +import ldap.sasl +import time + +from moulinette import m18n +from moulinette.authentication import BaseAuthenticator +from yunohost.utils.error import YunohostError + +logger = logging.getLogger("yunohost.authenticators.ldap_admin") + +class Authenticator(BaseAuthenticator): + + name = "ldap_admin" + + def __init__(self, *args, **kwargs): + self.uri = "ldap://localhost:389" + self.basedn = "dc=yunohost,dc=org" + self.admindn = "cn=admin,dc=yunohost,dc=org" + + def _authenticate_credentials(self, credentials=None): + + # TODO : change authentication format + # to support another dn to support multi-admins + + def _reconnect(): + con = ldap.ldapobject.ReconnectLDAPObject( + self.uri, retry_max=10, retry_delay=0.5 + ) + con.simple_bind_s(self.admindn, credentials) + return con + + try: + con = _reconnect() + except ldap.INVALID_CREDENTIALS: + raise YunohostError("invalid_password") + except ldap.SERVER_DOWN: + # ldap is down, attempt to restart it before really failing + logger.warning(m18n.n("ldap_server_is_down_restart_it")) + os.system("systemctl restart slapd") + time.sleep(10) # waits 10 secondes so we are sure that slapd has restarted + + try: + con = _reconnect() + except ldap.SERVER_DOWN: + raise YunohostError("ldap_server_down") + + # Check that we are indeed logged in with the expected identity + try: + # whoami_s return dn:..., then delete these 3 characters + who = con.whoami_s()[3:] + except Exception as e: + logger.warning("Error during ldap authentication process: %s", e) + raise + else: + if who != self.admindn: + raise YunohostError(f"Not logged with the appropriate identity ? Found {who}, expected {self.admindn} !?", raw_msg=True) + finally: + # Free the connection, we don't really need it to keep it open as the point is only to check authentication... + if con: + con.unbind_s() diff --git a/src/yunohost/backup.py b/src/yunohost/backup.py index ecc5ae033..09b35cb67 100644 --- a/src/yunohost/backup.py +++ b/src/yunohost/backup.py @@ -38,7 +38,7 @@ from collections import OrderedDict from functools import reduce from packaging import version -from moulinette import msignals, m18n, msettings +from moulinette import Moulinette, m18n from moulinette.utils import filesystem from moulinette.utils.log import getActionLogger from moulinette.utils.filesystem import read_file, mkdir, write_to_yaml, read_yaml @@ -1509,7 +1509,7 @@ class RestoreManager: m18n.n("app_restore_failed", app=app_instance_name, error=error) ) failure_message_with_debug_instructions = operation_logger.error(error) - if msettings.get("interface") != "api": + if Moulinette.interface.type != "api": dump_app_log_extract_for_debugging(operation_logger) # Script got manually interrupted ... N.B. : KeyboardInterrupt does not inherit from Exception except (KeyboardInterrupt, EOFError): @@ -1840,7 +1840,7 @@ class BackupMethod(object): # Ask confirmation for copying if size > MB_ALLOWED_TO_ORGANIZE: try: - i = msignals.prompt( + i = Moulinette.prompt( m18n.n( "backup_ask_for_copying_if_needed", answers="y/N", @@ -2344,7 +2344,7 @@ def backup_restore(name, system=[], apps=[], force=False): if not force: try: # Ask confirmation for restoring - i = msignals.prompt( + i = Moulinette.prompt( m18n.n("restore_confirm_yunohost_installed", answers="y/N") ) except NotImplemented: @@ -2418,7 +2418,7 @@ def backup_list(with_info=False, human_readable=False): def backup_download(name): - if msettings.get("interface") != "api": + if Moulinette.interface.type != "api": logger.error( "This option is only meant for the API/webadmin and doesn't make sense for the command line." ) diff --git a/src/yunohost/diagnosis.py b/src/yunohost/diagnosis.py index ff1a14c4e..4ac5e2731 100644 --- a/src/yunohost/diagnosis.py +++ b/src/yunohost/diagnosis.py @@ -28,7 +28,7 @@ import re import os import time -from moulinette import m18n, msettings +from moulinette import m18n, Moulinette from moulinette.utils import log from moulinette.utils.filesystem import ( read_json, @@ -138,7 +138,7 @@ def diagnosis_show( url = yunopaste(content) logger.info(m18n.n("log_available_on_yunopaste", url=url)) - if msettings.get("interface") == "api": + if Moulinette.interface.type == "api": return {"url": url} else: return @@ -219,7 +219,7 @@ def diagnosis_run( if email: _email_diagnosis_issues() - if issues and msettings.get("interface") == "cli": + if issues and Moulinette.interface.type == "cli": logger.warning(m18n.n("diagnosis_display_tip")) @@ -595,7 +595,7 @@ class Diagnoser: info[1].update(meta_data) s = m18n.n(info[0], **(info[1])) # In cli, we remove the html tags - if msettings.get("interface") != "api" or force_remove_html_tags: + if Moulinette.interface.type != "api" or force_remove_html_tags: s = s.replace("", "'").replace("", "'") s = html_tags.sub("", s.replace("
", "\n")) else: diff --git a/src/yunohost/domain.py b/src/yunohost/domain.py index 09d419c71..3bc70c424 100644 --- a/src/yunohost/domain.py +++ b/src/yunohost/domain.py @@ -26,7 +26,7 @@ import os import re -from moulinette import m18n, msettings, msignals +from moulinette import m18n, Moulinette from moulinette.core import MoulinetteError from yunohost.utils.error import YunohostError, YunohostValidationError from moulinette.utils.log import getActionLogger @@ -241,8 +241,8 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False): if apps_on_that_domain: if remove_apps: - if msettings.get("interface") == "cli" and not force: - answer = msignals.prompt( + if Moulinette.interface.type == "cli" and not force: + answer = Moulinette.prompt( m18n.n( "domain_remove_confirm_apps_removal", apps="\n".join([x[1] for x in apps_on_that_domain]), @@ -348,7 +348,7 @@ def domain_dns_conf(domain, ttl=None): for record in record_list: result += "\n{name} {ttl} IN {type} {value}".format(**record) - if msettings.get("interface") == "cli": + if Moulinette.interface.type == "cli": logger.info(m18n.n("domain_dns_conf_is_just_a_recommendation")) return result diff --git a/src/yunohost/hook.py b/src/yunohost/hook.py index 33f5885e2..0594a27ae 100644 --- a/src/yunohost/hook.py +++ b/src/yunohost/hook.py @@ -31,7 +31,7 @@ import mimetypes from glob import iglob from importlib import import_module -from moulinette import m18n, msettings +from moulinette import m18n, Moulinette from yunohost.utils.error import YunohostError, YunohostValidationError from moulinette.utils import log from moulinette.utils.filesystem import read_json @@ -416,7 +416,7 @@ def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers): env = {} env["YNH_CWD"] = chdir - env["YNH_INTERFACE"] = msettings.get("interface") + env["YNH_INTERFACE"] = Moulinette.interface.type stdreturn = os.path.join(tempfile.mkdtemp(), "stdreturn") with open(stdreturn, "w") as f: diff --git a/src/yunohost/log.py b/src/yunohost/log.py index f10ade23d..9f61b1eed 100644 --- a/src/yunohost/log.py +++ b/src/yunohost/log.py @@ -33,7 +33,7 @@ import psutil from datetime import datetime, timedelta from logging import FileHandler, getLogger, Formatter -from moulinette import m18n, msettings +from moulinette import m18n, Moulinette from moulinette.core import MoulinetteError from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.packages import get_ynh_package_version @@ -44,7 +44,6 @@ CATEGORIES_PATH = "/var/log/yunohost/categories/" OPERATIONS_PATH = "/var/log/yunohost/categories/operation/" METADATA_FILE_EXT = ".yml" LOG_FILE_EXT = ".log" -RELATED_CATEGORIES = ["app", "domain", "group", "service", "user"] logger = getActionLogger("yunohost.log") @@ -125,7 +124,7 @@ def log_list(limit=None, with_details=False, with_suboperations=False): operations = list(reversed(sorted(operations, key=lambda o: o["name"]))) # Reverse the order of log when in cli, more comfortable to read (avoid # unecessary scrolling) - is_api = msettings.get("interface") == "api" + is_api = Moulinette.interface.type == "api" if not is_api: operations = list(reversed(operations)) @@ -214,7 +213,7 @@ def log_show( url = yunopaste(content) logger.info(m18n.n("log_available_on_yunopaste", url=url)) - if msettings.get("interface") == "api": + if Moulinette.interface.type == "api": return {"url": url} else: return @@ -609,7 +608,7 @@ class OperationLogger(object): "operation": self.operation, "parent": self.parent, "yunohost_version": get_ynh_package_version("yunohost")["version"], - "interface": msettings.get("interface"), + "interface": Moulinette.interface.type, } if self.related_to is not None: data["related_to"] = self.related_to @@ -663,7 +662,7 @@ class OperationLogger(object): self.logger.removeHandler(self.file_handler) self.file_handler.close() - is_api = msettings.get("interface") == "api" + is_api = Moulinette.interface.type == "api" desc = _get_description_from_name(self.name) if error is None: if is_api: diff --git a/src/yunohost/tests/conftest.py b/src/yunohost/tests/conftest.py index 49f87decf..9dfe2b39c 100644 --- a/src/yunohost/tests/conftest.py +++ b/src/yunohost/tests/conftest.py @@ -3,7 +3,7 @@ import pytest import sys import moulinette -from moulinette import m18n, msettings +from moulinette import m18n, Moulinette from yunohost.utils.error import YunohostError from contextlib import contextmanager @@ -81,4 +81,11 @@ def pytest_cmdline_main(config): import yunohost yunohost.init(debug=config.option.yunodebug) - msettings["interface"] = "test" + class DummyInterface(): + + type = "test" + + def prompt(*args, **kwargs): + raise NotImplementedError + + Moulinette._interface = DummyInterface() diff --git a/src/yunohost/tests/test_apps_arguments_parsing.py b/src/yunohost/tests/test_apps_arguments_parsing.py index 95d1548ae..573c18cb2 100644 --- a/src/yunohost/tests/test_apps_arguments_parsing.py +++ b/src/yunohost/tests/test_apps_arguments_parsing.py @@ -5,7 +5,7 @@ from mock import patch from io import StringIO from collections import OrderedDict -from moulinette import msignals +from moulinette import Moulinette from yunohost import domain, user from yunohost.app import _parse_args_in_yunohost_format, PasswordArgumentParser @@ -84,7 +84,7 @@ def test_parse_args_in_yunohost_format_string_input(): answers = {} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -97,7 +97,7 @@ def test_parse_args_in_yunohost_format_string_input_no_ask(): answers = {} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -124,7 +124,7 @@ def test_parse_args_in_yunohost_format_string_optional_with_input(): answers = {} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -139,7 +139,7 @@ def test_parse_args_in_yunohost_format_string_optional_with_empty_input(): answers = {} expected_result = OrderedDict({"some_string": ("", "string")}) - with patch.object(msignals, "prompt", return_value=""): + with patch.object(Moulinette.interface, "prompt", return_value=""): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -153,7 +153,7 @@ def test_parse_args_in_yunohost_format_string_optional_with_input_without_ask(): answers = {} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -180,7 +180,7 @@ def test_parse_args_in_yunohost_format_string_input_test_ask(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) prompt.assert_called_with(ask_text, False) @@ -197,7 +197,7 @@ def test_parse_args_in_yunohost_format_string_input_test_ask_with_default(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) prompt.assert_called_with("%s (default: %s)" % (ask_text, default_text), False) @@ -215,7 +215,7 @@ def test_parse_args_in_yunohost_format_string_input_test_ask_with_example(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) assert ask_text in prompt.call_args[0][0] assert example_text in prompt.call_args[0][0] @@ -234,7 +234,7 @@ def test_parse_args_in_yunohost_format_string_input_test_ask_with_help(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) assert ask_text in prompt.call_args[0][0] assert help_text in prompt.call_args[0][0] @@ -251,7 +251,7 @@ def test_parse_args_in_yunohost_format_string_with_choice_prompt(): questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}] answers = {"some_string": "fr"} expected_result = OrderedDict({"some_string": ("fr", "string")}) - with patch.object(msignals, "prompt", return_value="fr"): + with patch.object(Moulinette.interface, "prompt", return_value="fr"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -275,7 +275,7 @@ def test_parse_args_in_yunohost_format_string_with_choice_ask(): ] answers = {} - with patch.object(msignals, "prompt", return_value="ru") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="ru") as prompt: _parse_args_in_yunohost_format(answers, questions) assert ask_text in prompt.call_args[0][0] @@ -333,7 +333,7 @@ def test_parse_args_in_yunohost_format_password_input(): answers = {} expected_result = OrderedDict({"some_password": ("some_value", "password")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -347,7 +347,7 @@ def test_parse_args_in_yunohost_format_password_input_no_ask(): answers = {} expected_result = OrderedDict({"some_password": ("some_value", "password")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -383,7 +383,7 @@ def test_parse_args_in_yunohost_format_password_optional_with_input(): answers = {} expected_result = OrderedDict({"some_password": ("some_value", "password")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -399,7 +399,7 @@ def test_parse_args_in_yunohost_format_password_optional_with_empty_input(): answers = {} expected_result = OrderedDict({"some_password": ("", "password")}) - with patch.object(msignals, "prompt", return_value=""): + with patch.object(Moulinette.interface, "prompt", return_value=""): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -414,7 +414,7 @@ def test_parse_args_in_yunohost_format_password_optional_with_input_without_ask( answers = {} expected_result = OrderedDict({"some_password": ("some_value", "password")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -462,7 +462,7 @@ def test_parse_args_in_yunohost_format_password_input_test_ask(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) prompt.assert_called_with(ask_text, True) @@ -481,7 +481,7 @@ def test_parse_args_in_yunohost_format_password_input_test_ask_with_example(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) assert ask_text in prompt.call_args[0][0] assert example_text in prompt.call_args[0][0] @@ -501,7 +501,7 @@ def test_parse_args_in_yunohost_format_password_input_test_ask_with_help(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) assert ask_text in prompt.call_args[0][0] assert help_text in prompt.call_args[0][0] @@ -594,7 +594,7 @@ def test_parse_args_in_yunohost_format_path_input(): answers = {} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -608,7 +608,7 @@ def test_parse_args_in_yunohost_format_path_input_no_ask(): answers = {} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -637,7 +637,7 @@ def test_parse_args_in_yunohost_format_path_optional_with_input(): answers = {} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -653,7 +653,7 @@ def test_parse_args_in_yunohost_format_path_optional_with_empty_input(): answers = {} expected_result = OrderedDict({"some_path": ("", "path")}) - with patch.object(msignals, "prompt", return_value=""): + with patch.object(Moulinette.interface, "prompt", return_value=""): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -668,7 +668,7 @@ def test_parse_args_in_yunohost_format_path_optional_with_input_without_ask(): answers = {} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - with patch.object(msignals, "prompt", return_value="some_value"): + with patch.object(Moulinette.interface, "prompt", return_value="some_value"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -697,7 +697,7 @@ def test_parse_args_in_yunohost_format_path_input_test_ask(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) prompt.assert_called_with(ask_text, False) @@ -715,7 +715,7 @@ def test_parse_args_in_yunohost_format_path_input_test_ask_with_default(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) prompt.assert_called_with("%s (default: %s)" % (ask_text, default_text), False) @@ -734,7 +734,7 @@ def test_parse_args_in_yunohost_format_path_input_test_ask_with_example(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) assert ask_text in prompt.call_args[0][0] assert example_text in prompt.call_args[0][0] @@ -754,7 +754,7 @@ def test_parse_args_in_yunohost_format_path_input_test_ask_with_help(): ] answers = {} - with patch.object(msignals, "prompt", return_value="some_value") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="some_value") as prompt: _parse_args_in_yunohost_format(answers, questions) assert ask_text in prompt.call_args[0][0] assert help_text in prompt.call_args[0][0] @@ -918,11 +918,11 @@ def test_parse_args_in_yunohost_format_boolean_input(): answers = {} expected_result = OrderedDict({"some_boolean": (1, "boolean")}) - with patch.object(msignals, "prompt", return_value="y"): + with patch.object(Moulinette.interface, "prompt", return_value="y"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result expected_result = OrderedDict({"some_boolean": (0, "boolean")}) - with patch.object(msignals, "prompt", return_value="n"): + with patch.object(Moulinette.interface, "prompt", return_value="n"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -936,7 +936,7 @@ def test_parse_args_in_yunohost_format_boolean_input_no_ask(): answers = {} expected_result = OrderedDict({"some_boolean": (1, "boolean")}) - with patch.object(msignals, "prompt", return_value="y"): + with patch.object(Moulinette.interface, "prompt", return_value="y"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -965,7 +965,7 @@ def test_parse_args_in_yunohost_format_boolean_optional_with_input(): answers = {} expected_result = OrderedDict({"some_boolean": (1, "boolean")}) - with patch.object(msignals, "prompt", return_value="y"): + with patch.object(Moulinette.interface, "prompt", return_value="y"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -981,7 +981,7 @@ def test_parse_args_in_yunohost_format_boolean_optional_with_empty_input(): answers = {} expected_result = OrderedDict({"some_boolean": (0, "boolean")}) # default to false - with patch.object(msignals, "prompt", return_value=""): + with patch.object(Moulinette.interface, "prompt", return_value=""): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -996,7 +996,7 @@ def test_parse_args_in_yunohost_format_boolean_optional_with_input_without_ask() answers = {} expected_result = OrderedDict({"some_boolean": (0, "boolean")}) - with patch.object(msignals, "prompt", return_value="n"): + with patch.object(Moulinette.interface, "prompt", return_value="n"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -1039,7 +1039,7 @@ def test_parse_args_in_yunohost_format_boolean_input_test_ask(): ] answers = {} - with patch.object(msignals, "prompt", return_value=0) as prompt: + with patch.object(Moulinette.interface, "prompt", return_value=0) as prompt: _parse_args_in_yunohost_format(answers, questions) prompt.assert_called_with(ask_text + " [yes | no] (default: no)", False) @@ -1057,7 +1057,7 @@ def test_parse_args_in_yunohost_format_boolean_input_test_ask_with_default(): ] answers = {} - with patch.object(msignals, "prompt", return_value=1) as prompt: + with patch.object(Moulinette.interface, "prompt", return_value=1) as prompt: _parse_args_in_yunohost_format(answers, questions) prompt.assert_called_with("%s [yes | no] (default: yes)" % ask_text, False) @@ -1193,11 +1193,11 @@ def test_parse_args_in_yunohost_format_domain_two_domains_default_input(): domain, "_get_maindomain", return_value=main_domain ), patch.object(domain, "domain_list", return_value={"domains": domains}): expected_result = OrderedDict({"some_domain": (main_domain, "domain")}) - with patch.object(msignals, "prompt", return_value=main_domain): + with patch.object(Moulinette.interface, "prompt", return_value=main_domain): assert _parse_args_in_yunohost_format(answers, questions) == expected_result expected_result = OrderedDict({"some_domain": (other_domain, "domain")}) - with patch.object(msignals, "prompt", return_value=other_domain): + with patch.object(Moulinette.interface, "prompt", return_value=other_domain): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -1380,14 +1380,14 @@ def test_parse_args_in_yunohost_format_user_two_users_default_input(): with patch.object(user, "user_list", return_value={"users": users}): with patch.object(user, "user_info", return_value={}): expected_result = OrderedDict({"some_user": (username, "user")}) - with patch.object(msignals, "prompt", return_value=username): + with patch.object(Moulinette.interface, "prompt", return_value=username): assert ( _parse_args_in_yunohost_format(answers, questions) == expected_result ) expected_result = OrderedDict({"some_user": (other_user, "user")}) - with patch.object(msignals, "prompt", return_value=other_user): + with patch.object(Moulinette.interface, "prompt", return_value=other_user): assert ( _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -1447,14 +1447,14 @@ def test_parse_args_in_yunohost_format_number_input(): answers = {} expected_result = OrderedDict({"some_number": (1337, "number")}) - with patch.object(msignals, "prompt", return_value="1337"): + with patch.object(Moulinette.interface, "prompt", return_value="1337"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result - with patch.object(msignals, "prompt", return_value=1337): + with patch.object(Moulinette.interface, "prompt", return_value=1337): assert _parse_args_in_yunohost_format(answers, questions) == expected_result expected_result = OrderedDict({"some_number": (0, "number")}) - with patch.object(msignals, "prompt", return_value=""): + with patch.object(Moulinette.interface, "prompt", return_value=""): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -1468,7 +1468,7 @@ def test_parse_args_in_yunohost_format_number_input_no_ask(): answers = {} expected_result = OrderedDict({"some_number": (1337, "number")}) - with patch.object(msignals, "prompt", return_value="1337"): + with patch.object(Moulinette.interface, "prompt", return_value="1337"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -1497,7 +1497,7 @@ def test_parse_args_in_yunohost_format_number_optional_with_input(): answers = {} expected_result = OrderedDict({"some_number": (1337, "number")}) - with patch.object(msignals, "prompt", return_value="1337"): + with patch.object(Moulinette.interface, "prompt", return_value="1337"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -1512,7 +1512,7 @@ def test_parse_args_in_yunohost_format_number_optional_with_input_without_ask(): answers = {} expected_result = OrderedDict({"some_number": (0, "number")}) - with patch.object(msignals, "prompt", return_value="0"): + with patch.object(Moulinette.interface, "prompt", return_value="0"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -1555,7 +1555,7 @@ def test_parse_args_in_yunohost_format_number_input_test_ask(): ] answers = {} - with patch.object(msignals, "prompt", return_value="1111") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="1111") as prompt: _parse_args_in_yunohost_format(answers, questions) prompt.assert_called_with("%s (default: 0)" % (ask_text), False) @@ -1573,7 +1573,7 @@ def test_parse_args_in_yunohost_format_number_input_test_ask_with_default(): ] answers = {} - with patch.object(msignals, "prompt", return_value="1111") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="1111") as prompt: _parse_args_in_yunohost_format(answers, questions) prompt.assert_called_with("%s (default: %s)" % (ask_text, default_value), False) @@ -1592,7 +1592,7 @@ def test_parse_args_in_yunohost_format_number_input_test_ask_with_example(): ] answers = {} - with patch.object(msignals, "prompt", return_value="1111") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="1111") as prompt: _parse_args_in_yunohost_format(answers, questions) assert ask_text in prompt.call_args[0][0] assert example_value in prompt.call_args[0][0] @@ -1612,7 +1612,7 @@ def test_parse_args_in_yunohost_format_number_input_test_ask_with_help(): ] answers = {} - with patch.object(msignals, "prompt", return_value="1111") as prompt: + with patch.object(Moulinette.interface, "prompt", return_value="1111") as prompt: _parse_args_in_yunohost_format(answers, questions) assert ask_text in prompt.call_args[0][0] assert help_value in prompt.call_args[0][0] diff --git a/src/yunohost/tests/test_ldapauth.py b/src/yunohost/tests/test_ldapauth.py new file mode 100644 index 000000000..7560608f5 --- /dev/null +++ b/src/yunohost/tests/test_ldapauth.py @@ -0,0 +1,58 @@ +import pytest +import os + +from yunohost.authenticators.ldap_admin import Authenticator as LDAPAuth +from yunohost.tools import tools_adminpw + +from moulinette import m18n +from moulinette.core import MoulinetteError + +def setup_function(function): + + if os.system("systemctl is-active slapd") != 0: + os.system("systemctl start slapd && sleep 3") + + tools_adminpw("yunohost", check_strength=False) + + +def test_authenticate(): + LDAPAuth().authenticate_credentials(credentials="yunohost") + + +def test_authenticate_with_wrong_password(): + with pytest.raises(MoulinetteError) as exception: + LDAPAuth().authenticate_credentials(credentials="bad_password_lul") + + translation = m18n.g("invalid_password") + expected_msg = translation.format() + assert expected_msg in str(exception) + + +def test_authenticate_server_down(mocker): + os.system("systemctl stop slapd && sleep 3") + + # Now if slapd is down, moulinette tries to restart it + mocker.patch("os.system") + mocker.patch("time.sleep") + with pytest.raises(MoulinetteError) as exception: + LDAPAuth().authenticate_credentials(credentials="yunohost") + + translation = m18n.n("ldap_server_down") + expected_msg = translation.format() + assert expected_msg in str(exception) + + +def test_authenticate_change_password(): + + LDAPAuth().authenticate_credentials(credentials="yunohost") + + tools_adminpw("plopette", check_strength=False) + + with pytest.raises(MoulinetteError) as exception: + LDAPAuth().authenticate_credentials(credentials="yunohost") + + translation = m18n.g("invalid_password") + expected_msg = translation.format() + assert expected_msg in str(exception) + + LDAPAuth().authenticate_credentials(credentials="plopette") diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index 1cd197d70..4190e7614 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -30,7 +30,7 @@ import time from importlib import import_module from packaging import version -from moulinette import msignals, m18n +from moulinette import Moulinette, m18n from moulinette.utils.log import getActionLogger from moulinette.utils.process import check_output, call_async_output from moulinette.utils.filesystem import read_yaml, write_to_yaml @@ -692,7 +692,7 @@ def tools_shutdown(operation_logger, force=False): if not shutdown: try: # Ask confirmation for server shutdown - i = msignals.prompt(m18n.n("server_shutdown_confirm", answers="y/N")) + i = Moulinette.prompt(m18n.n("server_shutdown_confirm", answers="y/N")) except NotImplemented: pass else: @@ -711,7 +711,7 @@ def tools_reboot(operation_logger, force=False): if not reboot: try: # Ask confirmation for restoring - i = msignals.prompt(m18n.n("server_reboot_confirm", answers="y/N")) + i = Moulinette.prompt(m18n.n("server_reboot_confirm", answers="y/N")) except NotImplemented: pass else: diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 266c2774c..01513f3bd 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -33,7 +33,7 @@ import string import subprocess import copy -from moulinette import msignals, msettings, m18n +from moulinette import Moulinette, m18n from moulinette.utils.log import getActionLogger from moulinette.utils.process import check_output @@ -117,18 +117,18 @@ def user_create( # Validate domain used for email address/xmpp account if domain is None: - if msettings.get("interface") == "api": + if Moulinette.interface.type == "api": raise YunohostValidationError( "Invalid usage, you should specify a domain argument" ) else: # On affiche les differents domaines possibles - msignals.display(m18n.n("domains_available")) + Moulinette.display(m18n.n("domains_available")) for domain in domain_list()["domains"]: - msignals.display("- {}".format(domain)) + Moulinette.display("- {}".format(domain)) maindomain = _get_maindomain() - domain = msignals.prompt( + domain = Moulinette.prompt( m18n.n("ask_user_domain") + " (default: %s)" % maindomain ) if not domain: @@ -379,8 +379,8 @@ def user_update( # when in the cli interface if the option to change the password is called # without a specified value, change_password will be set to the const 0. # In this case we prompt for the new password. - if msettings.get("interface") == "cli" and not change_password: - change_password = msignals.prompt(m18n.n("ask_password"), True, True) + if Moulinette.interface.type == "cli" and not change_password: + change_password = Moulinette.prompt(m18n.n("ask_password"), True, True) # Ensure sufficiently complex password assert_password_is_strong_enough("user", change_password) diff --git a/src/yunohost/utils/ldap.py b/src/yunohost/utils/ldap.py index 85bca34d7..1298eff69 100644 --- a/src/yunohost/utils/ldap.py +++ b/src/yunohost/utils/ldap.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- - """ License Copyright (C) 2019 YunoHost @@ -21,10 +20,18 @@ import os import atexit -from moulinette.core import MoulinetteLdapIsDownError -from moulinette.authenticators import ldap +import logging +import ldap +import ldap.sasl +import time +import ldap.modlist as modlist + +from moulinette import m18n +from moulinette.core import MoulinetteError from yunohost.utils.error import YunohostError +logger = logging.getLogger("yunohost.utils.ldap") + # We use a global variable to do some caching # to avoid re-authenticating in case we call _get_ldap_authenticator multiple times _ldap_interface = None @@ -35,51 +42,21 @@ def _get_ldap_interface(): global _ldap_interface if _ldap_interface is None: - - conf = { - "vendor": "ldap", - "name": "as-root", - "parameters": { - "uri": "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi", - "base_dn": "dc=yunohost,dc=org", - "user_rdn": "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth", - }, - "extra": {}, - } - - try: - _ldap_interface = ldap.Authenticator(**conf) - except MoulinetteLdapIsDownError: - raise YunohostError( - "Service slapd is not running but is required to perform this action ... You can try to investigate what's happening with 'systemctl status slapd'" - ) - - assert_slapd_is_running() + _ldap_interface = LDAPInterface() return _ldap_interface -def assert_slapd_is_running(): - - # Assert slapd is running... - if not os.system("pgrep slapd >/dev/null") == 0: - raise YunohostError( - "Service slapd is not running but is required to perform this action ... You can try to investigate what's happening with 'systemctl status slapd'" - ) - - # We regularly want to extract stuff like 'bar' in ldap path like # foo=bar,dn=users.example.org,ou=example.org,dc=org so this small helper allow # to do this without relying of dozens of mysterious string.split()[0] # # e.g. using _ldap_path_extract(path, "foo") on the previous example will # return bar - - def _ldap_path_extract(path, info): for element in path.split(","): if element.startswith(info + "="): - return element[len(info + "=") :] + return element[len(info + "="):] # Add this to properly close / delete the ldap interface / authenticator @@ -93,3 +70,247 @@ def _destroy_ldap_interface(): atexit.register(_destroy_ldap_interface) + + +class LDAPInterface(): + + def __init__(self): + logger.debug("initializing ldap interface") + + self.uri = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi" + self.basedn = "dc=yunohost,dc=org" + self.rootdn = "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" + self.connect() + + def connect(self): + def _reconnect(): + con = ldap.ldapobject.ReconnectLDAPObject( + self.uri, retry_max=10, retry_delay=0.5 + ) + con.sasl_non_interactive_bind_s("EXTERNAL") + return con + + try: + con = _reconnect() + except ldap.SERVER_DOWN: + # ldap is down, attempt to restart it before really failing + logger.warning(m18n.n("ldap_server_is_down_restart_it")) + os.system("systemctl restart slapd") + time.sleep(10) # waits 10 secondes so we are sure that slapd has restarted + try: + con = _reconnect() + except ldap.SERVER_DOWN: + raise YunohostError( + "Service slapd is not running but is required to perform this action ... " + "You can try to investigate what's happening with 'systemctl status slapd'" + ) + + # Check that we are indeed logged in with the right identity + try: + # whoami_s return dn:..., then delete these 3 characters + who = con.whoami_s()[3:] + except Exception as e: + logger.warning("Error during ldap authentication process: %s", e) + raise + else: + if who != self.rootdn: + raise MoulinetteError("Not logged in with the expected userdn ?!") + else: + self.con = con + + def __del__(self): + """Disconnect and free ressources""" + if hasattr(self, "con") and self.con: + self.con.unbind_s() + + def search(self, base=None, filter="(objectClass=*)", attrs=["dn"]): + """Search in LDAP base + + Perform an LDAP search operation with given arguments and return + results as a list. + + Keyword arguments: + - base -- The dn to search into + - filter -- A string representation of the filter to apply + - attrs -- A list of attributes to fetch + + Returns: + A list of all results + + """ + if not base: + base = self.basedn + + try: + result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs) + except Exception as e: + raise MoulinetteError( + "error during LDAP search operation with: base='%s', " + "filter='%s', attrs=%s and exception %s" % (base, filter, attrs, e), + raw_msg=True, + ) + + result_list = [] + if not attrs or "dn" not in attrs: + result_list = [entry for dn, entry in result] + else: + for dn, entry in result: + entry["dn"] = [dn] + result_list.append(entry) + + def decode(value): + if isinstance(value, bytes): + value = value.decode("utf-8") + return value + + # result_list is for example : + # [{'virtualdomain': [b'test.com']}, {'virtualdomain': [b'yolo.test']}, + for stuff in result_list: + if isinstance(stuff, dict): + for key, values in stuff.items(): + stuff[key] = [decode(v) for v in values] + + return result_list + + def add(self, rdn, attr_dict): + """ + Add LDAP entry + + Keyword arguments: + rdn -- DN without domain + attr_dict -- Dictionnary of attributes/values to add + + Returns: + Boolean | MoulinetteError + + """ + dn = rdn + "," + self.basedn + ldif = modlist.addModlist(attr_dict) + for i, (k, v) in enumerate(ldif): + if isinstance(v, list): + v = [a.encode("utf-8") for a in v] + elif isinstance(v, str): + v = [v.encode("utf-8")] + ldif[i] = (k, v) + + try: + self.con.add_s(dn, ldif) + except Exception as e: + raise MoulinetteError( + "error during LDAP add operation with: rdn='%s', " + "attr_dict=%s and exception %s" % (rdn, attr_dict, e), + raw_msg=True, + ) + else: + return True + + def remove(self, rdn): + """ + Remove LDAP entry + + Keyword arguments: + rdn -- DN without domain + + Returns: + Boolean | MoulinetteError + + """ + dn = rdn + "," + self.basedn + try: + self.con.delete_s(dn) + except Exception as e: + raise MoulinetteError( + "error during LDAP delete operation with: rdn='%s' and exception %s" + % (rdn, e), + raw_msg=True, + ) + else: + return True + + def update(self, rdn, attr_dict, new_rdn=False): + """ + Modify LDAP entry + + Keyword arguments: + rdn -- DN without domain + attr_dict -- Dictionnary of attributes/values to add + new_rdn -- New RDN for modification + + Returns: + Boolean | MoulinetteError + + """ + dn = rdn + "," + self.basedn + actual_entry = self.search(base=dn, attrs=None) + ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1) + + if ldif == []: + logger.debug("Nothing to update in LDAP") + return True + + try: + if new_rdn: + self.con.rename_s(dn, new_rdn) + new_base = dn.split(",", 1)[1] + dn = new_rdn + "," + new_base + + for i, (a, k, vs) in enumerate(ldif): + if isinstance(vs, list): + vs = [v.encode("utf-8") for v in vs] + elif isinstance(vs, str): + vs = [vs.encode("utf-8")] + ldif[i] = (a, k, vs) + + self.con.modify_ext_s(dn, ldif) + except Exception as e: + raise MoulinetteError( + "error during LDAP update operation with: rdn='%s', " + "attr_dict=%s, new_rdn=%s and exception: %s" + % (rdn, attr_dict, new_rdn, e), + raw_msg=True, + ) + else: + return True + + def validate_uniqueness(self, value_dict): + """ + Check uniqueness of values + + Keyword arguments: + value_dict -- Dictionnary of attributes/values to check + + Returns: + Boolean | MoulinetteError + + """ + attr_found = self.get_conflict(value_dict) + if attr_found: + logger.info( + "attribute '%s' with value '%s' is not unique", + attr_found[0], + attr_found[1], + ) + raise MoulinetteError( + "ldap_attribute_already_exists", + attribute=attr_found[0], + value=attr_found[1], + ) + return True + + def get_conflict(self, value_dict, base_dn=None): + """ + Check uniqueness of values + + Keyword arguments: + value_dict -- Dictionnary of attributes/values to check + + Returns: + None | tuple with Fist conflict attribute name and value + + """ + for attr, value in value_dict.items(): + if not self.search(base=base_dn, filter=attr + "=" + value): + continue + else: + return (attr, value) + return None diff --git a/tests/test_i18n_keys.py b/tests/test_i18n_keys.py index 7b5ad1956..33c1f7b65 100644 --- a/tests/test_i18n_keys.py +++ b/tests/test_i18n_keys.py @@ -35,6 +35,7 @@ def find_expected_string_keys(): python_files = glob.glob("src/yunohost/*.py") python_files.extend(glob.glob("src/yunohost/utils/*.py")) python_files.extend(glob.glob("src/yunohost/data_migrations/*.py")) + python_files.extend(glob.glob("src/yunohost/authenticators/*.py")) python_files.extend(glob.glob("data/hooks/diagnosis/*.py")) python_files.append("bin/yunohost")