Repository: spyder-ide/spyder

Issue:
Github warning when using Issue reporter
## Description

### What steps will reproduce the problem?

<!--- You can use Markdown here --->

I filed an issue using the Issue reporter. It asked me more my github credentials, which I filled in. Moments later I received the following email from github: 

----
You recently used a password to access an endpoint through the GitHub API using Python-urllib/3.7. We will deprecate basic authentication using password to this endpoint soon:

https://api.github.com/repositories/30892477/issues

We recommend using a personal access token (PAT) with the appropriate scope to access this endpoint instead. Visit https://github.com/settings/tokens for more information.

------

I submitted this report using Issue reporter, this time generating and using a PAT, and this worked. But googling suggests spyder should register itself as an OAuth application? https://developer.github.com/changes/2019-11-05-deprecated-passwords-and-authorizations-api/

In any case, it is a bit confusing to be allowed to log using a password and then get emails warning you about them. 

## Versions

* Spyder version: 4.0.0 
* Python version: 3.7.3
* Qt version: 5.9.6
* PyQt5 version: 5.9.2
* Operating System: Windows 7

### Dependencies

```
cloudpickle >=0.5.0          :  1.2.2 (OK)
pygments >=2.0               :  2.5.2 (OK)
qtconsole >=4.6.0            :  4.6.0 (OK)
nbconvert >=4.0              :  5.6.1 (OK)
sphinx >=0.6.6               :  2.2.2 (OK)
pylint >=0.25                :  2.4.4 (OK)
psutil >=0.3                 :  5.6.7 (OK)
qtawesome >=0.5.7            :  0.6.0 (OK)
qtpy >=1.5.0                 :  1.9.0 (OK)
pickleshare >=0.4            :  0.7.5 (OK)
zmq >=17                     :  18.1.0 (OK)
chardet >=2.0.0              :  3.0.4 (OK)
numpydoc >=0.6.0             :  0.9.1 (OK)
spyder_kernels >=1.8.1;<2.0.0:  1.8.1 (OK)
qdarkstyle >=2.7             :  2.7 (OK)
atomicwrites >=1.2.0         :  1.3.0 (OK)
diff_match_patch >=20181111  :  20181111 (OK)
intervaltree                 :  None (OK)
watchdog                     :  None (OK)
keyring                      :  None (OK)
pexpect >=4.4.0              :  4.7.0 (OK)
pympler                      :  None (OK)
sympy >=0.7.3                :  1.4 (OK)
cython >=0.21                :  0.29.14 (OK)
IPython >=4.0                :  7.10.1 (OK)
matplotlib >=2.0.0           :  3.1.1 (OK)
pandas >=0.13.1              :  0.25.3 (OK)
numpy >=1.7                  :  1.17.4 (OK)
scipy >=0.17.0               :  1.3.2 (OK)
pyls >=0.31.2;<0.32.0        :  0.31.2 (OK)
rtree >=0.8.3                :  0.8.3 (OK)
```



Discussion:
> I submitted this report using Issue reporter, this time generating and using a PAT, and this worked.

It seems we need to disable password-based authentications and only request tokens. Thanks for letting us know about it.

> But googling suggests spyder should register itself as an OAuth application?

That only works for web apps. Non-web apps need to require personal tokens:

https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/#non-web-application-flow
I decided to fix this for **4.0.1** because Github could remove password based authentications at any moment.

Resolving patch:
diff --git a/spyder/config/main.py b/spyder/config/main.py
--- a/spyder/config/main.py
+++ b/spyder/config/main.py
@@ -93,7 +93,6 @@
               'toolbars_visible': True,
               'cursor/width': 2,
               'completion/size': (300, 180),
-              'report_error/remember_me': False,
               'report_error/remember_token': False,
               }),
             ('quick_layouts',
@@ -614,4 +613,4 @@
 #    or if you want to *rename* options, then you need to do a MAJOR update in
 #    version, e.g. from 3.0.0 to 4.0.0
 # 3. You don't need to touch this value if you're just adding a new option
-CONF_VERSION = '54.0.0'
+CONF_VERSION = '55.0.0'
diff --git a/spyder/widgets/github/backend.py b/spyder/widgets/github/backend.py
--- a/spyder/widgets/github/backend.py
+++ b/spyder/widgets/github/backend.py
@@ -124,13 +124,10 @@ def send_report(self, title, body, application_log=None):
 
         # Credentials
         credentials = self.get_user_credentials()
-        username = credentials['username']
-        password = credentials['password']
-        remember = credentials['remember']
         token = credentials['token']
         remember_token = credentials['remember_token']
 
-        if username is None and password is None and token is None:
+        if token is None:
             return False
         _logger().debug('got user credentials')
 
@@ -139,10 +136,7 @@ def send_report(self, title, body, application_log=None):
             url = self.upload_log_file(application_log)
             body += '\nApplication log: %s' % url
         try:
-            if token:
-                gh = github.GitHub(access_token=token)
-            else:
-                gh = github.GitHub(username=username, password=password)
+            gh = github.GitHub(access_token=token)
             repo = gh.repos(self.gh_owner)(self.gh_repo)
             ret = repo.issues.post(title=title, body=body)
         except github.ApiError as e:
@@ -179,31 +173,8 @@ def send_report(self, title, body, application_log=None):
 
     def _get_credentials_from_settings(self):
         """Get the stored credentials if any."""
-        remember_me = CONF.get('main', 'report_error/remember_me')
         remember_token = CONF.get('main', 'report_error/remember_token')
-        username = CONF.get('main', 'report_error/username', '')
-        if not remember_me:
-            username = ''
-
-        return username, remember_me, remember_token
-
-    def _store_credentials(self, username, password, remember=False):
-        """Store credentials for future use."""
-        if username and password and remember:
-            CONF.set('main', 'report_error/username', username)
-            try:
-                keyring.set_password('github', username, password)
-            except Exception:
-                if self._show_msgbox:
-                    QMessageBox.warning(self.parent_widget,
-                                        _('Failed to store password'),
-                                        _('It was not possible to securely '
-                                          'save your password. You will be '
-                                          'prompted for your Github '
-                                          'credentials next time you want '
-                                          'to report an issue.'))
-                remember = False
-        CONF.set('main', 'report_error/remember_me', remember)
+        return remember_token
 
     def _store_token(self, token, remember=False):
         """Store token for future use."""
@@ -225,23 +196,9 @@ def _store_token(self, token, remember=False):
 
     def get_user_credentials(self):
         """Get user credentials with the login dialog."""
-        password = None
         token = None
-        (username, remember_me,
-         remember_token) = self._get_credentials_from_settings()
+        remember_token = self._get_credentials_from_settings()
         valid_py_os = not (PY2 and sys.platform.startswith('linux'))
-        if username and remember_me and valid_py_os:
-            # Get password from keyring
-            try:
-                password = keyring.get_password('github', username)
-            except Exception:
-                # No safe keyring backend
-                if self._show_msgbox:
-                    QMessageBox.warning(self.parent_widget,
-                                        _('Failed to retrieve password'),
-                                        _('It was not possible to retrieve '
-                                          'your password. Please introduce '
-                                          'it again.'))
         if remember_token and valid_py_os:
             # Get token from keyring
             try:
@@ -256,17 +213,10 @@ def get_user_credentials(self):
                                           'again.'))
 
         if not running_under_pytest():
-            credentials = DlgGitHubLogin.login(self.parent_widget, username,
-                                            password, token, remember_me,
-                                            remember_token)
-
-            if (credentials['username'] and credentials['password'] and
-                    valid_py_os):
-                self._store_credentials(credentials['username'],
-                                        credentials['password'],
-                                        credentials['remember'])
-                CONF.set('main', 'report_error/remember_me',
-                         credentials['remember'])
+            credentials = DlgGitHubLogin.login(
+                self.parent_widget,
+                token,
+                remember_token)
 
             if credentials['token'] and valid_py_os:
                 self._store_token(credentials['token'],
@@ -274,10 +224,7 @@ def get_user_credentials(self):
                 CONF.set('main', 'report_error/remember_token',
                          credentials['remember_token'])
         else:
-            return dict(username=username,
-                        password=password,
-                        token='',
-                        remember=remember_me,
+            return dict(token=token,
                         remember_token=remember_token)
 
         return credentials
diff --git a/spyder/widgets/github/gh_login.py b/spyder/widgets/github/gh_login.py
--- a/spyder/widgets/github/gh_login.py
+++ b/spyder/widgets/github/gh_login.py
@@ -31,8 +31,7 @@
 class DlgGitHubLogin(QDialog):
     """Dialog to submit error reports to Github."""
 
-    def __init__(self, parent, username, password, token, remember=False,
-                 remember_token=False):
+    def __init__(self, parent, token, remember_token=False):
         QDialog.__init__(self, parent)
 
         title = _("Sign in to Github")
@@ -50,54 +49,6 @@ def __init__(self, parent, username, password, token, remember=False,
         # Tabs
         self.tabs = QTabWidget()
 
-        # Basic form layout
-        basic_form_layout = QFormLayout()
-        basic_form_layout.setContentsMargins(-1, 0, -1, -1)
-
-        basic_lbl_msg = QLabel(_("For regular users, i.e. users <b>without</b>"
-                                 " two-factor authentication enabled"))
-        basic_lbl_msg.setWordWrap(True)
-        basic_lbl_msg.setAlignment(Qt.AlignJustify)
-
-        lbl_user = QLabel(_("Username:"))
-        basic_form_layout.setWidget(0, QFormLayout.LabelRole, lbl_user)
-        self.le_user = QLineEdit()
-        self.le_user.textChanged.connect(self.update_btn_state)
-        basic_form_layout.setWidget(0, QFormLayout.FieldRole, self.le_user)
-        basic_form_layout.addRow("", QWidget())
-
-        lbl_password = QLabel(_("Password: "))
-        basic_form_layout.setWidget(2, QFormLayout.LabelRole, lbl_password)
-        self.le_password = QLineEdit()
-        self.le_password.setEchoMode(QLineEdit.Password)
-        self.le_password.textChanged.connect(self.update_btn_state)
-        basic_form_layout.setWidget(2, QFormLayout.FieldRole, self.le_password)
-
-        self.cb_remember = None
-        # Check if we are not in Python 2 and Linux because
-        # there's no keyring backend there
-        valid_py_os = not (PY2 and sys.platform.startswith('linux'))
-        if self.is_keyring_available() and valid_py_os:
-            self.cb_remember = QCheckBox(_("Remember me"))
-            self.cb_remember.setToolTip(_("Spyder will save your credentials "
-                                          "safely"))
-            self.cb_remember.setChecked(remember)
-            basic_form_layout.setWidget(4, QFormLayout.FieldRole,
-                                        self.cb_remember)
-
-        # Basic auth tab
-        basic_auth = QWidget()
-        basic_layout = QVBoxLayout()
-        basic_layout.addSpacerItem(QSpacerItem(QSpacerItem(0, 8)))
-        basic_layout.addWidget(basic_lbl_msg)
-        basic_layout.addSpacerItem(
-            QSpacerItem(QSpacerItem(0, 50, vPolicy=QSizePolicy.Expanding)))
-        basic_layout.addLayout(basic_form_layout)
-        basic_layout.addSpacerItem(
-            QSpacerItem(QSpacerItem(0, 50, vPolicy=QSizePolicy.Expanding)))
-        basic_auth.setLayout(basic_layout)
-        self.tabs.addTab(basic_auth, _("Password Only"))
-
         # Token form layout
         token_form_layout = QFormLayout()
         token_form_layout.setContentsMargins(-1, 0, -1, -1)
@@ -122,7 +73,9 @@ def __init__(self, parent, username, password, token, remember=False,
         token_form_layout.setWidget(1, QFormLayout.FieldRole, self.le_token)
 
         self.cb_remember_token = None
-        # Same validation as with cb_remember
+        # Check if we are not in Python 2 and Linux because
+        # there's no keyring backend there
+        valid_py_os = not (PY2 and sys.platform.startswith('linux'))
         if self.is_keyring_available() and valid_py_os:
             self.cb_remember_token = QCheckBox(_("Remember token"))
             self.cb_remember_token.setToolTip(_("Spyder will save your "
@@ -157,25 +110,15 @@ def __init__(self, parent, username, password, token, remember=False,
         self.setLayout(layout)
 
         # Final adjustments
-        if username and password:
-            self.le_user.setText(username)
-            self.le_password.setText(password)
-            self.bt_sign_in.setFocus()
-        elif username:
-            self.le_user.setText(username)
-            self.le_password.setFocus()
-        elif token:
+        if token:
             self.le_token.setText(token)
         else:
-            self.le_user.setFocus()
+            self.le_token.setFocus()
 
         self.setFixedSize(self.width(), self.height())
-        self.le_password.installEventFilter(self)
-        self.le_user.installEventFilter(self)
-        self.tabs.currentChanged.connect(self.update_btn_state)
 
     def eventFilter(self, obj, event):
-        interesting_objects = [self.le_password, self.le_user]
+        interesting_objects = [self.le_token]
         if obj in interesting_objects and event.type() == QEvent.KeyPress:
             if (event.key() == Qt.Key_Return and
                     event.modifiers() & Qt.ControlModifier and
@@ -185,13 +128,8 @@ def eventFilter(self, obj, event):
         return False
 
     def update_btn_state(self):
-        user = to_text_string(self.le_user.text()).strip() != ''
-        password = to_text_string(self.le_password.text()).strip() != ''
         token = to_text_string(self.le_token.text()).strip() != ''
-        enable = ((user and password and
-                  self.tabs.currentIndex() == 0) or
-                  (token and self.tabs.currentIndex() == 1))
-        self.bt_sign_in.setEnabled(enable)
+        self.bt_sign_in.setEnabled(token)
 
     def is_keyring_available(self):
         """Check if keyring is available for password storage."""
@@ -202,41 +140,27 @@ def is_keyring_available(self):
             return False
 
     @classmethod
-    def login(cls, parent, username, password, token,
-              remember, remember_token):
-        dlg = DlgGitHubLogin(parent, username, password, token, remember,
-                             remember_token)
+    def login(cls, parent, token, remember_token):
+        dlg = DlgGitHubLogin(parent, token, remember_token)
         if dlg.exec_() == dlg.Accepted:
-            user = dlg.le_user.text()
-            password = dlg.le_password.text()
             token = dlg.le_token.text()
-            if dlg.cb_remember:
-                remember = dlg.cb_remember.isChecked()
-            else:
-                remember = False
             if dlg.cb_remember_token:
                 remember_token = dlg.cb_remember_token.isChecked()
             else:
                 remember_token = False
 
-            credentials = dict(username=user,
-                               password=password,
-                               token=token,
-                               remember=remember,
+            credentials = dict(token=token,
                                remember_token=remember_token)
             return credentials
 
-        return dict(username=None,
-                    password=None,
-                    token=None,
-                    remember=False,
+        return dict(token=None,
                     remember_token=False)
 
 
 def test():
     from spyder.utils.qthelpers import qapplication
     app = qapplication()  # analysis:ignore
-    dlg = DlgGitHubLogin(None, None, None, None)
+    dlg = DlgGitHubLogin(None, None)
     dlg.show()
     sys.exit(dlg.exec_())
 


Test patch:
diff --git a/spyder/widgets/github/tests/test_github_backend.py b/spyder/widgets/github/tests/test_github_backend.py
--- a/spyder/widgets/github/tests/test_github_backend.py
+++ b/spyder/widgets/github/tests/test_github_backend.py
@@ -24,8 +24,7 @@
 from spyder.widgets.github import backend
 
 
-USERNAME = 'tester'
-PASSWORD = 'test1234'
+TOKEN = 'token1234'
 GH_OWNER = 'ccordoba12'
 GH_REPO = 'spyder'
 
@@ -47,10 +46,7 @@ def get_wrong_user_credentials():
     Monkeypatch GithubBackend.get_user_credentials to force the case where
     invalid credentias were provided
     """
-    return dict(username='invalid',
-                password='invalid',
-                token='invalid',
-                remember=False,
+    return dict(token='invalid',
                 remember_token=False)
 
 
@@ -59,10 +55,7 @@ def get_empty_user_credentials():
     Monkeypatch GithubBackend.get_user_credentials to force the case where
     invalid credentias were provided
     """
-    return dict(username='',
-                password='',
-                token='',
-                remember=False,
+    return dict(token='',
                 remember_token=False)
 
 
@@ -71,10 +64,7 @@ def get_fake_user_credentials():
     Monkeypatch GithubBackend.get_user_credentials to force the case where
     invalid credentias were provided
     """
-    return dict(username=USERNAME,
-                password=PASSWORD,
-                token='',
-                remember=False,
+    return dict(token=TOKEN,
                 remember_token=False)
 
 
@@ -101,18 +91,12 @@ def test_fake_credentials_bad_repo():
 
 def test_get_credentials_from_settings():
     b = get_backend()
-    username, remember_me, remember_token = b._get_credentials_from_settings()
-    assert username == ''
-    assert remember_me is False
+    remember_token = b._get_credentials_from_settings()
     assert remember_token is False
 
-    CONF.set('main', 'report_error/username', 'user')
-    CONF.set('main', 'report_error/remember_me', True)
     CONF.set('main', 'report_error/remember_token', True)
 
-    username, remember_me, remember_token = b._get_credentials_from_settings()
-    assert username == 'user'
-    assert remember_me is True
+    remember_token = b._get_credentials_from_settings()
     assert remember_token is True
 
 
@@ -123,9 +107,8 @@ def test_get_credentials_from_settings():
                             "CIs and skip it locally on Linux and Python 2"))
 def test_store_user_credentials():
     b = get_backend()
-    b._store_credentials('user', 'toto', True)
+    b._store_token('token', True)
     credentials = b.get_user_credentials()
 
-    assert credentials['username'] == 'user'
-    assert credentials['password'] == 'toto'
-    assert credentials['remember'] is True
+    assert credentials['token'] == 'token'
+    assert credentials['remember_token'] is True
diff --git a/spyder/widgets/github/tests/test_github_login.py b/spyder/widgets/github/tests/test_github_login.py
--- a/spyder/widgets/github/tests/test_github_login.py
+++ b/spyder/widgets/github/tests/test_github_login.py
@@ -22,7 +22,7 @@
 @pytest.fixture
 def github_dialog(qtbot):
     """Set up error report dialog."""
-    widget = DlgGitHubLogin(None, None, None, None)
+    widget = DlgGitHubLogin(None, None)
     qtbot.addWidget(widget)
     return widget
 
@@ -37,29 +37,7 @@ def test_dialog(github_dialog, qtbot):
     # Assert Sign in button is disabled at first
     assert not dlg.bt_sign_in.isEnabled()
 
-    # Introduce Username
-    qtbot.keyClicks(dlg.le_user, 'user')
-
-    # Assert Sign in button is still disabled
-    assert not dlg.bt_sign_in.isEnabled()
-
-    # Introduce now password
-    qtbot.keyClicks(dlg.le_password, 'password')
-
-    # Assert Sign in button is enabled
-    assert dlg.bt_sign_in.isEnabled()
-
-    # Remove user and password
-    dlg.le_user.selectAll()
-    dlg.le_user.cut()
-    dlg.le_password.selectAll()
-    dlg.le_user.cut()
-
-    # Assert Sign in is disabled
-    assert not dlg.bt_sign_in.isEnabled()
-
-    # Change tab and add token
-    dlg.tabs.setCurrentIndex(1)
+    # Add token
     qtbot.keyClicks(dlg.le_token, 'token')
 
     # Assert Sign in button is enabled