diff --git a/docs/sources/authentication.md b/docs/sources/authentication.md new file mode 100644 index 0000000000000000000000000000000000000000..fe948ce2c7d0f30b8283573e6a1d1c2d5d5a8dd4 --- /dev/null +++ b/docs/sources/authentication.md @@ -0,0 +1,28 @@ +# Access your forge + +`Travo` goal is to easily interact with a forge, for that, before starting using +`Travo`, it is essential to verify that the forge is accessible and the user can +successfully authenticate into it. + +`Travo` communicates with the forge via securized http transactions. +It supports two types of authentication: +- [basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) via user and password credentials; +- [personal access token](https://en.wikipedia.org/wiki/Personal_access_token) +authentication. + +### Password authentication +`Travo` asks for user and password in order to authenticate. + +Please note that the user should have authenticate into the forge at least once for `Travo` +to log in successfully. + +### Personal access token authentication +Personal access token (PAT) authentication is required when two-factor authentication (2FA) +or SAML Single Sign-On (SSO) are enabled on the forge: users authenticate with a personal +access token in place of the password. +Username is not evaluated as part of the authentication process. + +The user needs to create a Personal Access Token, first, as described, for example, +in the [related gitlab documentation](https://docs.gitlab.com/user/profile/personal_access_tokens/#create-a-personal-access-token). +For `Travo` to be able to interact with the forge the [scope](https://docs.gitlab.com/user/profile/personal_access_tokens/#personal-access-token-scopes) +of the token should be set to `api`. \ No newline at end of file diff --git a/docs/sources/changelog.md b/docs/sources/changelog.md index 7da13919def332c836695d267ea0a86546e3df8f..4321f00adae23aaa15396540a0fe8ed99eb99a02 100644 --- a/docs/sources/changelog.md +++ b/docs/sources/changelog.md @@ -1,5 +1,13 @@ # What's new? +## Version 2.0.0 + +### Features + +- Authentification via personal access token is now possible. + This is mandatory when double authentication or Single-Sign-On + is enabled on the forge + ## Version 1.1.4 ### Bug fixes diff --git a/docs/sources/index.md b/docs/sources/index.md index 4121795e79466cf8b9659033d0187ce0fe50a51b..0b93d85bbb4cc015cbe2c79035ea91567acc75bd 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -155,6 +155,7 @@ hidden: --- changelog install +authentication quickstart_tutorial.rst Assignment Tutorial Advanced course tutorial diff --git a/pyproject.toml b/pyproject.toml index 2e6888bb94e2210eb42d11159a3636fa4f092fc5..a4f5efb9932da1d32d682dc3e63ed8ac644ffb91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -179,6 +179,9 @@ docstring-code-format = false # enabled. docstring-code-line-length = "dynamic" +[tool.coverage.run] +omit = ["travo/tests/*"] + [tool.tox] legacy_tox_ini = """ [tox] diff --git a/travo/__init__.py b/travo/__init__.py index 4419e455f7e84b0006707b294d15dcb4c9b45e12..e0be66915e0982f6f17729f8b42b921a8e2bdd0a 100644 --- a/travo/__init__.py +++ b/travo/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.2.0" +__version__ = "2.0.0" try: from ._version import __version__ diff --git a/travo/gitlab.py b/travo/gitlab.py index b5f5e051ff9aed304aeeccf64c3a25ab043be65a..e7dfd70cef58963727c1bb27e15d32d9880b71e4 100644 --- a/travo/gitlab.py +++ b/travo/gitlab.py @@ -41,6 +41,8 @@ R = TypeVar("R", "Group", "Project", "Namespace", "User") # Job = TypeAlias(JSON) # Python 3.10 JSON = Any # Could be made more specific Job = JSON # Could be made more specific +JWT: str = "JWT" # Json Web Token type +PERSONAL_ACCESS: str = "PERSONAL_ACCESS" # Personal Access Token type class ResourceNotFoundError(RuntimeError): @@ -71,6 +73,7 @@ class GitLab: debug: bool = False home_dir: str = str(pathlib.Path.home()) token: Optional[str] = None + token_type: Optional[str] = None token_expires_at: Optional[float] = None _current_user: Optional[Union["User", "AnonymousUser"]] = None base_url: str @@ -88,6 +91,7 @@ class GitLab: self, base_url: str, token: Optional[str] = None, + token_type: Optional[str] = JWT, log: logging.Logger = getLogger(), home_dir: Optional[str] = None, ): @@ -97,6 +101,7 @@ class GitLab: self.api = base_url + "api/v4/" self.session = requests.Session() self.token = None + self.token_type = token_type self.log = log self.on_missing_credentials = request_credentials_basic if home_dir is not None: @@ -123,24 +128,28 @@ class GitLab: @return whether the token is valid """ - t = time.time() - response = self.session.get( - self.base_url + "/oauth/token/info", data=dict(access_token=token) - ) - try: - json = response.json() - except requests.HTTPError: - response.raise_for_status() - if "error" in json: - assert json["error"] == "invalid_token" - self.log.info( - _("invalid token", error_description=json["error_description"]) + if self.token_type == JWT: + t = time.time() + response = self.session.get( + self.base_url + "/oauth/token/info", data=dict(access_token=token) ) - return False + try: + json = response.json() + except requests.HTTPError: + response.raise_for_status() + if "error" in json: + assert json["error"] == "invalid_token" + self.log.info( + _("invalid token", error_description=json["error_description"]) + ) + return False + + self.session.headers.update({"Authorization": f"Bearer {token}"}) + self.token_expires_at = t + json["expires_in"] + elif self.token_type == PERSONAL_ACCESS: + self.session.headers.update({"PRIVATE-TOKEN": f"{token}"}) - self.session.headers.update({"Authorization": f"Bearer {token}"}) self.token = token - self.token_expires_at = t + json["expires_in"] if not nosave: os.makedirs(os.path.dirname(self.token_file()), exist_ok=True) with os.fdopen( @@ -161,6 +170,8 @@ class GitLab: del self.session.headers["Authorization"] if "TRAVO_TOKEN" in os.environ: del os.environ["TRAVO_TOKEN"] + if "PRIVATE-TOKEN" in self.session.headers: + del self.session.headers["PRIVATE-TOKEN"] token_file = self.token_file() # Testing whether the file exists before removing it is not # atomic; so just try to remove it. @@ -182,10 +193,13 @@ class GitLab: - If this session is already authenticated, do nothing - If a token file exists, load the token from there, and set it from now on. - - Otherwise request a new token through login and password - authentication, and set it. Unless the credentials are - passed as arguments, they are requested interactively by the - user. The token is stored in a token file for future reuse. + - Otherwise if the `token_type` is set to "JWT" request a new token + through login and password authentication, and set it. + - if the `token_type` is set to "PERSONAL_ACCESS" request to input + the personal access token. + - Unless the credentials are passed as arguments, they are requested + interactively by the user. + The token is stored in a token file for future reuse. In case of failure, for example, because no credentials are provided and `self.interactive` is `False`, an @@ -213,6 +227,12 @@ class GitLab: Login. Here the credentials are passed as parameters; they typically are instead entered interactively by the user: + >>> gitlab.login(username="student1", + ... password="aqwzsx(") + Traceback (most recent call last): + ... + AuthenticationError: Authentication failed; invalid username or password? + >>> gitlab.login(username="student1", ... password="aqwzsx(t1") @@ -246,7 +266,7 @@ class GitLab: {'emoji': None, 'message': None, 'availability': None, 'message_html': ''... """ - if self.token is not None: + if self.token is not None and self.token_type == JWT: assert self.token_expires_at is not None if time.time() < self.token_expires_at - 60: # Assumption: the token is valid; unless the token has @@ -275,28 +295,43 @@ class GitLab: self.logout() # No token available - if username is None: - if self._current_user is anonymous_user and anonymous_ok: + if self.token_type == JWT: + if username is None: + if self._current_user is anonymous_user and anonymous_ok: + return + username, password = self.on_missing_credentials( + forge=self, username=username, password=password + ) + if username is not None and username == "anonymous" and anonymous_ok: + self._current_user = anonymous_user return - username, password = self.on_missing_credentials( - forge=self, username=username, password=password - ) - if username is not None and username == "anonymous" and anonymous_ok: - self._current_user = anonymous_user - return - result = self.session.post( - self.base_url + "/oauth/token", - params=dict( - grant_type="password", username=username, password=password, scope="api" - ), - ) - token = result.json().get("access_token") - # TODO: handle connection failures - if token is None: - # TODO: pourrait réessayer - raise AuthenticationError(_("invalid credentials", url=self.base_url)) - assert self.set_token(token) + result = self.session.post( + self.base_url + "/oauth/token", + params=dict( + grant_type="password", + username=username, + password=password, + scope="api", + ), + ) + token = result.json().get("access_token") + # TODO: handle connection failures + if token is None: + # TODO: pourrait réessayer + raise AuthenticationError(_("invalid credentials", url=self.base_url)) + assert self.set_token(token) + + elif self.token_type == PERSONAL_ACCESS: + print( + _("personal access token") + + ".\n" + + _("no personal access token") + + "\n" + + f"`{self.base_url}/-/user_settings/personal_access_tokens`" + ) + token = getpass.getpass(_("personal access token") + ": ") + assert self.set_token(token) def get(self, path: str, data: dict = {}) -> requests.Response: """Issue a GET request to the forge's API""" diff --git a/travo/locale/travo.en.yml b/travo/locale/travo.en.yml index e702f0625e2b79e0103ed740cb18dc7922161399..779777b1ba8b689dedaf3d9998c10ea3269b6fc0 100644 --- a/travo/locale/travo.en.yml +++ b/travo/locale/travo.en.yml @@ -47,10 +47,10 @@ en: show command log: "Show the log of the commands" command log level: "Configure the log level of the commands" student group: "Student group" - invalid credentials: "Authentification failed; invalid username or password?" + invalid credentials: "Authentication failed; invalid username or password?" invalid token environment variable: "Invalid or expired TRAVO_TOKEN environment variable." sign in: "Sign in" - authentication required: "Authentification required" + authentication required: "Authentication required" abort conflicting merge: "Integrating %{content} would cause a conflit; abandon" updates: "updates" erratas: erratas @@ -128,3 +128,5 @@ en: try to merge: "Trying to merge" no content: "No %{content}" corrupted assignment directory: "Corrupted assignment directory" + personal access token: "Enter your personal access token" + no personal access token: "If you don't have one, please create it from" diff --git a/travo/locale/travo.fr.yml b/travo/locale/travo.fr.yml index 04a09f729c549e4080de6652c22e4c54b9da7921..073535dd506678679f607d73501d743047e3e6a1 100644 --- a/travo/locale/travo.fr.yml +++ b/travo/locale/travo.fr.yml @@ -128,3 +128,5 @@ fr: initializing submission: "Initialisation de la soumission à partir du devoir" your submission: "Votre submission" corrupted assignment directory: "Corrupted assignment directory" + personal access token: "Tapez votre jeton d'accès personnel" + no personal access token: "Si vous n'en avez pas, veuillez en créer un à partir de" diff --git a/travo/tests/test_console_scripts.py b/travo/tests/test_console_scripts.py index c13a8e8d6809d51593e978ca56c84570d5444b9a..e8cae96e69a20923e3abd3699870bafab070b131 100644 --- a/travo/tests/test_console_scripts.py +++ b/travo/tests/test_console_scripts.py @@ -1,6 +1,6 @@ import pytest import os -from travo.console_scripts import Travo +from travo.console_scripts import Travo, travo_echo_travo_token @pytest.mark.parametrize("embed_option", [False, True]) @@ -14,3 +14,13 @@ def test_quickstart(embed_option, tmp_path): clab_dir = course_dir assert os.path.isdir(course_dir + "/Instructors") assert os.path.isdir(clab_dir + "/ComputerLab") + + +def test_echo_token(capsys): + personal_token = "personal_token" + os.environ["TRAVO_TOKEN"] = personal_token + travo_echo_travo_token() + captured = capsys.readouterr().out.rstrip() + assert captured == personal_token + + del os.environ["TRAVO_TOKEN"] diff --git a/travo/tests/test_gitlab.py b/travo/tests/test_gitlab.py index bfc4a9868e01e109f17fdcfda06caeb2a899a9d5..531c3b86037df304adc2db9388de811bb94f3e7e 100644 --- a/travo/tests/test_gitlab.py +++ b/travo/tests/test_gitlab.py @@ -9,8 +9,8 @@ def test_request_credentials(monkeypatch: pytest.MonkeyPatch) -> None: forge = gitlab.GitLab(base_url="https://gitlab.example.com") inputs = iter(["travo-test-etu", "aqwzsx(t1"]) with pytest.raises(OSError, match="reading from stdin while output is captured"): - user, passwd = gitlab.request_credentials_basic(forge) monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + user, passwd = gitlab.request_credentials_basic(forge) assert user == "travo-test-etu" assert passwd == "aqwzsx(t1" @@ -24,6 +24,29 @@ def test_request_credentials(monkeypatch: pytest.MonkeyPatch) -> None: assert password == "" +def test_login_with_personal_access(monkeypatch: pytest.MonkeyPatch) -> None: + forge = gitlab.GitLab( + base_url="https://gitlab.example.com", token_type="PERSONAL_ACCESS" + ) + forge.logout() + inputs = iter(["personal_token"]) + monkeypatch.setattr(getpass, "getpass", lambda _: next(inputs)) + forge.login() + assert forge.token == "personal_token" + forge.logout() + + +def test_set_token() -> None: + forge = gitlab.GitLab( + base_url="https://gitlab.example.com", token_type="PERSONAL_ACCESS" + ) + personal_token = "personal_token" + forge.set_token(personal_token) + assert forge.session.headers["PRIVATE-TOKEN"] == personal_token + forge.logout() + assert "PRIVATE-TOKEN" not in forge.session.headers.keys() + + def test_token_requests(gitlab_url: str) -> None: forge = gitlab.GitLab(base_url=gitlab_url) assert not forge.set_token("*Ae", nosave=True) @@ -33,11 +56,27 @@ def test_token_requests(gitlab_url: str) -> None: forge.set_token("very_secret_token", nosave=True) -def test_login_logout(gitlab_url: str) -> None: +def test_login_username_none(gitlab_url: str, monkeypatch: pytest.MonkeyPatch) -> None: + forge = gitlab.GitLab(base_url=gitlab_url) + forge.logout() + + inputs = iter(["travo-test-etu", "aqwzsx(t1"]) + with pytest.raises(OSError, match="reading from stdin while output is captured"): + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + forge.login() + token = forge.token + assert token is not None + forge.logout() + + +def test_login_anonymous(gitlab_url: str) -> None: forge = gitlab.GitLab(base_url=gitlab_url) forge.login(username="anonymous", anonymous_ok=True) token = forge.token assert token is None + forge.login(anonymous_ok=True) + token = forge.token + assert token is None def test_get_user_nousername(gitlab):