From: Florian Sabonchi 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 ` 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") }} - +
{{ _('Forgotten your password?', url=url_for('browser.forgot_password')) }}
@@ -20,9 +20,14 @@ {% for key, lang in config.LANGUAGES.items() %} {% endfor %} - +
{% endif %} +{% if "oauth" in config.AUTHENTICATION_SOURCES and config.AUTHENTICATION_SOURCES %} +
+ +
+{% 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 --------------DD8DB929B327031546D5771C--