From 99a1af21e150ab7441d1d07c86db228998ca6ffa Mon Sep 17 00:00:00 2001
From: Dylann CORDEL <cordel.d@free.fr>
Date: Wed, 22 Sep 2021 15:40:38 +0200
Subject: [PATCH] Add support for clientlogin (#242)

---
 mwclient/client.py  |  82 ++++++++++++++++++++++++++++++++-
 test/test_client.py | 108 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 189 insertions(+), 1 deletion(-)

diff --git a/mwclient/client.py b/mwclient/client.py
index 8561fda..f5482b7 100644
--- a/mwclient/client.py
+++ b/mwclient/client.py
@@ -505,8 +505,19 @@ class Site(object):
 
     def login(self, username=None, password=None, cookies=None, domain=None):
         """
-        Login to the wiki using a username and password. The method returns
+        Login to the wiki using a username and bot password. The method returns
         nothing if the login was successful, but raises and error if it was not.
+        If you use mediawiki >= 1.27 and try to login with normal account
+        (not botpassword account), you should use `clientlogin` instead, because login
+        action is deprecated since 1.27 with normal account and will stop
+        working in the near future. See these pages to learn more:
+            - https://www.mediawiki.org/wiki/API:Login and
+            - https://www.mediawiki.org/wiki/Manual:Bot_passwords
+
+        Note: at least until v1.33.1, botpasswords accounts seem to not have
+              "userrights" permission. If you need to update user's groups,
+              this permission is required so you must use `client login`
+              with a user who has userrights permission (a bureaucrat for eg.).
 
         Args:
             username (str): MediaWiki username
@@ -566,6 +577,75 @@ class Site(object):
 
         self.site_init()
 
+    def clientlogin(self, cookies=None, **kwargs):
+        """
+        Login to the wiki using a username and password. The method returns
+        True if it's a success or the returned response if it's a multi-steps
+        login process you started. In case of failure it raises some Errors.
+
+        Example for classic username / password clientlogin request:
+            >>> try:
+            ...     site.clientlogin(username='myusername', password='secret')
+            ... except mwclient.errors.LoginError as e:
+            ...     print('Could not login to MediaWiki: %s' % e)
+
+        Args:
+            cookies (dict): Custom cookies to include with the log-in request.
+            **kwargs (dict): Custom vars used for clientlogin as:
+                - loginmergerequestfields
+                - loginpreservestate
+                - loginreturnurl,
+                - logincontinue
+                - logintoken
+                - *: additional params depending on the available auth requests.
+                     to log with classic username / password, you need to add
+                     `username` and `password`
+                See https://www.mediawiki.org/wiki/API:Login#Method_2._clientlogin
+
+        Raises:
+            LoginError (mwclient.errors.LoginError): Login failed, the reason can be
+                obtained from e.code and e.info (where e is the exception object) and
+                will be one of the API:Login errors. The most common error code is
+                "Failed", indicating a wrong username or password.
+
+            MaximumRetriesExceeded: API call to log in failed and was retried until all
+                retries were exhausted. This will not occur if the credentials are merely
+                incorrect. See MaximumRetriesExceeded for possible reasons.
+
+            APIError: An API error occurred. Rare, usually indicates an internal server
+                error.
+        """
+
+        self.require(1, 27)
+
+        if cookies:
+            self.connection.cookies.update(cookies)
+
+        if kwargs:
+            # Try to login using the scheme for MW 1.27+. If the wiki is read protected,
+            # it is not possible to get the wiki version upfront using the API, so we just
+            # have to try. If the attempt fails, we try the old method.
+            if 'logintoken' not in kwargs:
+                try:
+                    kwargs['logintoken'] = self.get_token('login')
+                except (errors.APIError, KeyError):
+                    log.debug('Failed to get login token, MediaWiki is older than 1.27.')
+
+            if 'logincontinue' not in kwargs and 'loginreturnurl' not in kwargs:
+                # should be great if API didn't require this...
+                kwargs['loginreturnurl'] = '%s://%s' % (self.scheme, self.host)
+
+            while True:
+                login = self.post('clientlogin', **kwargs)
+                status = login['clientlogin'].get('status')
+                if status == 'PASS':
+                    return True
+                elif status in ('UI', 'REDIRECT'):
+                    return login['clientlogin']
+                else:
+                    raise errors.LoginError(self, status,
+                                            login['clientlogin'].get('message'))
+
     def get_token(self, type, force=False, title=None):
 
         if self.version is None or self.version[:2] >= (1, 24):
diff --git a/test/test_client.py b/test/test_client.py
index e6528e7..67f1c90 100644
--- a/test/test_client.py
+++ b/test/test_client.py
@@ -465,6 +465,114 @@ class TestLogin(TestCase):
         assert call_args[0] == mock.call('query', 'GET', meta='tokens', type='login')
         assert call_args[1] == mock.call('login', 'POST', lgname='myusername', lgpassword='mypassword', lgtoken=login_token)
 
+    @mock.patch('mwclient.client.Site.site_init')
+    @mock.patch('mwclient.client.Site.raw_api')
+    def test_clientlogin_success(self, raw_api, site_init):
+        login_token = 'abc+\\'
+
+        def side_effect(*args, **kwargs):
+            if kwargs.get('meta') == 'tokens':
+                return {
+                    'query': {'tokens': {'logintoken': login_token}}
+                }
+            elif 'username' in kwargs:
+                assert kwargs['logintoken'] == login_token
+                assert kwargs.get('loginreturnurl')
+                return {
+                    'clientlogin': {'status': 'PASS'}
+                }
+
+        raw_api.side_effect = side_effect
+
+        site = mwclient.Site('test.wikipedia.org')
+        success = site.clientlogin(username='myusername', password='mypassword')
+        url = '%s://%s' % (site.scheme, site.host)
+
+        call_args = raw_api.call_args_list
+
+        assert success is True
+        assert len(call_args) == 2
+        assert call_args[0] == mock.call('query', 'GET', meta='tokens', type='login')
+        assert call_args[1] == mock.call(
+            'clientlogin', 'POST',
+            username='myusername',
+            password='mypassword',
+            loginreturnurl=url,
+            logintoken=login_token
+        )
+
+    @mock.patch('mwclient.client.Site.site_init')
+    @mock.patch('mwclient.client.Site.raw_api')
+    def test_clientlogin_fail(self, raw_api, site_init):
+        login_token = 'abc+\\'
+
+        def side_effect(*args, **kwargs):
+            if kwargs.get('meta') == 'tokens':
+                return {
+                    'query': {'tokens': {'logintoken': login_token}}
+                }
+            elif 'username' in kwargs:
+                assert kwargs['logintoken'] == login_token
+                assert kwargs.get('loginreturnurl')
+                return {
+                    'clientlogin': {'status': 'FAIL'}
+                }
+
+        raw_api.side_effect = side_effect
+
+        site = mwclient.Site('test.wikipedia.org')
+
+        with pytest.raises(mwclient.errors.LoginError):
+            success = site.clientlogin(username='myusername', password='mypassword')
+
+        call_args = raw_api.call_args_list
+
+        assert len(call_args) == 2
+        assert call_args[0] == mock.call('query', 'GET', meta='tokens', type='login')
+        assert call_args[1] == mock.call(
+            'clientlogin', 'POST',
+            username='myusername',
+            password='mypassword',
+            loginreturnurl='%s://%s' % (site.scheme, site.host),
+            logintoken=login_token
+        )
+
+    @mock.patch('mwclient.client.Site.site_init')
+    @mock.patch('mwclient.client.Site.raw_api')
+    def test_clientlogin_continue(self, raw_api, site_init):
+        login_token = 'abc+\\'
+
+        def side_effect(*args, **kwargs):
+            if kwargs.get('meta') == 'tokens':
+                return {
+                    'query': {'tokens': {'logintoken': login_token}}
+                }
+            elif 'username' in kwargs:
+                assert kwargs['logintoken'] == login_token
+                assert kwargs.get('loginreturnurl')
+                return {
+                    'clientlogin': {'status': 'UI'}
+                }
+
+        raw_api.side_effect = side_effect
+
+        site = mwclient.Site('test.wikipedia.org')
+        success = site.clientlogin(username='myusername', password='mypassword')
+        url = '%s://%s' % (site.scheme, site.host)
+
+        call_args = raw_api.call_args_list
+
+        assert success == {'status': 'UI'}
+        assert len(call_args) == 2
+        assert call_args[0] == mock.call('query', 'GET', meta='tokens', type='login')
+        assert call_args[1] == mock.call(
+            'clientlogin', 'POST',
+            username='myusername',
+            password='mypassword',
+            loginreturnurl=url,
+            logintoken=login_token
+        )
+
 
 class TestClientApiMethods(TestCase):
 
-- 
GitLab