From edb9278a9ce81e92aaf8778d3d069a18e3d6e2d3 Mon Sep 17 00:00:00 2001
From: Florian Sabonchi <sabonchi@posteo.de>
Date: Tue, 23 Mar 2021 20:07:12 +0100
Subject: [PATCH] Draft for oauth2 added

---
 DEPENDENCIES                                  |  1 +
 docs/en_US/oauth2.rst                         | 36 ++++++++
 requirements.txt                              |  2 +
 web/config.py                                 | 20 +++-
 web/pgadmin/__init__.py                       | 19 ++--
 web/pgadmin/authenticate/__init__.py          | 56 +++++++++--
 web/pgadmin/authenticate/oauth.py             | 92 +++++++++++++++++++
 web/pgadmin/browser/__init__.py               | 22 +++--
 web/pgadmin/browser/tests/test_oauth_login.py |  0
 web/pgadmin/messages.pot                      |  7 ++
 web/pgadmin/static/scss/_pgadmin.style.scss   |  3 +
 .../templates/security/login_user.html        |  9 +-
 web/pgadmin/utils/constants.py                |  4 +-
 web/pgadmin/utils/master_password.py          |  3 +-
 14 files changed, 243 insertions(+), 31 deletions(-)
 create mode 100644 docs/en_US/oauth2.rst
 create mode 100644 web/pgadmin/authenticate/oauth.py
 create mode 100644 web/pgadmin/browser/tests/test_oauth_login.py

diff --git a/DEPENDENCIES b/DEPENDENCIES
index 6b4d9cfcf..8efe007a1 100644
--- a/DEPENDENCIES
+++ b/DEPENDENCIES
@@ -51,6 +51,7 @@ sshtunnel                                                        0.4.0
 ldap3                                                            2.9              LGPL v3                              https://github.com/cannatag/ldap3
 Flask-BabelEx                                                    0.9.4            BSD                                  http://github.com/mrjoes/flask-babelex
 gssapi                                                           1.6.12           LICENSE.txt                          https://github.com/pythongssapi/python-gssapi
+authlib                                                          0.15.3           BSD                                  https://github.com/lepture/authlib
 
 28 dependencies listed.
 
diff --git a/docs/en_US/oauth2.rst b/docs/en_US/oauth2.rst
new file mode 100644
index 000000000..44dd2cfe5
--- /dev/null
+++ b/docs/en_US/oauth2.rst
@@ -0,0 +1,36 @@
+.. _enabling_ldap_authentication:
+
+**************************************************
+`Enabling OAUTH Authentication`:index:
+**************************************************
+
+
+To enable OAUTH authentication for pgAdmin, you must configure the OAUTH
+settings in the *config_local.py* or *config_system.py* file (see the
+:ref:`config.py <config_py>` documentation) on the system where pgAdmin is
+installed in Server mode. You can copy these settings from *config.py* file
+and modify the values for the following parameters:
+
+
+.. csv-table::
+   :header: "**Parameter**", "**Description**"
+   :class: longtable
+   :widths: 35, 55
+
+   "AUTHENTICATION_SOURCES","The default value for this parameter is *internal*.
+   To enable LDAP authentication, you must include *oauth* in the list of values
+   for this parameter. you can modify the value as follows:
+
+   * [‘oauth’, ‘internal’]: pgAdmin will display an additional button for authenticating with oauth
+
+    "OAUTH2_NAME", "The name of the of the oauth provider"
+    "OAUTH2_CLIENT_ID","Oauth client id'
+    "OAUTH2_CLIENT_SECRET", "Oauth secret"
+    "OAUTH2_TOKEN_URL","This url is used to generate a token for OpenID Connect."
+    "OAUTH2_AUTHORIZATION_URL", "This url is used for authentication"
+    "OAUTH2_API_BASE_URL", "Oauth base url"
+    "OAUTH2_USERINFO_ENDPOINT", "Endpoint for openid connect"
+    "OAUTH_ENDPOINT_NAME", "Name of the Endpoint"
+
+Important note: if you change the e-mail address stored in the account, the account will be lost.
+Because the e-mail address is used to find a user.
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index edd7000bd..3823d8144 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -36,3 +36,5 @@ sshtunnel==0.*
 ldap3==2.*
 Flask-BabelEx==0.*
 gssapi==1.6.*
+Authlib==0.15.*
+
diff --git a/web/config.py b/web/config.py
index 2643ef19e..6654fd9c7 100644
--- a/web/config.py
+++ b/web/config.py
@@ -530,11 +530,10 @@ ENHANCED_COOKIE_PROTECTION = True
 # External Authentication Sources
 ##########################################################################
 
-# Default setting is internal
-# External Supported Sources: ldap, kerberos
+# Default setting is internal External Supported Sources: ldap, kerberos
 # Multiple authentication can be achieved by setting this parameter to
-# ['ldap', 'internal']. pgAdmin will authenticate the user with ldap first,
-# in case of failure internal authentication will be done.
+# ['ldap', 'internal', 'oauth2']. pgAdmin will authenticate the user with ldap
+# first, in case of failure internal authentication will be done.
 
 AUTHENTICATION_SOURCES = ['internal']
 
@@ -614,7 +613,6 @@ LDAP_CA_CERT_FILE = ''
 LDAP_CERT_FILE = ''
 LDAP_KEY_FILE = ''
 
-
 ##########################################################################
 # Kerberos Configuration
 ##########################################################################
@@ -637,6 +635,18 @@ KRB_AUTO_CREATE_USER = True
 KERBEROS_CCACHE_DIR = os.path.join(DATA_DIR, 'krbccache')
 
 
+##########################################################################
+# OAuth2
+##########################################################################
+
+OAUTH2_NAME = None
+OAUTH2_CLIENT_ID = None
+OAUTH2_CLIENT_SECRET = None
+OAUTH2_TOKEN_URL = None
+OAUTH2_AUTHORIZATION_URL = None
+OAUTH2_API_BASE_URL = None
+OAUTH2_USERINFO_ENDPOINT = None
+
 ##########################################################################
 # Local config settings
 ##########################################################################
diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py
index a73335371..ed33d503d 100644
--- a/web/pgadmin/__init__.py
+++ b/web/pgadmin/__init__.py
@@ -44,11 +44,13 @@ from pgadmin.utils.csrf import pgCSRFProtect
 from pgadmin import authenticate
 from pgadmin.utils.security_headers import SecurityHeaders
 from pgadmin.utils.constants import KERBEROS
+from pgadmin.utils.constants import OAUTH
 
 # Explicitly set the mime-types so that a corrupted windows registry will not
 # affect pgAdmin 4 to be load properly. This will avoid the issues that may
 # occur due to security fix of X_CONTENT_TYPE_OPTIONS = "nosniff".
 import mimetypes
+
 mimetypes.add_type('application/javascript', '.js')
 mimetypes.add_type('text/css', '.css')
 
@@ -697,19 +699,24 @@ def create_app(app_name=None):
                 )
                 abort(401)
             login_user(user)
-        elif config.SERVER_MODE and\
-                app.PGADMIN_EXTERNAL_AUTH_SOURCE ==\
-                KERBEROS and \
+        elif config.SERVER_MODE and \
                 not current_user.is_authenticated and \
                 request.endpoint in ('redirects.index', 'security.login'):
-            return authenticate.login()
-
+            if app.PGADMIN_EXTERNAL_AUTH_SOURCE == KERBEROS:
+                return authenticate.login()
+            elif app.PGADMIN_EXTERNAL_AUTH_SOURCE == OAUTH:
+                # Disable OAuth if the master password is not used.
+                # Because encryption requires credentials that
+                # are not available with OAuth
+                if not config.MASTER_PASSWORD_REQUIRED and \
+                        OAUTH in config.AUTHENTICATION_SOURCES:
+                    config.AUTHENTICATION_SOURCES.remove(OAUTH)
         # if the server is restarted the in memory key will be lost
         # but the user session may still be active. Logout the user
         # to get the key again when login
         if config.SERVER_MODE and current_user.is_authenticated and \
                 app.PGADMIN_EXTERNAL_AUTH_SOURCE != \
-                KERBEROS and \
+                KERBEROS and OAUTH not in config.AUTHENTICATION_SOURCES and\
                 current_app.keyManager.get() is None and \
                 request.endpoint not in ('security.login', 'security.logout'):
             logout_user()
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
index 40c76b2b3..fb46f652e 100644
--- a/web/pgadmin/authenticate/__init__.py
+++ b/web/pgadmin/authenticate/__init__.py
@@ -8,6 +8,7 @@
 ##########################################################################
 
 """A blueprint module implementing the Authentication."""
+from typing import Optional, Any
 
 import flask
 import pickle
@@ -16,21 +17,31 @@ from flask import current_app, flash, Response, request, url_for,\
 from flask_babelex import gettext
 from flask_security import current_user, login_required
 from flask_security.views import _security, _ctx
-from flask_security.utils import config_value, get_post_logout_redirect, \
+from flask_security.utils import config_value, get_post_logout_redirect \
+
+from flask import current_app, flash, Response, request, url_for, \
+    render_template, redirect
+from flask_babelex import gettext
+from flask_login import current_user
+from flask_security.views import _security
+from flask_security.utils import get_post_logout_redirect, \
     get_post_login_redirect, logout_user
 from pgadmin.utils.ajax import make_json_response, internal_server_error
 import os
 
 from flask import session
 
-import config
 from pgadmin.utils import PgAdminModule
 from pgadmin.utils.constants import KERBEROS
 from pgadmin.utils.csrf import pgCSRFProtect
 
-from .registry import AuthSourceRegistry
+from pgadmin.authenticate.registry import AuthSourceRegistry
+from pgadmin.utils.constants import OAUTH
+import config
+import requests
 
 MODULE_NAME = 'authenticate'
+auth_obj = None
 
 
 class AuthenticateModule(PgAdminModule):
@@ -39,12 +50,37 @@ class AuthenticateModule(PgAdminModule):
                 'authenticate.kerberos_login',
                 'authenticate.kerberos_logout',
                 'authenticate.kerberos_update_ticket',
-                'authenticate.kerberos_validate_ticket']
+                'authenticate.kerberos_validate_ticket',
+                'authenticate.oauth_authorize',
+                'authenticate.oauth_logout']
 
 
 blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='')
 
 
+@blueprint.route('oauth_authorize', methods=['GET', 'POST'])
+@pgCSRFProtect.exempt
+def oauth_authorize():
+    source = get_auth_sources(OAUTH)
+    status = source.login(auth_obj)
+    if status:
+        session['_auth_source_manager_obj'] = auth_obj.as_dict()
+        return flask.redirect(get_post_login_redirect())
+    logout_user()
+    return flask.redirect(get_post_login_redirect())
+
+
+@blueprint.route('oauth_logout', methods=['GET', 'POST'])
+@pgCSRFProtect.exempt
+def oauth_logout():
+    if not current_user.is_authenticated:
+        return flask.redirect(get_post_logout_redirect())
+    for key in list(session.keys()):
+        session.pop(key)
+    logout_user()
+    return flask.redirect(get_post_logout_redirect())
+
+
 @blueprint.route("/login/kerberos",
                  endpoint="kerberos_login", methods=["GET"])
 @pgCSRFProtect.exempt
@@ -78,9 +114,9 @@ def login():
     The user input will be validated and authenticated.
     """
     form = _security.login_form()
+    global auth_obj
     auth_obj = AuthSourceManager(form, config.AUTHENTICATION_SOURCES)
     session['_auth_source_manager_obj'] = None
-
     # Validate the user
     if not auth_obj.validate():
         for field in form.errors:
@@ -92,8 +128,11 @@ def login():
     status, msg = auth_obj.authenticate()
     if status:
         # Login the user
+        if 'oauth_button' in request.form:
+            return session['provider'].authorize_redirect(msg)
         status, msg = auth_obj.login()
         current_auth_obj = auth_obj.as_dict()
+
         if not status:
             if current_auth_obj['current_source'] ==\
                     KERBEROS:
@@ -102,7 +141,6 @@ def login():
 
             flash(msg, 'danger')
             return flask.redirect(get_post_logout_redirect())
-
         session['_auth_source_manager_obj'] = current_auth_obj
         return flask.redirect(get_post_login_redirect())
 
@@ -113,9 +151,10 @@ def login():
     return response
 
 
-class AuthSourceManager():
+class AuthSourceManager:
     """This class will manage all the authentication sources.
      """
+
     def __init__(self, form, sources):
         self.form = form
         self.auth_sources = sources
@@ -179,6 +218,9 @@ class AuthSourceManager():
                 msg = gettext('pgAdmin internal user authentication'
                               ' is not enabled, please contact administrator.')
                 continue
+            if 'oauth_button' not in request.form and \
+                    source.get_source_name() == OAUTH:
+                continue
 
             status, msg = source.authenticate(self.form)
 
diff --git a/web/pgadmin/authenticate/oauth.py b/web/pgadmin/authenticate/oauth.py
new file mode 100644
index 000000000..56890a339
--- /dev/null
+++ b/web/pgadmin/authenticate/oauth.py
@@ -0,0 +1,92 @@
+import config
+from authlib.integrations.flask_client import OAuth
+from flask import Flask
+from flask import current_app, url_for, session
+from flask_babelex import gettext
+from flask_security import login_user
+from pgadmin.authenticate.internal import BaseAuthentication
+from pgadmin.model import User
+from pgadmin.tools import user_management
+from pgadmin.utils.constants import OAUTH
+
+from web.pgadmin.model import db
+
+oauth_obj = OAuth(Flask(__name__))
+
+
+def get_redirect_uri():
+    return url_for('authenticate.oauth_authorize',
+                   _external=True)
+
+
+class OAuthAuthentication(BaseAuthentication):
+    """OAuth Authentication Class"""
+
+    def get_source_name(self):
+        return OAUTH
+
+    def get_friendly_name(self):
+        return gettext("oauth2")
+
+    def validate(self, form):
+        return True
+
+    def login(self, auth_obj):
+        session['token'] = session['provider'].authorize_access_token()
+        resp = session['provider'].get(config.OAUTH_ENDPOINT_NAME).json()
+
+        if 'email' not in resp or not resp['email']:
+            current_app.logger.exception(
+                'An email is required for authentication'
+            )
+            return False
+
+        if self.__auto_create_user(resp):
+            user = db.session.query(User).filter_by(email=resp['email']).first()
+            if user.username != resp['name']:
+                try:
+                    user.username = resp['name']
+                    db.session.commit()
+                except Exception as e:
+                    current_app.logger.exception(e)
+                    return False
+            auth_obj.set_source_friendly_name(self.get_friendly_name())
+            auth_obj.set_current_source(self.get_source_name())
+            return login_user(user)
+        return False
+
+    def authenticate(self, form):
+        session['provider'] = oauth_obj.register(
+            name=config.OAUTH2_NAME,
+            client_id=config.OAUTH2_CLIENT_ID,
+            client_secret=config.OAUTH2_CLIENT_SECRET,
+            access_token_url=config.OAUTH2_TOKEN_URL,
+            authorize_url=config.OAUTH2_AUTHORIZATION_URL,
+            api_base_url=config.OAUTH2_API_BASE_URL,
+            userinfo_endpoint=config.OAUTH2_USERINFO_ENDPOINT,
+            client_kwargs={'scope': 'email profile'},
+        )
+        return True, get_redirect_uri()
+
+    def __auto_create_user(self, resp):
+        user = User.query.filter_by(email=resp['email']).first()
+        if not user:
+
+            if 'name' in resp and resp['name']:
+                name = resp['name']
+            elif 'preferred_username' in resp and resp['preferred_username']:
+                name = resp['preferred_username']
+            else:
+                current_app.logger.exception(
+                    'Missing username ("name" or "preferred_username")'
+                )
+                return False
+
+            return user_management.create_user({
+                'username': name,
+                'email': resp['email'],
+                'role': 2,
+                'active': True,
+                'auth_source': OAUTH
+            })
+        return True
diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py
index 300625c5e..0e803ff6b 100644
--- a/web/pgadmin/browser/__init__.py
+++ b/web/pgadmin/browser/__init__.py
@@ -52,6 +52,8 @@ from pgadmin.model import User
 from pgadmin.utils.constants import MIMETYPE_APP_JS, PGADMIN_NODE,\
     INTERNAL, KERBEROS, LDAP
 
+from pgadmin.utils.constants import OAUTH
+
 try:
     from flask_security.views import default_render_json
 except ImportError as e:
@@ -605,12 +607,13 @@ class BrowserPluginModule(PgAdminModule):
 
 
 def _get_logout_url():
-    if config.SERVER_MODE and\
-            session['_auth_source_manager_obj']['current_source'] == \
-            KERBEROS:
-        return '{0}?next={1}'.format(url_for(
-            'authenticate.kerberos_logout'), url_for(BROWSER_INDEX))
-
+    if config.SERVER_MODE:
+        if session['_auth_source_manager_obj']['current_source'] == KERBEROS:
+            return '{0}?next={1}'.format(url_for(
+                'authenticate.kerberos_logout'), url_for(BROWSER_INDEX))
+        elif session['_auth_source_manager_obj']['current_source'] == OAUTH:
+            return '{0}?next={1}'.format(url_for(
+                'authenticate.oauth_logout'), url_for(BROWSER_INDEX))
     return '{0}?next={1}'.format(
         url_for('security.logout'), url_for(BROWSER_INDEX))
 
@@ -987,8 +990,9 @@ def set_master_password():
         data = json.loads(data)
 
     # Master password is not applicable for server mode
-    if not config.SERVER_MODE and config.MASTER_PASSWORD_REQUIRED:
-
+    # Enable master password if oauth is used
+    if not config.SERVER_MODE or OAUTH in config.AUTHENTICATION_SOURCES\
+            and config.MASTER_PASSWORD_REQUIRED:
         # if master pass is set previously
         if current_user.masterpass_check is not None and \
             data.get('button_click') and \
@@ -1025,7 +1029,7 @@ def set_master_password():
                 existing=True,
                 present=False,
             )
-        elif not get_crypt_key()[0]:
+        elif not get_crypt_key()[1]:
             error_message = None
             if data.get('button_click') and data.get('password') == '':
                 # If user attempted to enter a blank password, then throw error
diff --git a/web/pgadmin/browser/tests/test_oauth_login.py b/web/pgadmin/browser/tests/test_oauth_login.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/web/pgadmin/messages.pot b/web/pgadmin/messages.pot
index cc52ec232..4b35ca904 100644
--- a/web/pgadmin/messages.pot
+++ b/web/pgadmin/messages.pot
@@ -29,6 +29,10 @@ msgstr ""
 msgid "403 FORBIDDEN"
 msgstr ""
 
+#: pgadmin/__init__.py:332 pgadmin/authenticate/internal.py:712
+msgid "OAuth is disabled because master password is required."
+msgstr ""
+
 #: pgadmin/about/__init__.py:36
 #, python-format
 msgid "About %(appname)s"
@@ -191,6 +195,9 @@ msgstr ""
 
 #: pgadmin/authenticate/ldap.py:270
 msgid "Could not find the specified user."
+#: pgadmin/authenticate/oauth.py:16
+#: pgadmin/templates/security/login_user.html:29
+msgid "Log in with oauth"
 msgstr ""
 
 #: pgadmin/authenticate/registry.py:50
diff --git a/web/pgadmin/static/scss/_pgadmin.style.scss b/web/pgadmin/static/scss/_pgadmin.style.scss
index 6a185471b..51c3b1dd5 100644
--- a/web/pgadmin/static/scss/_pgadmin.style.scss
+++ b/web/pgadmin/static/scss/_pgadmin.style.scss
@@ -946,6 +946,9 @@ table.table-empty-rows{
   & .btn-login {
     background-color: $security-btn-color;
   }
+  .btn-oauth {
+    background-color: $security-btn-color;
+  }
   & .user-language {
     & select{
       background-color: $color-primary;
diff --git a/web/pgadmin/templates/security/login_user.html b/web/pgadmin/templates/security/login_user.html
index 2e92d7b12..cbac51752 100644
--- a/web/pgadmin/templates/security/login_user.html
+++ b/web/pgadmin/templates/security/login_user.html
@@ -12,7 +12,7 @@
     {% set user_language = request.cookies.get('PGADMIN_LANGUAGE') or 'en' %}
     {{ render_username_with_errors(login_user_form.email, "text") }}
     {{ render_field_with_errors(login_user_form.password, "password") }}
-    <button class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
+    <button {% if "oauth" in config.AUTHENTICATION_SOURCES and config.AUTHENTICATION_SOURCES | length == 1 %} disabled {% endif %} class="btn btn-primary btn-block btn-login" type="submit" value="{{ _('Login') }}">{{ _('Login') }}</button>
     <div class="form-group row mb-3 c user-language">
         <div class="col-7"><span class="help-block">{{ _('<a href="%(url)s" class="text-white">Forgotten your password</a>?', url=url_for('browser.forgot_password')) }}</span></div>
         <div class="col-5">
@@ -20,9 +20,14 @@
                 {% for key, lang in config.LANGUAGES.items() %}
                 <option value="{{key}}" {% if user_language == key %}selected{% endif %}>{{lang}}</option>
                 {% endfor %}
-             </select>
+            </select>
         </div>
     </div>
 </form>
 {% endif %}
+{% if "oauth" in config.AUTHENTICATION_SOURCES and config.AUTHENTICATION_SOURCES %}
+    <form action="{{ url_for('authenticate.login') }}" method="POST" name="login_oauth_form">
+        <button name="oauth_button" class="btn btn-primary btn-block btn-oauth" value="{{ _('oauth') }}" type="submit">{{ _('Log in with oauth') }}</button>
+    </form>
+{% endif %}
 {% endblock %}
diff --git a/web/pgadmin/utils/constants.py b/web/pgadmin/utils/constants.py
index 5fd942304..c635f9da5 100644
--- a/web/pgadmin/utils/constants.py
+++ b/web/pgadmin/utils/constants.py
@@ -52,7 +52,9 @@ ERROR_FETCHING_DATA = gettext('Unable to fetch data.')
 INTERNAL = 'internal'
 LDAP = 'ldap'
 KERBEROS = 'kerberos'
+OAUTH = "oauth"
 
 SUPPORTED_AUTH_SOURCES = [INTERNAL,
                           LDAP,
-                          KERBEROS]
+                          KERBEROS,
+                          OAUTH]
diff --git a/web/pgadmin/utils/master_password.py b/web/pgadmin/utils/master_password.py
index f962684ff..0a0bf2d6c 100644
--- a/web/pgadmin/utils/master_password.py
+++ b/web/pgadmin/utils/master_password.py
@@ -31,7 +31,8 @@ def get_crypt_key():
         return True, current_user.password
     # if desktop mode and master pass enabled
     elif config.MASTER_PASSWORD_REQUIRED \
-            and not config.SERVER_MODE and enc_key is None:
+            and not config.SERVER_MODE or config.SERVER_MODE\
+            and enc_key is None:
         return False, None
     elif config.SERVER_MODE and \
             session['_auth_source_manager_obj']['current_source']\
-- 
2.25.1

