Blob Blame History Raw
diff -rupN a/sos/__init__.py b/sos/__init__.py
--- a/sos/__init__.py
+++ b/sos/__init__.py
@@ -201,6 +201,7 @@ class SoSOptions(object):
         self.upload_directory = ""
         self.upload_user = ""
         self.upload_pass = ""
+        self.upload_method = "auto"
         self.upload_protocol = _arg_defaults["upload_protocol"]
         self.verbosity = _arg_defaults["verbosity"]
         self.verify = False
diff -rupN a/sos/policies/auth/__init__.py b/sos/policies/auth/__init__.py
--- /dev/null
+++ b/sos/policies/auth/__init__.py
@@ -0,0 +1,205 @@
+# Copyright (C) 2023 Red Hat, Inc., Jose Castillo <jcastillo@redhat.com>
+
+# This file is part of the sos project: https://github.com/sosreport/sos
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# version 2 of the GNU General Public License.
+#
+# See the LICENSE file in the source distribution for further information.
+
+import logging
+try:
+    import requests
+    REQUESTS_LOADED = True
+except ImportError:
+    REQUESTS_LOADED = False
+import time
+from datetime import datetime, timedelta
+
+DEVICE_AUTH_CLIENT_ID = "sos-tools"
+GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
+
+logger = logging.getLogger("sos")
+
+
+class DeviceAuthorizationClass:
+    """
+    Device Authorization Class
+    """
+
+    def __init__(self, client_identifier_url, token_endpoint):
+
+        self._access_token = None
+        self._access_expires_at = None
+        self.__device_code = None
+
+        self.client_identifier_url = client_identifier_url
+        self.token_endpoint = token_endpoint
+        self._use_device_code_grant()
+
+    def _use_device_code_grant(self):
+        """
+        Start the device auth flow. In the future we will
+        store the tokens in an in-memory keyring.
+        """
+
+        self._request_device_code()
+        print(
+            "Please visit the following URL to authenticate this device: {}"
+            .format(self._verification_uri_complete)
+        )
+        self.poll_for_auth_completion()
+
+    def _request_device_code(self):
+        """
+        Initialize new Device Authorization Grant attempt by
+        requesting a new device code.
+        """
+        data = "client_id={}".format(DEVICE_AUTH_CLIENT_ID)
+        headers = {'content-type': 'application/x-www-form-urlencoded'}
+        if not REQUESTS_LOADED:
+            raise Exception("python3-requests is not installed and is required"
+                            " for obtaining device auth token.")
+        try:
+            res = requests.post(
+                self.client_identifier_url,
+                data=data,
+                headers=headers)
+            res.raise_for_status()
+            response = res.json()
+            self._user_code = response.get("user_code")
+            self._verification_uri = response.get("verification_uri")
+            self._interval = response.get("interval")
+            self.__device_code = response.get("device_code")
+            self._verification_uri_complete = response.get(
+                "verification_uri_complete")
+        except requests.HTTPError as e:
+            raise requests.HTTPError(
+                "HTTP request failed while attempting to acquire the tokens."
+                " Error returned was {}".format(res.status_code)
+            )
+
+    def poll_for_auth_completion(self):
+        """
+        Continuously poll OIDC token endpoint until the user is successfully
+        authenticated or an error occurs.
+        """
+        token_data = {'grant_type': GRANT_TYPE_DEVICE_CODE,
+                      'client_id': DEVICE_AUTH_CLIENT_ID,
+                      'device_code': self.__device_code}
+
+        if not REQUESTS_LOADED:
+            raise Exception("python3-requests is not installed and is required"
+                            " for obtaining device auth token.")
+        while self._access_token is None:
+            time.sleep(self._interval)
+            try:
+                check_auth_completion = requests.post(self.token_endpoint,
+                                                      data=token_data)
+
+                status_code = check_auth_completion.status_code
+
+                if status_code == 200:
+                    logger.info("The SSO authentication is successful")
+                    self._set_token_data(check_auth_completion.json())
+                if status_code not in [200, 400]:
+                    raise Exception(status_code, check_auth_completion.text)
+                if status_code == 400 and \
+                    check_auth_completion.json()['error'] not in \
+                        ("authorization_pending", "slow_down"):
+                    raise Exception(status_code, check_auth_completion.text)
+            except requests.exceptions.RequestException as e:
+                logger.error("Error was found while posting a request: {}"
+                             .format(e))
+
+    def _set_token_data(self, token_data):
+        """
+        Set the class attributes as per the input token_data received.
+        In the future we will persist the token data in a local,
+        in-memory keyring, to avoid visting the browser frequently.
+        :param token_data: Token data containing access_token, refresh_token
+        and their expiry etc.
+        """
+        self._access_token = token_data.get("access_token")
+        self._access_expires_at = datetime.utcnow() + \
+            timedelta(seconds=token_data.get("expires_in"))
+        self._refresh_token = token_data.get("refresh_token")
+        self._refresh_expires_in = token_data.get("refresh_expires_in")
+        if self._refresh_expires_in == 0:
+            self._refresh_expires_at = datetime.max
+        else:
+            self._refresh_expires_at = datetime.utcnow() + \
+                timedelta(seconds=self._refresh_expires_in)
+
+    def get_access_token(self):
+        """
+        Get the valid access_token at any given time.
+        :return: Access_token
+        :rtype: string
+        """
+        if self.is_access_token_valid():
+            return self._access_token
+        else:
+            if self.is_refresh_token_valid():
+                self._use_refresh_token_grant()
+                return self._access_token
+            else:
+                self._use_device_code_grant()
+                return self._access_token
+
+    def is_access_token_valid(self):
+        """
+        Check the validity of access_token. We are considering it invalid 180
+        sec. prior to it's exact expiry time.
+        :return: True/False
+        """
+        return self._access_token and self._access_expires_at and \
+            self._access_expires_at - timedelta(seconds=180) > \
+            datetime.utcnow()
+
+    def is_refresh_token_valid(self):
+        """
+        Check the validity of refresh_token. We are considering it invalid
+        180 sec. prior to it's exact expiry time.
+        :return: True/False
+        """
+        return self._refresh_token and self._refresh_expires_at and \
+            self._refresh_expires_at - timedelta(seconds=180) > \
+            datetime.utcnow()
+
+    def _use_refresh_token_grant(self, refresh_token=None):
+        """
+        Fetch the new access_token and refresh_token using the existing
+        refresh_token and persist it.
+        :param refresh_token: optional param for refresh_token
+        """
+        if not REQUESTS_LOADED:
+            raise Exception("python3-requests is not installed and is required"
+                            " for obtaining device auth token.")
+        refresh_token_data = {'client_id': DEVICE_AUTH_CLIENT_ID,
+                              'grant_type': 'refresh_token',
+                              'refresh_token': self._refresh_token if not
+                              refresh_token else refresh_token}
+
+        refresh_token_res = requests.post(self.token_endpoint,
+                                          data=refresh_token_data)
+
+        if refresh_token_res.status_code == 200:
+            self._set_token_data(refresh_token_res.json())
+
+        elif refresh_token_res.status_code == 400 and 'invalid' in\
+                refresh_token_res.json()['error']:
+            logger.warning("Problem while fetching the new tokens from refresh"
+                           " token grant - {} {}."
+                           " New Device code will be requested !".format
+                           (refresh_token_res.status_code,
+                            refresh_token_res.json()['error']))
+            self._use_device_code_grant()
+        else:
+            raise Exception(
+                "Something went wrong while using the "
+                "Refresh token grant for fetching tokens:"
+                "Returned status code {0} and error {1}"
+                .format(refresh_token_res.status_code,
+                        refresh_token_res.json()['error']))
diff -rupN a/sos/policies/__init__.py b/sos/policies/__init__.py
--- a/sos/policies/__init__.py
+++ b/sos/policies/__init__.py
@@ -861,7 +861,7 @@ class LinuxPolicy(Policy):
     _upload_directory = '/'
     _upload_user = None
     _upload_password = None
-    _use_https_streaming = False
+    _upload_method = None
     _preferred_hash_name = None
     upload_url = None
     upload_user = None
@@ -1011,8 +1011,6 @@ class LinuxPolicy(Policy):
                                             these MUST include protocol header
                 _upload_user                Default username, if any else None
                 _upload_password            Default password, if any else None
-                _use_https_streaming        Set to True if the HTTPS endpoint
-                                            supports streaming data
 
         Optional:
             Class Attrs:
@@ -1198,7 +1196,9 @@ class LinuxPolicy(Policy):
         elif put_success == 2:
             raise Exception("Unknown error during upload: %s" % ret.before)
         elif put_success == 3:
-            raise Exception("Unable to write archive to destination")
+            raise Exception(
+                "Unable to write archive to destination %s" % ret.before
+            )
         else:
             raise Exception("Unexpected response from server: %s" % ret.before)
 
@@ -1212,9 +1212,8 @@ class LinuxPolicy(Policy):
         """
         return self.upload_archive_name.split('/')[-1]
 
-    def _upload_https_streaming(self, archive):
-        """If upload_https() needs to use requests.put(), this method is used
-        to provide streaming functionality
+    def _upload_https_put(self, archive):
+        """If upload_https() needs to use requests.put(), use this method.
 
         Policies should override this method instead of the base upload_https()
 
@@ -1229,9 +1228,8 @@ class LinuxPolicy(Policy):
         """
         return {}
 
-    def _upload_https_no_stream(self, archive):
-        """If upload_https() needs to use requests.post(), this method is used
-        to provide non-streaming functionality
+    def _upload_https_post(self, archive):
+        """If upload_https() needs to use requests.post(), use this method.
 
         Policies should override this method instead of the base upload_https()
 
@@ -1247,20 +1245,20 @@ class LinuxPolicy(Policy):
 
     def upload_https(self):
         """Attempts to upload the archive to an HTTPS location.
-
-        Policies may define whether this upload attempt should use streaming
-        or non-streaming data by setting the `use_https_streaming` class
-        attr to True
         """
         if not REQUESTS_LOADED:
             raise Exception("Unable to upload due to missing python requests "
                             "library")
 
         with open(self.upload_archive_name, 'rb') as arc:
-            if not self._use_https_streaming:
-                r = self._upload_https_no_stream(arc)
+            if self.commons['cmdlineopts'].upload_method == 'auto':
+                method = self._upload_method
+            else:
+                method = self.commons['cmdlineopts'].upload_method
+            if method == 'put':
+                r = self._upload_https_put(arc)
             else:
-                r = self._upload_https_streaming(arc)
+                r = self._upload_https_post(arc)
             if r.status_code != 200 and r.status_code != 201:
                 if r.status_code == 401:
                     raise Exception(
@@ -1313,9 +1311,13 @@ class LinuxPolicy(Policy):
             errno = str(err).split()[0]
             if errno == '503':
                 raise Exception("could not login as '%s'" % user)
+            if errno == '530':
+                raise Exception("invalid password for user '%s'" % user)
             if errno == '550':
                 raise Exception("could not set upload directory to %s"
                                 % directory)
+            raise Exception("error trying to establish session: %s"
+                            % str(err))
 
         try:
             with open(self.upload_archive_name, 'rb') as _arcfile:
diff -rupN a/sos/policies/redhat.py b/sos/policies/redhat.py
--- a/sos/policies/redhat.py
+++ b/sos/policies/redhat.py
@@ -14,12 +14,16 @@ import json
 import os
 import sys
 import re
+import logging
+from sos.policies.auth import DeviceAuthorizationClass
 
-from sos.plugins import RedHatPlugin
+from sos.plugins import Plugin, RedHatPlugin
 from sos.policies import LinuxPolicy, PackageManager, PresetDefaults
 from sos import _sos as _
 from sos import SoSOptions
 
+logger = logging.getLogger("sos")
+
 try:
     import requests
     REQUESTS_LOADED = True
@@ -53,6 +57,10 @@ class RedHatPolicy(LinuxPolicy):
     name_pattern = 'friendly'
     upload_url = None
     upload_user = None
+    client_identifier_url = "https://sso.redhat.com/auth/"\
+        "realms/redhat-external/protocol/openid-connect/auth/device"
+    token_endpoint = "https://sso.redhat.com/auth/realms/"\
+        "redhat-external/protocol/openid-connect/token"
 
     def __init__(self, sysroot=None):
         super(RedHatPolicy, self).__init__(sysroot=sysroot)
@@ -275,6 +283,8 @@ generated in %(tmpdir)s and may be provi
 support representative.
 """ + disclaimer_text + "%(vendor_text)s\n")
     _upload_url = RH_SFTP_HOST
+    _device_token = None
+    _upload_method = 'post'
 
     def __init__(self, sysroot=None):
         super(RHELPolicy, self).__init__(sysroot=sysroot)
@@ -306,16 +316,23 @@ support representative.
 
     def prompt_for_upload_user(self):
         if self.commons['cmdlineopts'].upload_user:
-            return
-        # Not using the default, so don't call this prompt for RHCP
-        if self.commons['cmdlineopts'].upload_url:
-            super(RHELPolicy, self).prompt_for_upload_user()
-            return
-        if self.case_id:
-            self.upload_user = input(_(
-                "Enter your Red Hat Customer Portal username for uploading ["
-                "empty for anonymous SFTP]: ")
+            logger.info(
+                _("The option --upload-user has been deprecated in favour"
+                  " of device authorization in RHEL")
+            )
+        if not self.case_id:
+            # no case id provided => failover to SFTP
+            self.upload_url = RH_SFTP_HOST
+            logger.info("No case id provided, uploading to SFTP")
+
+    def prompt_for_upload_password(self):
+        # With OIDC we don't ask for user/pass anymore
+        if self.commons['cmdlineopts'].upload_pass:
+            logger.info(
+                _("The option --upload-pass has been deprecated in favour"
+                  " of device authorization in RHEL")
             )
+        return
 
     def get_upload_url(self):
         if self.upload_url:
@@ -324,10 +341,40 @@ support representative.
             return self.commons['cmdlineopts'].upload_url
         elif self.commons['cmdlineopts'].upload_protocol == 'sftp':
             return RH_SFTP_HOST
+        elif not self.commons['cmdlineopts'].case_id:
+            logger.info("No case id provided, uploading to SFTP")
+            return RH_SFTP_HOST
         else:
             rh_case_api = "/support/v1/cases/%s/attachments"
             return RH_API_HOST + rh_case_api % self.case_id
 
+    def _get_upload_https_auth(self):
+        str_auth = "Bearer {}".format(self._device_token)
+        return {'Authorization': str_auth}
+
+    def _upload_https_post(self, archive, verify=True):
+        """If upload_https() needs to use requests.post(), use this method.
+        Policies should override this method instead of the base upload_https()
+        :param archive:     The open archive file object
+        """
+        files = {
+            'file': (archive.name.split('/')[-1], archive,
+                     self._get_upload_headers())
+        }
+        # Get the access token at this point. With this,
+        # we cover the cases where report generation takes
+        # longer than the token timeout
+        RHELAuth = DeviceAuthorizationClass(
+                self.client_identifier_url,
+                self.token_endpoint
+            )
+        self._device_token = RHELAuth.get_access_token()
+        logger.info("Device authorized correctly. Uploading file to {}"
+                    .format(self.get_upload_url_string()))
+        return requests.post(self.get_upload_url(), files=files,
+                             headers=self._get_upload_https_auth(),
+                             verify=verify)
+
     def _get_upload_headers(self):
         if self.get_upload_url().startswith(RH_API_HOST):
             return {'isPrivate': 'false', 'cache-control': 'no-cache'}
@@ -362,21 +409,43 @@ support representative.
                             " for obtaining SFTP auth token.")
         _token = None
         _user = None
+
+        # We may have a device token already if we attempted
+        # to upload via http but the upload failed. So
+        # lets check first if there isn't one.
+        if not self._device_token:
+            try:
+                RHELAuth = DeviceAuthorizationClass(
+                    self.client_identifier_url,
+                    self.token_endpoint
+                )
+            except Exception as e:
+                # We end up here if the user cancels the device
+                # authentication in the web interface
+                if "end user denied" in str(e):
+                    logger.info(
+                        "Device token authorization "
+                        "has been cancelled by the user."
+                    )
+            else:
+                self._device_token = RHELAuth.get_access_token()
+        if self._device_token:
+            logger.info("Device authorized correctly. Uploading file to {}"
+                        .format(self.get_upload_url_string()))
+
         url = RH_API_HOST + '/support/v2/sftp/token'
-        # we have a username and password, but we need to reset the password
-        # to be the token returned from the auth endpoint
-        if self.get_upload_user() and self.get_upload_password():
-            auth = self.get_upload_https_auth()
-            ret = requests.post(url, auth=auth, timeout=10)
+        ret = None
+        if self._device_token:
+            headers = self._get_upload_https_auth()
+            ret = requests.post(url, headers=headers, timeout=10)
             if ret.status_code == 200:
                 # credentials are valid
-                _user = self.get_upload_user()
+                _user = json.loads(ret.text)['username']
                 _token = json.loads(ret.text)['token']
             else:
                 print("Unable to retrieve Red Hat auth token using provided "
                       "credentials. Will try anonymous.")
-        # we either do not have a username or password/token, or both
-        if not _token:
+        else:
             adata = {"isAnonymous": True}
             anon = requests.post(url, data=json.dumps(adata), timeout=10)
             if anon.status_code == 200:
@@ -395,16 +464,17 @@ support representative.
         from RHCP failures to the public RH dropbox
         """
         try:
-            if not self.get_upload_user() or not self.get_upload_password():
+            if self.upload_url and self.upload_url.startswith(RH_API_HOST) and\
+              (not self.get_upload_user() or not self.get_upload_password()):
                 self.upload_url = RH_SFTP_HOST
             uploaded = super(RHELPolicy, self).upload_archive(archive)
-        except Exception:
+        except Exception as e:
             uploaded = False
             if not self.upload_url.startswith(RH_API_HOST):
                 raise
             else:
-                print("Upload to Red Hat Customer Portal failed. Trying %s"
-                      % RH_SFTP_HOST)
+                print("Upload to Red Hat Customer Portal failed due to %s. "
+                      "Trying %s" % (e, RH_SFTP_HOST))
                 self.upload_url = RH_SFTP_HOST
                 uploaded = super(RHELPolicy, self).upload_archive(archive)
         return uploaded
diff -rupN a/sos/policies/ubuntu.py b/sos/policies/ubuntu.py
--- a/sos/policies/ubuntu.py
+++ b/sos/policies/ubuntu.py
@@ -15,7 +15,7 @@ class UbuntuPolicy(DebianPolicy):
     _upload_url = "https://files.support.canonical.com/uploads/"
     _upload_user = "ubuntu"
     _upload_password = "ubuntu"
-    _use_https_streaming = True
+    _upload_method = 'put'
 
     def __init__(self, sysroot=None):
         super(UbuntuPolicy, self).__init__(sysroot=sysroot)
diff -rupN a/sos/sosreport.py b/sos/sosreport.py
--- a/sos/sosreport.py
+++ b/sos/sosreport.py
@@ -255,6 +255,9 @@ def _get_parser():
                         help="Username to authenticate to upload server with")
     parser.add_argument("--upload-pass", default=None,
                         help="Password to authenticate to upload server with")
+    parser.add_argument("--upload-method", default='auto',
+                        choices=['auto', 'put', 'post'],
+                        help="HTTP method to use for uploading")
     parser.add_argument("--upload-protocol", default='auto',
                         choices=['auto', 'https', 'ftp', 'sftp'],
                         help="Manually specify the upload protocol")
diff -rupN a/man/en/sosreport.1 b/man/en/sosreport.1
--- a/man/en/sosreport.1
+++ b/man/en/sosreport.1
@@ -315,6 +315,13 @@ when prompted rather than using this opt
 Specify a directory to upload to, if one is not specified by a vendor default location
 or if your destination server does not allow writes to '/'.
 .TP
+.B \--upload-method METHOD
+Specify the HTTP method to use for uploading to the provided --upload-url. Valid
+values are 'auto' (default), 'put', or 'post'. The use of 'auto' will default to
+the method required by the policy-default upload location, if one exists.
+
+This option has no effect on upload methods other than HTTPS.
+.TP
 .B \--upload-protocol PROTO
 Manually specify the protocol to use for uploading to the target \fBupload-url\fR.

diff --git a/setup.py b/setup.py
index affe731..718ded1 100644
--- a/setup.py
+++ b/setup.py
@@ -71,7 +71,7 @@ setup(name='sos',
         ('share/man/man1', ['man/en/sosreport.1']),
         ('share/man/man5', ['man/en/sos.conf.5']),
         ],
-      packages=['sos', 'sos.plugins', 'sos.policies'],
+      packages=['sos', 'sos.plugins', 'sos.policies', 'sos.policies.auth'],
       cmdclass={'build': BuildData, 'install_data': InstallData},
       requires=['six', 'futures'],
      )
diff --git a/Makefile b/Makefile
index ba99c5c..f02b888 100644
--- a/Makefile
+++ b/Makefile
@@ -9,7 +9,7 @@ MINOR   := $(shell echo $(VERSION) | cut -f 2 -d '.')
 RELEASE := $(shell echo `awk '/^Release:/ {gsub(/\%.*/,""); print $2}' sos.spec`)
 REPO = https://github.com/sosreport/sos
 
-SUBDIRS = po sos sos/plugins sos/policies #docs
+SUBDIRS = po sos sos/plugins sos/policies sos/policies/auth #docs
 PYFILES = $(wildcard *.py)
 # OS X via brew
 # MSGCAT = /usr/local/Cellar/gettext/0.18.1.1/bin/msgcat
diff --git a/sos/policies/auth/Makefile b/sos/policies/auth/Makefile
new file mode 100644
index 0000000000..29a4830406
--- /dev/null
+++ b/sos/policies/auth/Makefile
@@ -0,0 +1,20 @@
+PYTHON=python
+PACKAGE = $(shell basename `pwd`)
+PYFILES = $(wildcard *.py)
+PYVER := $(shell $(PYTHON) -c 'import sys; print("%.3s" %(sys.version))')
+PYSYSDIR := $(shell $(PYTHON) -c 'import sys; print(sys.prefix)')
+PYLIBDIR = $(PYSYSDIR)/lib/python$(PYVER)
+PKGDIR = $(PYLIBDIR)/site-packages/sos/policies/$(PACKAGE)
+
+all:
+	echo "nada"
+
+clean:
+	rm -f *.pyc *.pyo *~
+
+install:
+	mkdir -p $(DESTDIR)/$(PKGDIR)
+	for p in $(PYFILES) ; do \
+		install -m 644 $$p $(DESTDIR)/$(PKGDIR)/$$p; \
+	done
+	$(PYTHON) -c "import compileall; compileall.compile_dir('$(DESTDIR)/$(PKGDIR)', 1, '$(PYDIR)', 1)"
+