Repository: conan-io/conan

Issue:
[bug] authentication error is handled incorrectly in client.rest.uploader_downloader.FileDownloader
When installing a Conan package I've got this error message:
```
tools.get(**self.conan_data["sources"][self.version])
AttributeError: 'NoneType' object has no attribute 'token'
```
Obviously, the message isn't very helpful as it implies that there's a problem with the package recipe (there wasn't).

After some investigation I found out that the error ultimately happens in `client.rest.uploader_downloader.FileDownloader` when the download fails with a 403.
The method does the following check: `auth.token is None`. The issue is, `auth` is an optional parameter. Also it is effectively allowed to be a tuple according to documentation for `tools.get`. So, I assume the check should have been `not auth or not hasattr(auth, "token") or auth.token is None`.

Conan version: 1.21.0



Discussion:

Resolving patch:
diff --git a/conans/client/rest/uploader_downloader.py b/conans/client/rest/uploader_downloader.py
--- a/conans/client/rest/uploader_downloader.py
+++ b/conans/client/rest/uploader_downloader.py
@@ -45,7 +45,7 @@ def upload(self, url, abs_path, auth=None, dedup=False, retry=None, retry_wait=N
                 raise AuthenticationException(response_to_str(response))
 
             if response.status_code == 403:
-                if auth.token is None:
+                if auth is None or auth.token is None:
                     raise AuthenticationException(response_to_str(response))
                 raise ForbiddenException(response_to_str(response))
             if response.status_code == 201:  # Artifactory returns 201 if the file is there
@@ -86,7 +86,7 @@ def load_in_chunks(_file, size):
                     raise AuthenticationException(response_to_str(response))
 
                 if response.status_code == 403:
-                    if auth.token is None:
+                    if auth is None or auth.token is None:
                         raise AuthenticationException(response_to_str(response))
                     raise ForbiddenException(response_to_str(response))
 
@@ -157,7 +157,8 @@ def _download_file(self, url, auth, headers, file_path):
             if response.status_code == 404:
                 raise NotFoundException("Not found: %s" % url)
             elif response.status_code == 403:
-                if auth.token is None:
+                if auth is None or (hasattr(auth, "token") and auth.token is None):
+                    # TODO: This is a bit weird, why this conversion? Need to investigate
                     raise AuthenticationException(response_to_str(response))
                 raise ForbiddenException(response_to_str(response))
             elif response.status_code == 401:


Test patch:
diff --git a/conans/test/unittests/client/rest/uploader_test.py b/conans/test/unittests/client/rest/uploader_test.py
--- a/conans/test/unittests/client/rest/uploader_test.py
+++ b/conans/test/unittests/client/rest/uploader_test.py
@@ -10,54 +10,41 @@
 from conans.util.files import save
 
 
-class UploaderUnitTest(unittest.TestCase):
+class MockRequester(object):
+    retry = 0
+    retry_wait = 0
 
-    def test_401_raises_unauthoirzed_exception(self):
+    def __init__(self, response):
+        self._response = response
 
-        class MockRequester(object):
-            retry = 0
-            retry_wait = 0
+    def put(self, *args, **kwargs):
+        return namedtuple("response", "status_code content")(self._response, "tururu")
 
-            def put(self, *args, **kwargs):
-                return namedtuple("response", "status_code content")(401, "tururu")
 
-        out = TestBufferConanOutput()
-        uploader = FileUploader(MockRequester(), out, verify=False)
-        f = tempfile.mktemp()
-        save(f, "some contents")
+class UploaderUnitTest(unittest.TestCase):
+    def setUp(self):
+        self.f = tempfile.mktemp()
+        save(self.f, "some contents")
+        self.out = TestBufferConanOutput()
+
+    def test_401_raises_unauthoirzed_exception(self):
+        uploader = FileUploader(MockRequester(401), self.out, verify=False)
         with six.assertRaisesRegex(self, AuthenticationException, "tururu"):
-            uploader.upload("fake_url", f)
+            uploader.upload("fake_url", self.f)
 
     def test_403_raises_unauthoirzed_exception_if_no_token(self):
-
-        class MockRequester(object):
-            retry = 0
-            retry_wait = 0
-
-            def put(self, *args, **kwargs):
-                return namedtuple("response", "status_code content")(403, "tururu")
-
-        out = TestBufferConanOutput()
         auth = namedtuple("auth", "token")(None)
-        uploader = FileUploader(MockRequester(), out, verify=False)
-        f = tempfile.mktemp()
-        save(f, "some contents")
+        uploader = FileUploader(MockRequester(403), self.out, verify=False)
         with six.assertRaisesRegex(self, AuthenticationException, "tururu"):
-            uploader.upload("fake_url", f, auth=auth)
-
-    def test_403_raises_forbidden_exception_if_token(self):
+            uploader.upload("fake_url", self.f, auth=auth)
 
-        class MockRequester(object):
-            retry = 0
-            retry_wait = 0
-
-            def put(self, *args, **kwargs):
-                return namedtuple("response", "status_code content")(403, "tururu")
+    def test_403_raises_unauthorized_exception_if_no_auth(self):
+        uploader = FileUploader(MockRequester(403), self.out, verify=False)
+        with six.assertRaisesRegex(self, AuthenticationException, "tururu"):
+            uploader.upload("fake_url", self.f)
 
-        out = TestBufferConanOutput()
+    def test_403_raises_forbidden_exception_if_token(self):
         auth = namedtuple("auth", "token")("SOMETOKEN")
-        uploader = FileUploader(MockRequester(), out, verify=False)
-        f = tempfile.mktemp()
-        save(f, "some contents")
+        uploader = FileUploader(MockRequester(403), self.out, verify=False)
         with six.assertRaisesRegex(self, ForbiddenException, "tururu"):
-            uploader.upload("fake_url", f, auth=auth)
+            uploader.upload("fake_url", self.f, auth=auth)
diff --git a/conans/test/unittests/util/tools_test.py b/conans/test/unittests/util/tools_test.py
--- a/conans/test/unittests/util/tools_test.py
+++ b/conans/test/unittests/util/tools_test.py
@@ -10,7 +10,7 @@
 import mock
 import requests
 import six
-from bottle import request, static_file
+from bottle import request, static_file, HTTPError
 from mock.mock import mock_open, patch
 from nose.plugins.attrib import attr
 from parameterized import parameterized
@@ -25,7 +25,7 @@
 from conans.client.tools.files import replace_in_file, which
 from conans.client.tools.oss import check_output, OSInfo
 from conans.client.tools.win import vcvars_dict, vswhere
-from conans.errors import ConanException, NotFoundException
+from conans.errors import ConanException, NotFoundException, AuthenticationException
 from conans.model.build_info import CppInfo
 from conans.model.settings import Settings
 from conans.test.utils.conanfile import ConanFileMock
@@ -785,6 +785,25 @@ def get(self, *args, **kwargs):
                        requester=requests, out=out)
         http_server.stop()
 
+    @attr("slow")
+    def download_unathorized_test(self):
+        http_server = StoppableThreadBottle()
+
+        @http_server.server.get('/forbidden')
+        def get_forbidden():
+            return HTTPError(403, "Access denied.")
+
+        http_server.run_server()
+
+        out = TestBufferConanOutput()
+        dest = os.path.join(temp_folder(), "manual.html")
+        # Not authorized
+        with six.assertRaisesRegex(self, AuthenticationException, "403"):
+            tools.download("http://localhost:%s/forbidden" % http_server.port, dest,
+                           requester=requests, out=out)
+
+        http_server.stop()
+
     @parameterized.expand([
         ["Linux", "x86", None, "x86-linux-gnu"],
         ["Linux", "x86_64", None, "x86_64-linux-gnu"],