Hello in this patch I have implemented OAuth2. Unfortunately I wasn't
sure how the test should look like, because I couldn't find anything in
the developer documentation, so I decided not to write one for now.
The configuration has to look like this here is an example for github:
OAUTH2_NAME = 'github'
OAUTH2_CLIENT_ID = 'secret'
OAUTH2_CLIENT_SECRET = 'secret'
OAUTH2_TOKEN_URL = 'https://github.com/login/oauth/access_token'
OAUTH2_AUTHORIZATION_URL = 'https://github.com/login/oauth/authorize'
OAUTH2_API_BASE_URL = 'https://api.github.com/'
OAUTH2_USERINFO_ENDPOINT = 'https://api.github.com/user'
OAUTH_ENDPOINT_NAME = 'user'
>From edb9278a9ce81e92aaf8778d3d069a18e3d6e2d3 Mon Sep 17 00:00:00 2001
From: Florian Sabonchi <sabon...@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