diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 05180deedee29a1797429e92e87e7804de18e41d..748f7a9d1acdb0147bbc3a9be9613251e3d1804e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -55,9 +55,8 @@ py36-functionnal: &base-functionnal - docker exec --workdir /var/www/sharelatex/web/modules/server-ce-scripts/scripts -i sharelatex node create-user-with-pass.js --email=joe3@inria.fr --pass=TestTest42 - export CI_BASE_URL=http://172.17.0.1:8080 - export CI_USERNAMES=joe1@inria.fr,joe2@inria.fr,joe3@inria.fr - # NOTE(msimonin): the password is hardcoded in the user creation script in - # sharelatex/tests/CreateUserTest.coffee - export CI_PASSWORDS=TestTest42,TestTest42,TestTest42 + - export CI_AUTH_TYPE=default # let's test ! # the python interpreter version is taken from the TOXENV var - tox diff --git a/sharelatex/__init__.py b/sharelatex/__init__.py index 593dc4d72227465d60fcd5af98ec9d73edd40c42..c25642fa3a5ef934bf8c0eb829b7e73746542b10 100644 --- a/sharelatex/__init__.py +++ b/sharelatex/__init__.py @@ -207,7 +207,13 @@ class Authenticator(object): class DefaultAuthenticator(Authenticator): def __init__( - self, login_url: str, username: str, password: str, verify: bool = True, sid_name="sharelatex.sid" + self, + base_url: str, + username: str, + password: str, + verify: bool = True, + login_path="/login", + sid_name="sharelatex.sid", ): """Use the default login form of the community edition. @@ -219,7 +225,7 @@ class DefaultAuthenticator(Authenticator): testing instance) """ super().__init__() - self.login_url = login_url + self.login_url = urllib.parse.urljoin(base_url, login_path) self.username = username self.password = password self.verify = verify @@ -241,8 +247,6 @@ class DefaultAuthenticator(Authenticator): return login_data, {self.sid_name: _r.cookies[self.sid_name]} - - class IrisaAuthenticator(DefaultAuthenticator): """We use Gitlab as authentification backend (using OAUTH2). @@ -252,9 +256,16 @@ class IrisaAuthenticator(DefaultAuthenticator): """ def __init__( - self, login_url: str, username: str, password: str, verify: bool = True + self, + base_url: str, + username: str, + password: str, + verify: bool = True, + login_path="/auth/callback/gitlab", ): - super().__init__(login_url, username, password, verify=verify) + super().__init__( + base_url, username, password, verify=verify, login_path=login_path + ) def authenticate(self) -> Tuple[str, str]: # go to the login form @@ -293,7 +304,16 @@ class IrisaAuthenticator(DefaultAuthenticator): _r.raise_for_status() check_login_error(_r) login_data = dict(email=self.username, _csrf=get_csrf_Token(_r.text)) - return login_data, _r.cookies["sharelatex.sid"] + return login_data, {self.sid_name: _r.cookies[self.sid_name]} + + +def get_authenticator_class(auth_type: str): + if auth_type == "default": + return DefaultAuthenticator + elif auth_type == "irisa": + return IrisaAuthenticator + else: + raise ValueError("auth_type must be in (default|irisa)") class SyncClient: @@ -301,7 +321,6 @@ class SyncClient: self, *, base_url=BASE_URL, - login_path="/login", username: str = None, password: str = None, verify: bool = True, @@ -323,7 +342,6 @@ class SyncClient: if base_url == "": raise Exception("projet_url is not well formed or missing") self.base_url = base_url - self.login_path = login_path self.verify = verify # Used in _get, _post... to add common headers @@ -334,9 +352,8 @@ class SyncClient: if authenticator is None: # build a default authenticator based on the # given credentials - login_url = urllib.parse.urljoin(self.base_url, login_path) authenticator = DefaultAuthenticator( - login_url, username, password, verify=self.verify + self.base_url, username, password, verify=self.verify ) # set the session to use for authentication @@ -637,7 +654,11 @@ class SyncClient: - 400 the folder already exists """ url = f"{self.base_url}/project/{project_id}/folder" - data = {"parent_folder_id": parent_folder, "_csrf": self.login_data["_csrf"], "name": name} + data = { + "parent_folder_id": parent_folder, + "_csrf": self.login_data["_csrf"], + "name": name, + } logger.debug(data) r = self._post(url, data=data, verify=self.verify) r.raise_for_status() @@ -719,7 +740,7 @@ class SyncClient: data = { "email": email, "privileges": "readAndWrite" if can_edit else "readOnly", - "_csrf":self.login_data["_csrf"] + "_csrf": self.login_data["_csrf"], } r = self._post(url, data=data, verify=self.verify) r.raise_for_status() @@ -808,7 +829,11 @@ class SyncClient: """ url = f"{self.base_url}/project/new" - data = {"_csrf": self.login_data["_csrf"], "projectName": project_name, "template": "example"} + data = { + "_csrf": self.login_data["_csrf"], + "projectName": project_name, + "template": "example", + } r = self._post(url, data=data, verify=self.verify) r.raise_for_status() response = r.json() diff --git a/sharelatex/cli.py b/sharelatex/cli.py index 7f51aecb7b4c86076337dbdd866ab9ef88694d01..6894a08ec1f6fe98a5bc6b09cec7ddc0daa2e1ce 100644 --- a/sharelatex/cli.py +++ b/sharelatex/cli.py @@ -1,10 +1,16 @@ import logging import os from pathlib import Path +import urllib.parse import getpass -from sharelatex import SyncClient, walk_project_data, set_logger +from sharelatex import ( + get_authenticator_class, + SyncClient, + walk_project_data, + set_logger, +) import click from git import Repo @@ -30,7 +36,7 @@ SLATEX_SECTION = "slatex" SYNC_BRANCH = "__remote__sharelatex__" PROMPT_BASE_URL = "Base url: " PROMPT_PROJECT_ID = "Project id: " -PROMPT_LOGIN_PATH = "Login path (example: 'login'): " +PROMPT_AUTH_TYPE = "Authentification type (default|irisa): " PROMPT_USERNAME = "Username: " PROMPT_PASSWORD = "Password: " PROMPT_CONFIRM = "Do you want to save your password in your OS keyring system (y/n) ?" @@ -79,16 +85,16 @@ class Config: def get_value(self, section, key, default=None, config_level=None): """Get a config value in a specific section of the config. - Note: this returns the associated value if found. - Otherwise it returns the default value. + Note: this returns the associated value if found. + Otherwise it returns the default value. - Args: - section (str): the section name: str - key (str): the key to set - default (str): the defaut value to apply - config_level (str): the config level to look for - see: -https://gitpython.readthedocs.io/en/stable/reference.html#git.repo.base.Repo.config_level + Args: + section (str): the section name: str + key (str): the key to set + default (str): the defaut value to apply + config_level (str): the config level to look for + see: + https://gitpython.readthedocs.io/en/stable/reference.html#git.repo.base.Repo.config_level """ with self.repo.config_reader(config_level) as c: @@ -181,7 +187,7 @@ def refresh_project_information( def refresh_account_information( repo, - login_path="login", + auth_type, username=None, password=None, save_password=None, @@ -207,14 +213,14 @@ def refresh_account_information( config = Config(repo) base_url = config.get_value(SLATEX_SECTION, "baseUrl") - if login_path is None: + if auth_type is None: if not ignore_saved_user_info: - u = config.get_value(SLATEX_SECTION, "loginPath") + u = config.get_value(SLATEX_SECTION, "authType") if u: - login_path = u - if login_path is None: - login_path = input(PROMPT_LOGIN_PATH) - config.set_value(SLATEX_SECTION, "loginPath", login_path) + auth_type = u + if auth_type is None: + auth_type = input(PROMPT_AUTH_TYPE) + config.set_value(SLATEX_SECTION, "authType", auth_type) if username is None: if not ignore_saved_user_info: @@ -226,11 +232,9 @@ def refresh_account_information( config.set_value(SLATEX_SECTION, "username", username) import urllib.parse - login_url = urllib.parse.urljoin(base_url, login_path) - if password is None: if not ignore_saved_user_info: - p = config.get_password(login_url, username) + p = config.get_password(base_url, username) if p: password = p if password is None: @@ -240,29 +244,37 @@ def refresh_account_information( if r == "Y" or r == "y": save_password = True if save_password: - config.set_password(login_url, username, password) - return login_path, username, password + config.set_password(base_url, username, password) + return auth_type, username, password def getClient( - repo, base_url, login_path, username, password, verify, save_password=None + repo, + base_url, + auth_type, + username, + password, + verify, + save_password=None, ): logger.info(f"try to open session on {base_url} with {username}") client = None + + authenticator = get_authenticator_class(auth_type)( + base_url, username, password, verify + ) for i in range(MAX_NUMBER_ATTEMPTS): try: client = SyncClient( base_url=base_url, - login_path=login_path, - username=username, - password=password, + authenticator=authenticator, verify=verify, ) break except Exception as inst: client = None logger.warning("{} : attempt # {} ".format(inst, i + 1)) - login_path, username, password = refresh_account_information( + auth_type, username, password = refresh_account_information( repo, save_password=save_password, ignore_saved_user_info=True ) if client is None: @@ -304,6 +316,13 @@ def log_options(function): def authentication_options(function): + function = click.option( + "--auth_type", + "-a", + default="irisa", + help="""Authentification type (default|irisa).""", + )(function) + function = click.option( "--login-path", "-l", @@ -387,6 +406,7 @@ def _pull(repo, client, project_id): @log_options def compile( project_id, + auth_type, login_path, username, password, @@ -401,7 +421,13 @@ def compile( repo, login_path, username, password, save_password, ignore_saved_user_info ) client = getClient( - repo, base_url, login_path, username, password, https_cert_check, save_password + repo, + base_url, + auth_type, + username, + password, + https_cert_check, + save_password, ) response = client.compile(project_id) @@ -422,6 +448,7 @@ def share( project_id, email, can_edit, + auth_type, login_path, username, password, @@ -438,7 +465,13 @@ def share( repo, login_path, username, password, save_password, ignore_saved_user_info ) client = getClient( - repo, base_url, login_path, username, password, https_cert_check, save_password + repo, + base_url, + auth_type, + username, + password, + https_cert_check, + save_password, ) response = client.share(project_id, email, can_edit) @@ -458,7 +491,13 @@ def share( @authentication_options @log_options def pull( - login_path, username, password, save_password, ignore_saved_user_info, verbose + login_path, + auth_type, + username, + password, + save_password, + ignore_saved_user_info, + verbose, ): set_log_level(verbose) repo = Repo() @@ -467,7 +506,13 @@ def pull( repo, login_path, username, password, save_password, ignore_saved_user_info ) client = getClient( - repo, base_url, login_path, username, password, https_cert_check, save_password + repo, + base_url, + auth_type, + username, + password, + https_cert_check, + save_password, ) # Fail if the repo is clean _pull(repo, client, project_id) @@ -503,6 +548,7 @@ It works as follow: def clone( projet_url, directory, + auth_type, login_path, username, password, @@ -538,7 +584,7 @@ def clone( client = getClient( repo, base_url, - login_path, + auth_type, username, password, https_cert_check, @@ -569,6 +615,7 @@ This works as follow: @log_options def push( force, + auth_type, login_path, username, password, @@ -616,7 +663,13 @@ def push( ) client = getClient( - repo, base_url, login_path, username, password, https_cert_check, save_password + repo, + base_url, + auth_type, + username, + password, + https_cert_check, + save_password, ) if not force: @@ -679,6 +732,7 @@ def new( projectname, base_url, https_cert_check, + auth_type, login_path, username, password, @@ -694,7 +748,13 @@ def new( repo, login_path, username, password, save_password, ignore_saved_user_info ) client = getClient( - repo, base_url, login_path, username, password, https_cert_check, save_password + repo, + base_url, + auth_type, + username, + password, + https_cert_check, + save_password, ) iter_file = repo.tree().traverse() diff --git a/sharelatex/tests/test_cli.py b/sharelatex/tests/test_cli.py index db9278071707376ffdaa769f41fc36374997b61e..84ea0228de23b5f3675f70e971d274ef512d718a 100644 --- a/sharelatex/tests/test_cli.py +++ b/sharelatex/tests/test_cli.py @@ -6,7 +6,7 @@ from subprocess import check_call import tempfile import unittest -from sharelatex import SyncClient, walk_project_data +from sharelatex import SyncClient, walk_project_data, get_authenticator_class from ddt import ddt, data, unpack @@ -17,6 +17,7 @@ logging.basicConfig(level=logging.DEBUG) BASE_URL = os.environ.get("CI_BASE_URL") USERNAMES = os.environ.get("CI_USERNAMES") PASSWORDS = os.environ.get("CI_PASSWORDS") +AUTH_TYPE = os.environ.get("CI_AUTH_TYPE") # Operate with a list of users # This workarounds the rate limitation on the API if enough usernames and passwords are given @@ -24,10 +25,12 @@ PASSWORDS = os.environ.get("CI_PASSWORDS") # An alternative would be to define a smoke user in the settings # settings.smokeTest = True, settings.smokeTest.UserId import queue + CREDS = queue.Queue() for username, passwords in zip(USERNAMES.split(","), PASSWORDS.split(",")): CREDS.put((username, passwords)) + def log(f): def wrapped(*args, **kwargs): print("-" * 60) @@ -77,8 +80,14 @@ class Project: @contextmanager def project(project_name, branch=None): """A convenient contextmanager to create a temporary project on sharelatex.""" + + # First we create a client. + # For testing purpose we disable SSL verification everywhere username, password = CREDS.get() - client = SyncClient(base_url=BASE_URL, username=username, password=password) + authenticator = get_authenticator_class(AUTH_TYPE)( + BASE_URL, username, password, verify=False + ) + client = SyncClient(base_url=BASE_URL, authenticator=authenticator, verify=False) with tempfile.TemporaryDirectory() as temp_path: os.chdir(temp_path) r = client.new(project_name) @@ -88,7 +97,7 @@ def project(project_name, branch=None): project = Project(client, project_id, fs_path) # let's clone it - args = f"--username={username} --password={password} --save-password" + args = f"--auth_type={AUTH_TYPE} --username={username} --password={password} --save-password --no-https-cert-check" check_call(f"git slatex clone {project.url} {args}", shell=True) os.chdir(project.fs_path) check_call("git config --local user.email 'test@test.com'", shell=True) @@ -107,7 +116,7 @@ def project(project_name, branch=None): def new_project(branch=None): def _new_project(f): """A convenient decorator to launch a function in the - context of a new project.""" + context of a new project.""" def wrapped(*args, **kwargs): with project(f.__name__, branch=branch) as p: diff --git a/test-requirements.txt b/test-requirements.txt index fcd4abff311605f33f467b8d53385641a1a87307..1bfd8ce650df159f192b09b6f1ada6b0e1755310 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,4 +4,4 @@ sphinx-rtd-theme>=0.2.4 pytest sphinx-click keyrings.alt -ddt \ No newline at end of file +ddt