diff options
-rw-r--r-- | docs/api-usage.rst | 28 | ||||
-rw-r--r-- | docs/cli-usage.rst | 3 | ||||
-rw-r--r-- | gitlab/client.py | 48 | ||||
-rw-r--r-- | gitlab/oauth.py | 33 | ||||
-rw-r--r-- | tests/functional/api/test_gitlab.py | 8 | ||||
-rw-r--r-- | tests/unit/test_gitlab_auth.py | 63 | ||||
-rw-r--r-- | tests/unit/test_oauth.py | 27 |
7 files changed, 181 insertions, 29 deletions
diff --git a/docs/api-usage.rst b/docs/api-usage.rst index 2e7f5c6..d3af5ae 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -84,14 +84,32 @@ Note on password authentication GitLab has long removed password-based basic authentication. You can currently still use the `resource owner password credentials <https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow>`_ -flow to obtain an OAuth token. +flow and python-gitlab will obtain an OAuth token for you when instantiated. However, we do not recommend this as it will not work with 2FA enabled, and GitLab is removing -ROPC-based flows without client IDs in a future release. We recommend you obtain tokens for -automated workflows as linked above or obtain a session cookie from your browser. +ROPC-based flows without client credentials in a future release. We recommend you obtain tokens for +automated workflows. -For a python example of password authentication using the ROPC-based OAuth2 -flow, see `this Ansible snippet <https://github.com/ansible-collections/community.general/blob/1c06e237c8100ac30d3941d5a3869a4428ba2974/plugins/module_utils/gitlab.py#L86-L92>`_. +.. code-block:: python + + import gitlab + from gitlab.oauth import PasswordCredentials + + oauth_credentials = PasswordCredentials("username", "password") + gl = gitlab.Gitlab(oauth_credentials=oauth_credentials) + + # Define a specific OAuth scope + oauth_credentials = PasswordCredentials("username", "password", scope="read_api") + gl = gitlab.Gitlab(oauth_credentials=oauth_credentials) + + # Use with client credentials + oauth_credentials = PasswordCredentials( + "username", + "password", + client_id="your-client-id", + client_secret="your-client-secret", + ) + gl = gitlab.Gitlab(oauth_credentials=oauth_credentials) Managers ======== diff --git a/docs/cli-usage.rst b/docs/cli-usage.rst index c728221..ee2627d 100644 --- a/docs/cli-usage.rst +++ b/docs/cli-usage.rst @@ -168,8 +168,7 @@ We recommend that you use `Credential helpers`_ to securely store your tokens. <https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html>`__ to learn how to obtain a token. * - ``oauth_token`` - - An Oauth token for authentication. The Gitlab server must be configured - to support this authentication method. + - An Oauth token for authentication. * - ``job_token`` - Your job token. See `the official documentation <https://docs.gitlab.com/ce/api/jobs.html#get-job-artifacts>`__ diff --git a/gitlab/client.py b/gitlab/client.py index c3982f3..0196a8f 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -12,7 +12,7 @@ import gitlab import gitlab.config import gitlab.const import gitlab.exceptions -from gitlab import _backends, utils +from gitlab import _backends, oauth, utils REDIRECT_MSG = ( "python-gitlab detected a {status_code} ({reason!r}) redirection. You must update " @@ -41,8 +41,8 @@ class Gitlab: the value is a string, it is the path to a CA file used for certificate validation. timeout: Timeout to use for requests to the GitLab server. - http_username: Username for HTTP authentication - http_password: Password for HTTP authentication + http_username: Username for OAuth ROPC flow (deprecated, use oauth_credentials) + http_password: Password for OAuth ROPC flow (deprecated, use oauth_credentials) api_version: Gitlab API version to use (support for 4 only) pagination: Can be set to 'keyset' to use keyset pagination order_by: Set order_by globally @@ -51,6 +51,7 @@ class Gitlab: or 52x responses. Defaults to False. keep_base_url: keep user-provided base URL for pagination if it differs from response headers + oauth_credentials: Password credentials for authenticating via OAuth ROPC flow Keyword Args: requests.Session session: HTTP Requests Session @@ -74,6 +75,8 @@ class Gitlab: user_agent: str = gitlab.const.USER_AGENT, retry_transient_errors: bool = False, keep_base_url: bool = False, + *, + oauth_credentials: Optional[oauth.PasswordCredentials] = None, **kwargs: Any, ) -> None: self._api_version = str(api_version) @@ -96,7 +99,7 @@ class Gitlab: self.http_password = http_password self.oauth_token = oauth_token self.job_token = job_token - self._set_auth_info() + self.oauth_credentials = oauth_credentials #: Create a session object for requests _backend: Type[_backends.DefaultBackend] = kwargs.pop( @@ -105,6 +108,7 @@ class Gitlab: self._backend = _backend(**kwargs) self.session = self._backend.client + self._set_auth_info() self.per_page = per_page self.pagination = pagination self.order_by = order_by @@ -522,22 +526,48 @@ class Gitlab: self.headers.pop("Authorization", None) self.headers["PRIVATE-TOKEN"] = self.private_token self.headers.pop("JOB-TOKEN", None) + return + + if not self.oauth_credentials and (self.http_username and self.http_password): + utils.warn( + "Passing http_username and http_password is deprecated and will be " + "removed in a future version.\nPlease use the OAuth ROPC flow with" + "(gitlab.oauth.PasswordCredentials) if you need password-based" + "authentication. See https://docs.gitlab.com/ee/api/oauth2.html" + "#resource-owner-password-credentials-flow for more details.", + category=DeprecationWarning, + ) + self.oauth_credentials = oauth.PasswordCredentials( + self.http_username, self.http_password + ) + + if self.oauth_credentials: + post_data = { + "grant_type": self.oauth_credentials.grant_type, + "scope": self.oauth_credentials.scope, + "username": self.oauth_credentials.username, + "password": self.oauth_credentials.password, + } + response = self.http_post( + f"{self._base_url}/oauth/token", post_data=post_data + ) + if isinstance(response, dict): + self.oauth_token = response["access_token"] + else: + self.oauth_token = response.json()["access_token"] + self._http_auth = self.oauth_credentials.basic_auth if self.oauth_token: self.headers["Authorization"] = f"Bearer {self.oauth_token}" self.headers.pop("PRIVATE-TOKEN", None) self.headers.pop("JOB-TOKEN", None) + return if self.job_token: self.headers.pop("Authorization", None) self.headers.pop("PRIVATE-TOKEN", None) self.headers["JOB-TOKEN"] = self.job_token - if self.http_username: - self._http_auth = requests.auth.HTTPBasicAuth( - self.http_username, self.http_password - ) - @staticmethod def enable_debug() -> None: import logging diff --git a/gitlab/oauth.py b/gitlab/oauth.py new file mode 100644 index 0000000..f4fecf8 --- /dev/null +++ b/gitlab/oauth.py @@ -0,0 +1,33 @@ +import dataclasses +from typing import Optional + + +@dataclasses.dataclass +class PasswordCredentials: + """ + Resource owner password credentials modelled according to + https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow + https://datatracker.ietf.org/doc/html/rfc6749#section-4-3. + + If the GitLab server has disabled the ROPC flow without client credentials, + client_id and client_secret must be provided. + """ + + username: str + password: str + grant_type: str = "password" + scope: str = "api" + client_id: Optional[str] = None + client_secret: Optional[str] = None + + def __post_init__(self) -> None: + basic_auth = (self.client_id, self.client_secret) + + if not any(basic_auth): + self.basic_auth = None + return + + if not all(basic_auth): + raise TypeError("Both client_id and client_secret must be defined") + + self.basic_auth = basic_auth diff --git a/tests/functional/api/test_gitlab.py b/tests/functional/api/test_gitlab.py index ced77c2..bde64d3 100644 --- a/tests/functional/api/test_gitlab.py +++ b/tests/functional/api/test_gitlab.py @@ -2,6 +2,7 @@ import pytest import requests import gitlab +from gitlab.oauth import PasswordCredentials @pytest.fixture( @@ -22,6 +23,13 @@ def test_auth_from_config(gl, gitlab_config, temp_dir): assert isinstance(test_gitlab.user, gitlab.v4.objects.CurrentUser) +def test_auth_with_ropc_flow(gl, temp_dir): + oauth_credentials = PasswordCredentials("root", "5iveL!fe") + test_gitlab = gitlab.Gitlab(gl.url, oauth_credentials=oauth_credentials) + test_gitlab.auth() + assert isinstance(test_gitlab.user, gitlab.v4.objects.CurrentUser) + + def test_no_custom_session(gl, temp_dir): """Test no custom session""" custom_session = requests.Session() diff --git a/tests/unit/test_gitlab_auth.py b/tests/unit/test_gitlab_auth.py index 8d6677f..3e0c87d 100644 --- a/tests/unit/test_gitlab_auth.py +++ b/tests/unit/test_gitlab_auth.py @@ -1,8 +1,35 @@ import pytest -import requests +import responses from gitlab import Gitlab from gitlab.config import GitlabConfigParser +from gitlab.oauth import PasswordCredentials + + +# /oauth/token endpoint might be missing correct content-type header +@pytest.fixture(params=["application/json", None]) +def resp_oauth_token(gl: Gitlab, request: pytest.FixtureRequest): + ropc_payload = { + "username": "foo", + "password": "bar", + "grant_type": "password", + "scope": "api", + } + ropc_response = { + "access_token": "test-token", + "token_type": "bearer", + "expires_in": 7200, + } + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url=f"{gl._base_url}/oauth/token", + status=201, + match=[responses.matchers.json_params_matcher(ropc_payload)], + json=ropc_response, + content_type=request.param, + ) + yield rsps def test_invalid_auth_args(): @@ -42,7 +69,6 @@ def test_private_token_auth(): assert gl.private_token == "private_token" assert gl.oauth_token is None assert gl.job_token is None - assert gl._http_auth is None assert "Authorization" not in gl.headers assert gl.headers["PRIVATE-TOKEN"] == "private_token" assert "JOB-TOKEN" not in gl.headers @@ -53,7 +79,6 @@ def test_oauth_token_auth(): assert gl.private_token is None assert gl.oauth_token == "oauth_token" assert gl.job_token is None - assert gl._http_auth is None assert gl.headers["Authorization"] == "Bearer oauth_token" assert "PRIVATE-TOKEN" not in gl.headers assert "JOB-TOKEN" not in gl.headers @@ -64,26 +89,38 @@ def test_job_token_auth(): assert gl.private_token is None assert gl.oauth_token is None assert gl.job_token == "CI_JOB_TOKEN" - assert gl._http_auth is None assert "Authorization" not in gl.headers assert "PRIVATE-TOKEN" not in gl.headers assert gl.headers["JOB-TOKEN"] == "CI_JOB_TOKEN" -def test_http_auth(): +def test_oauth_resource_password_auth(resp_oauth_token): + oauth_credentials = PasswordCredentials("foo", "bar") gl = Gitlab( "http://localhost", - private_token="private_token", - http_username="foo", - http_password="bar", api_version="4", + oauth_credentials=oauth_credentials, ) - assert gl.private_token == "private_token" - assert gl.oauth_token is None + assert gl.oauth_token == "test-token" + assert gl.private_token is None assert gl.job_token is None - assert isinstance(gl._http_auth, requests.auth.HTTPBasicAuth) - assert gl.headers["PRIVATE-TOKEN"] == "private_token" - assert "Authorization" not in gl.headers + assert "Authorization" in gl.headers + assert "PRIVATE-TOKEN" not in gl.headers + + +def test_oauth_resource_password_auth_with_legacy_params_warns(resp_oauth_token): + with pytest.warns(DeprecationWarning, match="use the OAuth ROPC flow"): + gl = Gitlab( + "http://localhost", + http_username="foo", + http_password="bar", + api_version="4", + ) + assert gl.oauth_token == "test-token" + assert gl.private_token is None + assert gl.job_token is None + assert "Authorization" in gl.headers + assert "PRIVATE-TOKEN" not in gl.headers @pytest.mark.parametrize( diff --git a/tests/unit/test_oauth.py b/tests/unit/test_oauth.py new file mode 100644 index 0000000..ecc256b --- /dev/null +++ b/tests/unit/test_oauth.py @@ -0,0 +1,27 @@ +import pytest + +from gitlab.oauth import PasswordCredentials + + +def test_password_credentials_without_password_raises(): + with pytest.raises(TypeError, match="missing 1 required positional argument"): + PasswordCredentials("username") + + +def test_password_credentials_with_client_id_without_client_secret_raises(): + with pytest.raises(TypeError, match="client_id and client_secret must be defined"): + PasswordCredentials( + "username", + "password", + client_id="abcdef123456", + ) + + +def test_password_credentials_with_client_credentials_sets_basic_auth(): + credentials = PasswordCredentials( + "username", + "password", + client_id="abcdef123456", + client_secret="123456abcdef", + ) + assert credentials.basic_auth == ("abcdef123456", "123456abcdef") |