diff --git a/DEPENDENCIES b/DEPENDENCIES index 3aaea84c4ad..9eace7eed14 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -56,6 +56,7 @@ eventlet 0.31.0 httpagentparser 1.9.1 http://www.opensource.org/licenses/mit-license.php http://shon.github.com/httpagentparser user-agents 2.2.0 MIT https://github.com/selwin/python-user-agents pywinpty 1.1.1 Unknown Unknown +authlib 0.15.3 BSD https://github.com/lepture/authlib NOTE: This report was generated using Python 3.9. Full information may not be shown for Python modules that are not required with this version. diff --git a/docs/en_US/getting_started.rst b/docs/en_US/getting_started.rst index b135fe082d9..e3d0edc969d 100644 --- a/docs/en_US/getting_started.rst +++ b/docs/en_US/getting_started.rst @@ -37,6 +37,7 @@ Mode is pre-configured for security. change_user_password ldap kerberos + oauth2 .. note:: Pre-compiled and configured installation packages are available for diff --git a/docs/en_US/images/oauth2_login.png b/docs/en_US/images/oauth2_login.png new file mode 100644 index 00000000000..50ecff57645 Binary files /dev/null and b/docs/en_US/images/oauth2_login.png differ diff --git a/docs/en_US/kerberos.rst b/docs/en_US/kerberos.rst index 1116c467231..6dcbd674cb6 100644 --- a/docs/en_US/kerberos.rst +++ b/docs/en_US/kerberos.rst @@ -34,6 +34,7 @@ from *config.py* file and modify the values for the following parameters. Please note that if it is not set, it will take the value of *default_server* parameter." + Keytab file for HTTP Service ============================ @@ -116,3 +117,13 @@ PostgreSQL Server settings to configure Kerberos Authentication * Note that, you have to login into pgAdmin with Kerberos authentication to then connect to PostgreSQL using Kerberos. + + +Master Password +=============== + +In the multi user mode, pgAdmin uses user's login password to encrypt/decrypt the PostgreSQL server password. +In the Kerberos authentication, the pgAdmin user does not have the password, so we need an encryption key to store +the PostgreSQL server password for the servers which are not configured to use the Kerberos authentication. +To accomplish this, set the configuration parameter MASTER_PASSWORD to *True*, so upon setting the master password, +it will be used as an encryption key while storing the password. If it is False, the server password can not be stored. diff --git a/docs/en_US/oauth2.rst b/docs/en_US/oauth2.rst new file mode 100644 index 00000000000..8947b509ee9 --- /dev/null +++ b/docs/en_US/oauth2.rst @@ -0,0 +1,61 @@ +.. _oauth2: + +***************************************** +`Enabling OAUTH2 Authentication`:index: +***************************************** + + +To enable OAUTH2 authentication for pgAdmin, you must configure the OAUTH2 +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 OAUTH2 authentication, you must include *oauth2* in the list of values + for this parameter. you can modify the value as follows: + + * [‘oauth2’, ‘internal’]: pgAdmin will display an additional button for authenticating with oauth2" + "OAUTH2_NAME", "The name of the Oauth2 provider, ex: Google, Github" + "OAUTH2_DISPLAY_NAME", "Oauth2 display name in pgAdmin" + "OAUTH2_CLIENT_ID", "Oauth2 Client ID" + "OAUTH2_CLIENT_SECRET", "Oauth2 Client Secret" + "OAUTH2_TOKEN_URL", "Oauth2 Access Token endpoint" + "OAUTH2_AUTHORIZATION_URL", "Endpoint for user authorization" + "OAUTH2_API_BASE_URL", "Oauth2 base URL endpoint to make requests simple, ex: *https://api.github.com/*" + "OAUTH2_USERINFO_ENDPOINT", "User Endpoint, ex: *user* (for github) and *useinfo* (for google)" + "OAUTH2_ICON", "The Font-awesome icon to be placed on the oauth2 button, ex: fa-github" + "OAUTH2_BUTTON_COLOR", "Oauth2 button color" + "OAUTH2_AUTO_CREATE_USER", "Set the value to *True* if you want to automatically + create a pgAdmin user corresponding to a successfully authenticated Oauth2 user. + Please note that password is not stored in the pgAdmin database." + +Redirect URL +============ + +The redirect url to configure Oauth2 server is *http:///oauth2/authorize* + +Master Password +=============== + +In the multi user mode, pgAdmin uses user's login password to encrypt/decrypt the PostgreSQL server password. +In the Oauth2 authentication, the pgAdmin does not store the user's password, so we need an encryption key to store +the PostgreSQL server password. +To accomplish this, set the configuration parameter MASTER_PASSWORD to *True*, so upon setting the master password, +it will be used as an encryption key while storing the password. If it is False, the server password can not be stored. + + +Login Page +============ + +After configuration, on restart, you can see the login page with the Oauth2 login button(s). + +.. image:: images/oauth2_login.png + :alt: Oauth2 login + :align: center diff --git a/docs/en_US/release_notes_5_5.rst b/docs/en_US/release_notes_5_5.rst index 5ce8170344f..25b01923a7c 100644 --- a/docs/en_US/release_notes_5_5.rst +++ b/docs/en_US/release_notes_5_5.rst @@ -12,6 +12,7 @@ New features | `Issue #1975 `_ - Highlighted long running queries on the dashboards. | `Issue #3893 `_ - Added support for Reassign/Drop Owned for login roles. | `Issue #3920 `_ - Do not block the query editor window when running a query. +| `Issue #5940 `_ - Added support for OAuth 2 authentication. | `Issue #6559 `_ - Added option to provide maximum width of the column when 'Resize by data?’ option in the preferences is set to True. Housekeeping diff --git a/requirements.txt b/requirements.txt index e6022d8f149..d45566a0bc8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,3 +41,5 @@ eventlet==0.31.0 httpagentparser==1.9.* user-agents==2.2.0 pywinpty==1.1.1; sys_platform=="win32" +Authlib==0.15.* +requests==2.25.* diff --git a/web/config.py b/web/config.py index 1c65a318081..3b730733b18 100644 --- a/web/config.py +++ b/web/config.py @@ -562,10 +562,11 @@ ########################################################################## # Default setting is internal -# External Supported Sources: ldap, kerberos +# External Supported Sources: ldap, kerberos, oauth2 # 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'] or ['oauth2', 'internal'] etc. +# pgAdmin will authenticate the user with ldap/oauth2 whatever first in the +# list, in case of failure the second authentication option will be considered. AUTHENTICATION_SOURCES = ['internal'] @@ -666,6 +667,47 @@ KERBEROS_CCACHE_DIR = os.path.join(DATA_DIR, 'krbccache') +########################################################################## +# OAuth2 Configuration +########################################################################## + +# Multiple OAUTH2 providers can be added in the list like [{...},{...}] +# All parameters are required + +OAUTH2_CONFIG = [ + { + # The name of the of the oauth provider, ex: github, google + 'OAUTH2_NAME': None, + # The display name, ex: Google + 'OAUTH2_DISPLAY_NAME': '', + # Oauth client id + 'OAUTH2_CLIENT_ID': None, + # Oauth secret + 'OAUTH2_CLIENT_SECRET': None, + # URL to generate a token, + # Ex: https://github.com/login/oauth/access_token + 'OAUTH2_TOKEN_URL': None, + # URL is used for authentication, + # Ex: https://github.com/login/oauth/authorize + 'OAUTH2_AUTHORIZATION_URL': None, + # Oauth base url, ex: https://api.github.com/ + 'OAUTH2_API_BASE_URL': None, + # Name of the Endpoint, ex: user + 'OAUTH2_USERINFO_ENDPOINT': None, + # Font-awesome icon, ex: fa-github + 'OAUTH2_ICON': None, + # UI button colour, ex: #0000ff + 'OAUTH2_BUTTON_COLOR': None, + } +] + +# After Oauth authentication, user will be added into the SQLite database +# automatically, if set to True. +# Set it to False, if user should not be added automatically, +# in this case Admin has to add the user manually in the SQLite database. + +OAUTH2_AUTO_CREATE_USER = True + ########################################################################## # PSQL tool settings ########################################################################## diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py index 8bfb18d399d..2a1c959fd43 100644 --- a/web/pgadmin/__init__.py +++ b/web/pgadmin/__init__.py @@ -46,12 +46,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 KERBEROS, OAUTH2, INTERNAL, LDAP # 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') @@ -469,6 +470,13 @@ def upgrade_db(): 'SECURITY_EMAIL_VALIDATOR_ARGS': config.SECURITY_EMAIL_VALIDATOR_ARGS })) + app.config.update(dict({ + 'INTERNAL': INTERNAL, + 'LDAP': LDAP, + 'KERBEROS': KERBEROS, + 'OAUTH2': OAUTH2 + })) + security.init_app(app, user_datastore) # register custom unauthorised handler. @@ -760,19 +768,18 @@ def before_request(): ) 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() # 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 app.PGADMIN_EXTERNAL_AUTH_SOURCE != \ + OAUTH2 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 101d5765609..b512eb2ce15 100644 --- a/web/pgadmin/authenticate/__init__.py +++ b/web/pgadmin/authenticate/__init__.py @@ -9,68 +9,33 @@ """A blueprint module implementing the Authentication.""" -import flask -import pickle +import config +import copy + from flask import current_app, flash, Response, request, url_for,\ - render_template + session, redirect 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, \ - get_post_login_redirect, logout_user -from pgadmin.utils.ajax import make_json_response, internal_server_error -import os +from flask_security.views import _security +from flask_security.utils import get_post_logout_redirect, \ + get_post_login_redirect -from flask import session - -import config from pgadmin.utils import PgAdminModule -from pgadmin.utils.constants import KERBEROS, INTERNAL -from pgadmin.utils.csrf import pgCSRFProtect +from pgadmin.utils.constants import KERBEROS, INTERNAL, OAUTH2, LDAP +from pgadmin.authenticate.registry import AuthSourceRegistry -from .registry import AuthSourceRegistry MODULE_NAME = 'authenticate' +auth_obj = None class AuthenticateModule(PgAdminModule): def get_exposed_url_endpoints(self): - return ['authenticate.login', - 'authenticate.kerberos_login', - 'authenticate.kerberos_logout', - 'authenticate.kerberos_update_ticket', - 'authenticate.kerberos_validate_ticket'] + return ['authenticate.login'] blueprint = AuthenticateModule(MODULE_NAME, __name__, static_url_path='') -@blueprint.route("/login/kerberos", - endpoint="kerberos_login", methods=["GET"]) -@pgCSRFProtect.exempt -def kerberos_login(): - logout_user() - return Response(render_template("browser/kerberos_login.html", - login_url=url_for('security.login'), - )) - - -@blueprint.route("/logout/kerberos", - endpoint="kerberos_logout", methods=["GET"]) -@pgCSRFProtect.exempt -def kerberos_logout(): - logout_user() - if 'KRB5CCNAME' in session: - # Remove the credential cache - cache_file_path = session['KRB5CCNAME'].split(":")[1] - if os.path.exists(cache_file_path): - os.remove(cache_file_path) - - return Response(render_template("browser/kerberos_logout.html", - login_url=url_for('security.login'), - )) - - @blueprint.route('/login', endpoint='login', methods=['GET', 'POST']) def login(): """ @@ -78,15 +43,20 @@ def login(): The user input will be validated and authenticated. """ form = _security.login_form() - auth_obj = AuthSourceManager(form, config.AUTHENTICATION_SOURCES) - session['_auth_source_manager_obj'] = None + auth_obj = AuthSourceManager(form, copy.deepcopy( + config.AUTHENTICATION_SOURCES)) + if OAUTH2 in config.AUTHENTICATION_SOURCES\ + and 'oauth2_button' in request.form: + session['auth_obj'] = auth_obj + + session['auth_source_manager'] = None # Validate the user if not auth_obj.validate(): for field in form.errors: for error in form.errors[field]: flash(error, 'warning') - return flask.redirect(get_post_logout_redirect()) + return redirect(get_post_logout_redirect()) # Authenticate the user status, msg = auth_obj.authenticate() @@ -94,34 +64,40 @@ def login(): # Login the user status, msg = auth_obj.login() current_auth_obj = auth_obj.as_dict() + if not status: if current_auth_obj['current_source'] ==\ KERBEROS: - return flask.redirect('{0}?next={1}'.format(url_for( + return redirect('{0}?next={1}'.format(url_for( 'authenticate.kerberos_login'), url_for('browser.index'))) 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()) + return redirect(get_post_logout_redirect()) + session['auth_source_manager'] = current_auth_obj + if 'auth_obj' in session: + session['auth_obj'] = None + return redirect(get_post_login_redirect()) elif isinstance(msg, Response): return msg + elif 'oauth2_button' in request.form and not isinstance(msg, str): + return msg flash(msg, 'danger') - response = flask.redirect(get_post_logout_redirect()) + response = redirect(get_post_logout_redirect()) 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 self.source = None self.source_friendly_name = INTERNAL - self.current_source = None + self.current_source = INTERNAL + self.update_auth_sources() def as_dict(self): """ @@ -135,6 +111,14 @@ def as_dict(self): return res + def update_auth_sources(self): + for auth_src in [KERBEROS, OAUTH2]: + if auth_src in self.auth_sources: + if 'internal_button' in request.form: + self.auth_sources.remove(auth_src) + elif INTERNAL in self.auth_sources: + self.auth_sources.remove(INTERNAL) + def set_current_source(self, source): self.current_source = source @@ -170,36 +154,19 @@ def authenticate(self): msg = None for src in self.auth_sources: source = get_auth_sources(src) + self.set_source(source) current_app.logger.debug( "Authentication initiated via source: %s" % source.get_source_name()) - if self.form.data['email'] and self.form.data['password'] and \ - source.get_source_name() == KERBEROS: - msg = gettext('pgAdmin internal user authentication' - ' is not enabled, please contact administrator.') - continue - status, msg = source.authenticate(self.form) - # When server sends Unauthorized header to get the ticket over HTTP - # OR When kerberos authentication failed while accessing pgadmin, - # we need to break the loop as no need to authenticate further - # even if the authentication sources set to multiple - if not status: - if (hasattr(msg, 'status') and - msg.status == '401 UNAUTHORIZED') or\ - (source.get_source_name() == - KERBEROS and - request.method == 'GET'): - break - if status: - self.set_source(source) self.set_current_source(source.get_source_name()) if msg is not None and 'username' in msg: self.form._fields['email'].data = msg['username'] return status, msg + return status, msg def login(self): @@ -209,6 +176,13 @@ def login(self): current_app.logger.debug( "Authentication and Login successfully done via source : %s" % self.source.get_source_name()) + + # Set the login, logout view as per source if available + current_app.login_manager.login_view = getattr( + self.source, 'LOGIN_VIEW', 'security.login') + current_app.login_manager.logout_view = getattr( + self.source, 'LOGOUT_VIEW', 'security.logout') + return status, msg @@ -239,58 +213,3 @@ def init_app(app): AuthSourceRegistry.load_modules(app) return auth_sources - - -@blueprint.route("/kerberos/update_ticket", - endpoint="kerberos_update_ticket", methods=["GET"]) -@pgCSRFProtect.exempt -@login_required -def kerberos_update_ticket(): - """ - Update the kerberos ticket. - """ - from werkzeug.datastructures import Headers - headers = Headers() - - authorization = request.headers.get("Authorization", None) - - if authorization is None: - # Send the Negotiate header to the client - # if Kerberos ticket is not found. - headers.add('WWW-Authenticate', 'Negotiate') - return Response("Unauthorised", 401, headers) - else: - source = get_auth_sources(KERBEROS) - auth_header = authorization.split() - in_token = auth_header[1] - - # Validate the Kerberos ticket - status, context = source.negotiate_start(in_token) - if status: - return Response("Ticket updated successfully.") - - return Response(context, 500) - - -@blueprint.route("/kerberos/validate_ticket", - endpoint="kerberos_validate_ticket", methods=["GET"]) -@pgCSRFProtect.exempt -@login_required -def kerberos_validate_ticket(): - """ - Return the kerberos ticket lifetime left after getting the - ticket from the credential cache - """ - import gssapi - - try: - del_creds = gssapi.Credentials(store={'ccache': session['KRB5CCNAME']}) - creds = del_creds.acquire(store={'ccache': session['KRB5CCNAME']}) - except Exception as e: - current_app.logger.exception(e) - return internal_server_error(errormsg=str(e)) - - return make_json_response( - data={'ticket_lifetime': creds.lifetime}, - status=200 - ) diff --git a/web/pgadmin/authenticate/internal.py b/web/pgadmin/authenticate/internal.py index 484a7fdca95..515f00a5dee 100644 --- a/web/pgadmin/authenticate/internal.py +++ b/web/pgadmin/authenticate/internal.py @@ -10,7 +10,7 @@ """Implements Internal Authentication""" import six -from flask import current_app +from flask import current_app, flash from flask_security import login_user from abc import abstractmethod, abstractproperty from flask_babelex import gettext @@ -31,6 +31,8 @@ class BaseAuthentication(object): 'PASSWORD_NOT_PROVIDED': gettext('Password not provided'), 'INVALID_EMAIL': gettext('Email/Username is not valid') } + LOGIN_VIEW = 'security.login' + LOGOUT_VIEW = 'security.logout' @abstractmethod def get_source_name(self): @@ -97,7 +99,7 @@ def validate(self, form): """User validation""" # validate the email id first if not validate_email(form.data['email']): - form.errors['email'] = [self.messages('INVALID_EMAIL')] + flash(self.messages('INVALID_EMAIL'), 'warning') return False # Flask security validation return form.validate_on_submit() diff --git a/web/pgadmin/authenticate/kerberos.py b/web/pgadmin/authenticate/kerberos.py index 2f8fd0d6e37..a018f473e02 100644 --- a/web/pgadmin/authenticate/kerberos.py +++ b/web/pgadmin/authenticate/kerberos.py @@ -10,22 +10,28 @@ """A blueprint module implementing the Spnego/Kerberos authentication.""" import base64 -from os import environ, path +from os import environ, path, remove -from werkzeug.datastructures import Headers +from werkzeug.datastructures import Headers, MultiDict from flask_babelex import gettext -from flask import Flask, request, Response, session,\ - current_app, render_template, flash +from flask import request, Response, session,\ + current_app, render_template, flash, url_for +from flask_security.views import _security +from flask_security.utils import logout_user +from flask_security import login_required import config from pgadmin.model import User from pgadmin.tools.user_management import create_user from pgadmin.utils.constants import KERBEROS +from pgadmin.utils import PgAdminModule +from pgadmin.utils.ajax import make_json_response, internal_server_error -from flask_security.views import _security -from werkzeug.datastructures import MultiDict -from .internal import BaseAuthentication +from pgadmin.authenticate.internal import BaseAuthentication +from pgadmin.authenticate import get_auth_sources +from pgadmin.utils.csrf import pgCSRFProtect + try: import gssapi @@ -46,8 +52,110 @@ environ['KRB5_KTNAME'] = config.KRB_KTNAME +class KerberosModule(PgAdminModule): + def register(self, app, options, first_registration=False): + # Do not look for the sub_modules, + # instead call blueprint.register(...) directly + super(PgAdminModule, self).register(app, options, first_registration) + + def get_exposed_url_endpoints(self): + return ['kerberos.login', + 'kerberos.logout', + 'kerberos.update_ticket', + 'kerberos.validate_ticket'] + + +def init_app(app): + MODULE_NAME = 'kerberos' + + blueprint = KerberosModule(MODULE_NAME, __name__, static_url_path='') + + @blueprint.route("/login", + endpoint="login", methods=["GET"]) + @pgCSRFProtect.exempt + def kerberos_login(): + logout_user() + return Response(render_template("browser/kerberos_login.html", + login_url=url_for('security.login'), + )) + + @blueprint.route("/logout", + endpoint="logout", methods=["GET"]) + @pgCSRFProtect.exempt + def kerberos_logout(): + logout_user() + if 'KRB5CCNAME' in session: + # Remove the credential cache + cache_file_path = session['KRB5CCNAME'].split(":")[1] + if path.exists(cache_file_path): + remove(cache_file_path) + + return Response(render_template("browser/kerberos_logout.html", + login_url=url_for('security.login'), + )) + + @blueprint.route("/update_ticket", + endpoint="update_ticket", methods=["GET"]) + @pgCSRFProtect.exempt + @login_required + def kerberos_update_ticket(): + """ + Update the kerberos ticket. + """ + from werkzeug.datastructures import Headers + headers = Headers() + + authorization = request.headers.get("Authorization", None) + + if authorization is None: + # Send the Negotiate header to the client + # if Kerberos ticket is not found. + headers.add('WWW-Authenticate', 'Negotiate') + return Response("Unauthorised", 401, headers) + else: + source = get_auth_sources(KERBEROS) + auth_header = authorization.split() + in_token = auth_header[1] + + # Validate the Kerberos ticket + status, context = source.negotiate_start(in_token) + if status: + return Response("Ticket updated successfully.") + + return Response(context, 500) + + @blueprint.route("/validate_ticket", + endpoint="validate_ticket", methods=["GET"]) + @pgCSRFProtect.exempt + @login_required + def kerberos_validate_ticket(): + """ + Return the kerberos ticket lifetime left after getting the + ticket from the credential cache + """ + import gssapi + + try: + del_creds = gssapi.Credentials(store={ + 'ccache': session['KRB5CCNAME']}) + creds = del_creds.acquire(store={'ccache': session['KRB5CCNAME']}) + except Exception as e: + current_app.logger.exception(e) + return internal_server_error(errormsg=str(e)) + + return make_json_response( + data={'ticket_lifetime': creds.lifetime}, + status=200 + ) + + app.register_blueprint(blueprint) + + class KerberosAuthentication(BaseAuthentication): + LOGIN_VIEW = 'kerberos.login' + LOGOUT_VIEW = 'kerberos.logout' + def get_source_name(self): return KERBEROS @@ -85,7 +193,7 @@ def authenticate(self, frm): if status: # Saving the first 15 characters of the kerberos key # to encrypt/decrypt database password - session['kerberos_key'] = auth_header[1][0:15] + session['pass_enc_key'] = auth_header[1][0:15] # Create user retval = self.__auto_create_user( str(negotiate.initiator_name)) @@ -162,7 +270,7 @@ def __auto_create_user(self, username): username = str(username) if config.KRB_AUTO_CREATE_USER: user = User.query.filter_by( - username=username).first() + username=username, auth_source=KERBEROS).first() if user is None: return create_user({ 'username': username, diff --git a/web/pgadmin/authenticate/oauth2.py b/web/pgadmin/authenticate/oauth2.py new file mode 100644 index 00000000000..67c00ba71d1 --- /dev/null +++ b/web/pgadmin/authenticate/oauth2.py @@ -0,0 +1,172 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""A blueprint module implementing the Oauth2 authentication.""" + +import config + +from authlib.integrations.flask_client import OAuth +from flask import current_app, url_for, session, request,\ + redirect, Flask, flash +from flask_babelex import gettext +from flask_security import login_user, current_user +from flask_security.utils import get_post_logout_redirect, \ + get_post_login_redirect, logout_user + +from pgadmin.authenticate.internal import BaseAuthentication +from pgadmin.model import User +from pgadmin.tools.user_management import create_user +from pgadmin.utils.constants import OAUTH2 +from pgadmin.utils import PgAdminModule +from pgadmin.utils.csrf import pgCSRFProtect +from pgadmin.model import db + +OAUTH2_LOGOUT = 'oauth2.logout' +OAUTH2_AUTHORIZE = 'oauth2.authorize' + + +class Oauth2Module(PgAdminModule): + def register(self, app, options, first_registration=False): + # Do not look for the sub_modules, + # instead call blueprint.register(...) directly + super(PgAdminModule, self).register(app, options, first_registration) + + def get_exposed_url_endpoints(self): + return [OAUTH2_AUTHORIZE, + OAUTH2_LOGOUT] + + +def init_app(app): + MODULE_NAME = 'oauth2' + + blueprint = Oauth2Module(MODULE_NAME, __name__, static_url_path='') + + @blueprint.route('/authorize', endpoint="authorize", + methods=['GET', 'POST']) + @pgCSRFProtect.exempt + def oauth_authorize(): + auth_obj = session['auth_obj'] + auth_obj.set_current_source(auth_obj.source.get_source_name()) + status, msg = auth_obj.login() + if status: + session['auth_source_manager'] = auth_obj.as_dict() + session['auth_obj'] = None + return redirect(get_post_login_redirect()) + logout_user() + flash(msg) + return redirect(get_post_login_redirect()) + + @blueprint.route('/logout', endpoint="logout", + methods=['GET', 'POST']) + @pgCSRFProtect.exempt + def oauth_logout(): + if not current_user.is_authenticated: + return redirect(get_post_logout_redirect()) + for key in list(session.keys()): + session.pop(key) + logout_user() + return redirect(get_post_logout_redirect()) + + app.register_blueprint(blueprint) + app.login_manager.logout_view = OAUTH2_LOGOUT + + +class OAuth2Authentication(BaseAuthentication): + """OAuth Authentication Class""" + + LOGOUT_VIEW = OAUTH2_LOGOUT + + oauth_obj = OAuth(Flask(__name__)) + oauth2_clients = {} + oauth2_config = {} + + def __init__(self): + for oauth2_config in config.OAUTH2_CONFIG: + + OAuth2Authentication.oauth2_config[ + oauth2_config['OAUTH2_NAME']] = oauth2_config + + OAuth2Authentication.oauth2_clients[ + oauth2_config['OAUTH2_NAME'] + ] = OAuth2Authentication.oauth_obj.register( + name=oauth2_config['OAUTH2_NAME'], + client_id=oauth2_config['OAUTH2_CLIENT_ID'], + client_secret=oauth2_config['OAUTH2_CLIENT_SECRET'], + access_token_url=oauth2_config['OAUTH2_TOKEN_URL'], + authorize_url=oauth2_config['OAUTH2_AUTHORIZATION_URL'], + api_base_url=oauth2_config['OAUTH2_API_BASE_URL'], + client_kwargs={'scope': 'email profile'} + ) + + def get_source_name(self): + return OAUTH2 + + def get_friendly_name(self): + return self.oauth2_config[self.oauth2_current_client]['OAUTH2_NAME'] + + def validate(self, form): + return True + + def login(self, form): + profile = self.get_user_profile() + print(profile) + if 'email' not in profile or not profile['email']: + current_app.logger.exception( + 'An email is required for authentication' + ) + return False, gettext( + "An email is required for the oauth authentication.") + + user, msg = self.__auto_create_user(profile) + if user: + user = db.session.query(User).filter_by( + username=profile['email'], auth_source=OAUTH2).first() + current_app.login_manager.logout_view = \ + OAuth2Authentication.LOGOUT_VIEW + return login_user(user), None + return False, msg + + def get_user_profile(self): + session['oauth2_token'] = self.oauth2_clients[ + self.oauth2_current_client].authorize_access_token() + + session['pass_enc_key'] = session['oauth2_token']['access_token'] + + resp = self.oauth2_clients[self.oauth2_current_client].get( + self.oauth2_config[ + self.oauth2_current_client]['OAUTH2_USERINFO_ENDPOINT'], + token=session['oauth2_token'] + ) + resp.raise_for_status() + return resp.json() + + def authenticate(self, form): + self.oauth2_current_client = request.form['oauth2_button'] + redirect_url = url_for(OAUTH2_AUTHORIZE, _external=True) + + if self.oauth2_current_client not in self.oauth2_clients: + return False, gettext( + "Please set the configuration parameters properly.") + return False, self.oauth2_clients[ + self.oauth2_current_client].authorize_redirect(redirect_url) + + def __auto_create_user(self, resp): + if config.OAUTH2_AUTO_CREATE_USER: + user = User.query.filter_by(username=resp['email'], + auth_source=OAUTH2).first() + if not user: + return create_user({ + 'username': resp['email'], + 'email': resp['email'], + 'role': 2, + 'active': True, + 'auth_source': OAUTH2 + }) + + return True, {'username': resp['email']} diff --git a/web/pgadmin/authenticate/static/js/kerberos.js b/web/pgadmin/authenticate/static/js/kerberos.js index 64373369c0d..8e4f2c50e65 100644 --- a/web/pgadmin/authenticate/static/js/kerberos.js +++ b/web/pgadmin/authenticate/static/js/kerberos.js @@ -1,10 +1,19 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + import url_for from 'sources/url_for'; import userInfo from 'pgadmin.user_management.current_user'; import pgConst from 'pgadmin.browser.constants'; function fetch_ticket() { // Fetch the Kerberos Updated ticket through SPNEGO - return fetch(url_for('authenticate.kerberos_update_ticket') + return fetch(url_for('kerberos.update_ticket') ) .then(function(response){ if (response.status >= 200 && response.status < 300) { @@ -18,7 +27,7 @@ function fetch_ticket() { function fetch_ticket_lifetime () { // Fetch the Kerberos ticket lifetime left - return fetch(url_for('authenticate.kerberos_validate_ticket') + return fetch(url_for('kerberos.validate_ticket') ) .then( function(response){ diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py index fa7aa9e5487..d71a06e3d1f 100644 --- a/web/pgadmin/browser/__init__.py +++ b/web/pgadmin/browser/__init__.py @@ -50,7 +50,7 @@ set_crypt_key, process_masterpass_disabled from pgadmin.model import User from pgadmin.utils.constants import MIMETYPE_APP_JS, PGADMIN_NODE,\ - INTERNAL, KERBEROS, LDAP, QT_DEFAULT_PLACEHOLDER + INTERNAL, KERBEROS, LDAP, QT_DEFAULT_PLACEHOLDER, OAUTH2 from pgadmin.authenticate import AuthSourceManager try: @@ -607,14 +607,8 @@ def register_preferences(self): 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)) - return '{0}?next={1}'.format( - url_for('security.logout'), url_for(BROWSER_INDEX)) + url_for(current_app.login_manager.logout_view), url_for(BROWSER_INDEX)) def _get_supported_browser(): @@ -748,10 +742,10 @@ def index(): if len(config.AUTHENTICATION_SOURCES) == 1\ and INTERNAL in config.AUTHENTICATION_SOURCES: auth_only_internal = True - auth_source = session['_auth_source_manager_obj'][ + auth_source = session['auth_source_manager'][ 'source_friendly_name'] - if session['_auth_source_manager_obj']['current_source'] == KERBEROS: + if not config.MASTER_PASSWORD_REQUIRED and 'pass_enc_key' in session: session['allow_save_password'] = False response = Response(render_template( @@ -877,7 +871,8 @@ def app_constants(): render_template('browser/js/constants.js', INTERNAL=INTERNAL, LDAP=LDAP, - KERBEROS=KERBEROS), + KERBEROS=KERBEROS, + OAUTH2=OAUTH2), 200, {'Content-Type': MIMETYPE_APP_JS} ) @@ -1005,8 +1000,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 OAUTH2 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 \ @@ -1043,7 +1039,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 @@ -1334,6 +1330,9 @@ def reset_password(token): do_flash(*get_message('PASSWORD_RESET')) login_user(user) + auth_obj = AuthSourceManager(form, [INTERNAL]) + session['auth_source_manager'] = auth_obj.as_dict() + return redirect(get_url(_security.post_reset_view) or get_url(_security.post_login_view)) diff --git a/web/pgadmin/browser/server_groups/servers/tests/test_role_dependencies_sql.py b/web/pgadmin/browser/server_groups/servers/tests/test_role_dependencies_sql.py index fefe6a75f6b..c186ac4f030 100644 --- a/web/pgadmin/browser/server_groups/servers/tests/test_role_dependencies_sql.py +++ b/web/pgadmin/browser/server_groups/servers/tests/test_role_dependencies_sql.py @@ -7,6 +7,7 @@ # ########################################################################## import os +import uuid import jinja2 from regression.python_test_utils import test_utils @@ -24,20 +25,21 @@ class TestRoleDependenciesSql(SQLTemplateTestBase): def __init__(self): super(TestRoleDependenciesSql, self).__init__() self.table_id = -1 + self.role_name = "testpgadmin%s" % str(uuid.uuid4())[1:8] def setUp(self): with test_utils.Database(self.server) as (connection, database_name): cursor = connection.cursor() try: cursor.execute( - "CREATE ROLE testpgadmin LOGIN PASSWORD '%s'" - % self.server['db_password']) + "CREATE ROLE %s LOGIN PASSWORD '%s'" + % (self.role_name, self.server['db_password'])) except Exception as exception: print(exception) connection.commit() self.server_with_modified_user = self.server.copy() - self.server_with_modified_user['username'] = "testpgadmin" + self.server_with_modified_user['username'] = self.role_name def runTest(self): if hasattr(self, "ignore_test"): @@ -61,7 +63,7 @@ def runTest(self): def tearDown(self): with test_utils.Database(self.server) as (connection, database_name): cursor = connection.cursor() - cursor.execute("DROP ROLE testpgadmin") + cursor.execute("DROP ROLE %s" % self.role_name) connection.commit() def generate_sql(self, version): diff --git a/web/pgadmin/browser/templates/browser/js/constants.js b/web/pgadmin/browser/templates/browser/js/constants.js index 6a63d6ed9d6..6da835b6518 100644 --- a/web/pgadmin/browser/templates/browser/js/constants.js +++ b/web/pgadmin/browser/templates/browser/js/constants.js @@ -12,6 +12,7 @@ define('pgadmin.browser.constants', [], function() { return { 'INTERNAL': '{{ INTERNAL }}', 'LDAP': '{{ LDAP }}', - 'KERBEROS': '{{ KERBEROS }}' + 'KERBEROS': '{{ KERBEROS }}', + 'OAUTH2': '{{ OAUTH2 }}' } }); diff --git a/web/pgadmin/browser/tests/test_kerberos_with_mocking.py b/web/pgadmin/browser/tests/test_kerberos_with_mocking.py index e67ced8c4df..0f49c444d31 100644 --- a/web/pgadmin/browser/tests/test_kerberos_with_mocking.py +++ b/web/pgadmin/browser/tests/test_kerberos_with_mocking.py @@ -13,6 +13,7 @@ from pgadmin.authenticate.registry import AuthSourceRegistry from unittest.mock import patch, MagicMock from werkzeug.datastructures import Headers +from pgadmin.utils.constants import LDAP, INTERNAL, KERBEROS class KerberosLoginMockTestCase(BaseTestGenerator): @@ -23,17 +24,17 @@ class KerberosLoginMockTestCase(BaseTestGenerator): scenarios = [ ('Spnego/Kerberos Authentication: Test Unauthorized', dict( - auth_source=['kerberos'], + auth_source=[KERBEROS], auto_create_user=True, flag=1 )), ('Spnego/Kerberos Authentication: Test Authorized', dict( - auth_source=['kerberos'], + auth_source=[KERBEROS], auto_create_user=True, flag=2 )), ('Spnego/Kerberos Update Ticket', dict( - auth_source=['kerberos'], + auth_source=[KERBEROS], auto_create_user=True, flag=3 )) @@ -49,7 +50,7 @@ def setUpClass(cls): def setUp(self): app_config.AUTHENTICATION_SOURCES = self.auth_source - self.app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'kerberos' + self.app.PGADMIN_EXTERNAL_AUTH_SOURCE = KERBEROS def runTest(self): """This function checks spnego/kerberos login functionality.""" @@ -100,14 +101,13 @@ def __init__(self): self.initiator_name = 'user@PGADMIN.ORG' del_crads = delCrads() - - AuthSourceRegistry._registry['kerberos'].negotiate_start = MagicMock( + AuthSourceRegistry._registry[KERBEROS].negotiate_start = MagicMock( return_value=[True, del_crads]) return del_crads def test_update_ticket(self): # Response header should include the Negotiate header in the first call - response = self.tester.get('/authenticate/kerberos/update_ticket') + response = self.tester.get('/kerberos/update_ticket') self.assertEqual(response.status_code, 401) self.assertEqual(response.headers.get('www-authenticate'), 'Negotiate') @@ -117,12 +117,13 @@ def test_update_ticket(self): krb_token = Headers({}) krb_token['Authorization'] = 'Negotiate CTOKEN' - response = self.tester.get('/authenticate/kerberos/update_ticket', + response = self.tester.get('/kerberos/update_ticket', headers=krb_token) self.assertEqual(response.status_code, 200) + self.tester.logout() def tearDown(self): - self.app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap' + pass @classmethod def tearDownClass(cls): @@ -130,5 +131,6 @@ def tearDownClass(cls): We need to again login the test client as soon as test scenarios finishes. """ - app_config.AUTHENTICATION_SOURCES = ['internal'] + cls.tester.logout() + app_config.AUTHENTICATION_SOURCES = [INTERNAL] utils.login_tester_account(cls.tester) diff --git a/web/pgadmin/browser/tests/test_ldap_login.py b/web/pgadmin/browser/tests/test_ldap_login.py index a21268f7f8d..51b512afd99 100644 --- a/web/pgadmin/browser/tests/test_ldap_login.py +++ b/web/pgadmin/browser/tests/test_ldap_login.py @@ -11,6 +11,7 @@ from pgadmin.utils.route import BaseTestGenerator from regression.python_test_utils import test_utils as utils from regression.test_setup import config_data +from pgadmin.utils.constants import LDAP, INTERNAL class LDAPLoginTestCase(BaseTestGenerator): @@ -50,7 +51,7 @@ def setUp(self): ldap_config = config_data['ldap_config'][0][self.config_key_param] except (KeyError, TypeError, IndexError): self.skipTest("LDAP config not set.") - app_config.AUTHENTICATION_SOURCES = ['ldap'] + app_config.AUTHENTICATION_SOURCES = [LDAP] app_config.LDAP_AUTO_CREATE_USER = True app_config.LDAP_SERVER_URI = ldap_config['uri'] app_config.LDAP_BASE_DN = ldap_config['base_dn'] @@ -70,6 +71,7 @@ def setUp(self): if ldap_config['anonymous_bind'] != "" and\ ldap_config['anonymous_bind']: app_config.LDAP_ANONYMOUS_BIND = True + self.app.PGADMIN_EXTERNAL_AUTH_SOURCE = LDAP def runTest(self): """This function checks login functionality.""" @@ -92,5 +94,5 @@ def tearDownClass(cls): finishes. """ cls.tester.logout() - app_config.AUTHENTICATION_SOURCES = ['internal'] + app_config.AUTHENTICATION_SOURCES = [INTERNAL] utils.login_tester_account(cls.tester) diff --git a/web/pgadmin/browser/tests/test_ldap_with_mocking.py b/web/pgadmin/browser/tests/test_ldap_with_mocking.py index 92f5c70c798..38c6b4724a1 100644 --- a/web/pgadmin/browser/tests/test_ldap_with_mocking.py +++ b/web/pgadmin/browser/tests/test_ldap_with_mocking.py @@ -13,6 +13,7 @@ from regression.test_setup import config_data from pgadmin.authenticate.registry import AuthSourceRegistry from unittest.mock import patch +from pgadmin.utils.constants import LDAP, INTERNAL class LDAPLoginMockTestCase(BaseTestGenerator): @@ -23,17 +24,17 @@ class LDAPLoginMockTestCase(BaseTestGenerator): scenarios = [ ('LDAP Authentication with Auto Create User', dict( - auth_source=['ldap'], + auth_source=[LDAP], auto_create_user=True, username='ldap_user', password='ldap_pass')), ('LDAP Authentication without Auto Create User', dict( - auth_source=['ldap'], + auth_source=[LDAP], auto_create_user=False, username='ldap_user', password='ldap_pass')), ('LDAP + Internal Authentication', dict( - auth_source=['ldap', 'internal'], + auth_source=[LDAP, INTERNAL], auto_create_user=False, username=config_data[ 'pgAdmin4_login_credentials']['login_username'], @@ -56,14 +57,15 @@ def setUp(self): app_config.LDAP_ANONYMOUS_BIND = False app_config.LDAP_BIND_USER = None app_config.LDAP_BIND_PASSWORD = None + self.app.PGADMIN_EXTERNAL_AUTH_SOURCE = LDAP - @patch.object(AuthSourceRegistry._registry['ldap'], 'connect', + @patch.object(AuthSourceRegistry._registry[LDAP], 'connect', return_value=[True, "Done"]) - @patch.object(AuthSourceRegistry._registry['ldap'], 'search_ldap_user', + @patch.object(AuthSourceRegistry._registry[LDAP], 'search_ldap_user', return_value=[True, '']) def runTest(self, conn_mock_obj, search_mock_obj): """This function checks ldap login functionality.""" - AuthSourceRegistry._registry['ldap'].dedicated_user = False + AuthSourceRegistry._registry[LDAP].dedicated_user = False res = self.tester.login(self.username, self.password, True) respdata = 'Gravatar image for %s' % self.username self.assertTrue(respdata in res.data.decode('utf8')) @@ -78,5 +80,5 @@ def tearDownClass(cls): finishes. """ cls.tester.logout() - app_config.AUTHENTICATION_SOURCES = ['internal'] + app_config.AUTHENTICATION_SOURCES = [INTERNAL] utils.login_tester_account(cls.tester) diff --git a/web/pgadmin/browser/tests/test_login.py b/web/pgadmin/browser/tests/test_login.py index 743d0099cdd..451c05b6461 100644 --- a/web/pgadmin/browser/tests/test_login.py +++ b/web/pgadmin/browser/tests/test_login.py @@ -12,6 +12,7 @@ from pgadmin.utils.route import BaseTestGenerator from regression.python_test_utils import test_utils as utils from regression.test_setup import config_data +from pgadmin.utils.constants import INTERNAL class LoginTestCase(BaseTestGenerator): @@ -98,7 +99,7 @@ def setUpClass(cls): # No need to call base class setup function def setUp(self): - pass + app_config.AUTHENTICATION_SOURCES = [INTERNAL] def runTest(self): """This function checks login functionality.""" diff --git a/web/pgadmin/browser/tests/test_master_password.py b/web/pgadmin/browser/tests/test_master_password.py index b5eabd6d0ff..be4675a73e9 100644 --- a/web/pgadmin/browser/tests/test_master_password.py +++ b/web/pgadmin/browser/tests/test_master_password.py @@ -11,6 +11,7 @@ from pgadmin.utils.route import BaseTestGenerator import config +from pgadmin.utils.constants import INTERNAL class MasterPasswordTestCase(BaseTestGenerator): @@ -53,6 +54,7 @@ class MasterPasswordTestCase(BaseTestGenerator): def setUp(self): config.MASTER_PASSWORD_REQUIRED = True + config.AUTHENTICATION_SOURCES = [INTERNAL] def runTest(self): """This function will check change password functionality.""" diff --git a/web/pgadmin/browser/tests/test_oauth2_with_mocking.py b/web/pgadmin/browser/tests/test_oauth2_with_mocking.py new file mode 100644 index 00000000000..b170720a8d4 --- /dev/null +++ b/web/pgadmin/browser/tests/test_oauth2_with_mocking.py @@ -0,0 +1,147 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import config as app_config +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from pgadmin.authenticate.registry import AuthSourceRegistry +from unittest.mock import patch, MagicMock +from pgadmin.authenticate import AuthSourceManager +from pgadmin.utils.constants import OAUTH2, LDAP, INTERNAL + + +class Oauth2LoginMockTestCase(BaseTestGenerator): + """ + This class checks oauth2 login functionality by mocking + External Oauth2 Authentication. + """ + + scenarios = [ + ('Oauth2 External Authentication', dict( + auth_source=['oauth2'], + oauth2_provider='github', + flag=1 + )), + ('Oauth2 Authentication', dict( + auth_source=['oauth2'], + oauth2_provider='github', + flag=2 + )), + ] + + @classmethod + def setUpClass(cls): + """ + We need to logout the test client as we are testing + spnego/kerberos login scenarios. + """ + cls.tester.logout() + + def setUp(self): + app_config.AUTHENTICATION_SOURCES = self.auth_source + self.app.PGADMIN_EXTERNAL_AUTH_SOURCE = OAUTH2 + app_config.OAUTH2_CONFIG = [ + { + 'OAUTH2_NAME': 'github', + 'OAUTH2_DISPLAY_NAME': 'Github', + 'OAUTH2_CLIENT_ID': 'testclientid', + 'OAUTH2_CLIENT_SECRET': 'testclientsec', + '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': 'user', + 'OAUTH2_ICON': 'fa-github', + 'OAUTH2_BUTTON_COLOR': '#3253a8', + } + ] + + def runTest(self): + """This function checks oauth2 login functionality.""" + if app_config.SERVER_MODE is False: + self.skipTest( + "Can not run Oauth2 Authentication in the Desktop mode." + ) + + if self.flag == 1: + self.test_external_authentication() + elif self.flag == 2: + self.test_oauth2_authentication() + + def test_external_authentication(self): + """ + Ensure that the user should be redirected + to the external url for the authentication. + """ + + AuthSourceManager.update_auth_sources = MagicMock() + + try: + self.tester.login( + email=None, password=None, + _follow_redirects=True, + headers=None, + extra_form_data=dict(oauth2_button=self.oauth2_provider) + ) + except Exception as e: + self.assertEqual('Following external' + ' redirects is not supported.', str(e)) + + def test_oauth2_authentication(self): + """ + Ensure that when the client sends an correct authorization token, + they receive a 200 OK response and the user principal is extracted and + passed on to the routed method. + """ + + profile = self.mock_user_profile() + + # Mock Oauth2 Authenticate + AuthSourceRegistry._registry[OAUTH2].authenticate = MagicMock( + return_value=[True, '']) + + AuthSourceManager.update_auth_sources = MagicMock() + + # Create AuthSourceManager object + auth_obj = AuthSourceManager({}, [OAUTH2]) + auth_source = AuthSourceRegistry.get(OAUTH2) + auth_obj.set_source(auth_source) + auth_obj.set_current_source(auth_source.get_source_name()) + + # Check the login with Oauth2 + res = self.tester.login(email=None, password=None, + _follow_redirects=True, + headers=None, + extra_form_data=dict( + oauth2_button=self.oauth2_provider) + ) + + respdata = 'Gravatar image for %s' % profile['email'] + self.assertTrue(respdata in res.data.decode('utf8')) + + def mock_user_profile(self): + profile = {'email': 'oauth2@gmail.com'} + + AuthSourceRegistry._registry[OAUTH2].get_user_profile = MagicMock( + return_value=profile) + return profile + + def tearDown(self): + self.tester.logout() + + @classmethod + def tearDownClass(cls): + """ + We need to again login the test client as soon as test scenarios + finishes. + """ + cls.tester.logout() + app_config.AUTHENTICATION_SOURCES = [INTERNAL] + utils.login_tester_account(cls.tester) diff --git a/web/pgadmin/misc/bgprocess/processes.py b/web/pgadmin/misc/bgprocess/processes.py index 25e0a2a9eb0..4da7896a032 100644 --- a/web/pgadmin/misc/bgprocess/processes.py +++ b/web/pgadmin/misc/bgprocess/processes.py @@ -280,7 +280,7 @@ def start(self, cb=None): env['OUTDIR'] = self.log_dir env['PGA_BGP_FOREGROUND'] = "1" if config.SERVER_MODE and session and \ - session['_auth_source_manager_obj']['current_source'] == \ + session['auth_source_manager']['current_source'] == \ KERBEROS: env['KRB5CCNAME'] = session['KRB5CCNAME'] diff --git a/web/pgadmin/static/scss/_pgadmin.style.scss b/web/pgadmin/static/scss/_pgadmin.style.scss index 6a185471b31..d990b550988 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 2e92d7b125e..884562d2188 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,16 @@ {% for key, lang in config.LANGUAGES.items() %} {% endfor %} - +
+{% if config.OAUTH2 in config.AUTHENTICATION_SOURCES and config.AUTHENTICATION_SOURCES %} + {% for oauth_config in config.OAUTH2_CONFIG %} + + {% endfor %} +{% endif %} {% endif %} {% endblock %} diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py index 5d56081bad9..3aabd774989 100644 --- a/web/pgadmin/tools/user_management/__init__.py +++ b/web/pgadmin/tools/user_management/__init__.py @@ -176,7 +176,7 @@ def current_user_info(): config.ALLOW_SAVE_TUNNEL_PASSWORD and session[ 'allow_save_password'] else 'false', auth_sources=config.AUTHENTICATION_SOURCES, - current_auth_source=session['_auth_source_manager_obj'][ + current_auth_source=session['auth_source_manager'][ 'current_source'] if config.SERVER_MODE is True else INTERNAL ), status=200, diff --git a/web/pgadmin/tools/user_management/static/js/user_management.js b/web/pgadmin/tools/user_management/static/js/user_management.js index e436f494893..ffed1d44df6 100644 --- a/web/pgadmin/tools/user_management/static/js/user_management.js +++ b/web/pgadmin/tools/user_management/static/js/user_management.js @@ -28,6 +28,7 @@ define([ DEFAULT_AUTH_SOURCE = pgConst['INTERNAL'], LDAP = pgConst['LDAP'], KERBEROS = pgConst['KERBEROS'], + OAUTH2 = pgConst['OAUTH2'], AUTH_ONLY_INTERNAL = (userInfo['auth_sources'].length == 1 && userInfo['auth_sources'].includes(DEFAULT_AUTH_SOURCE)) ? true : false, userFilter = function(collection) { return (new Backgrid.Extension.ClientSideFilter({ @@ -607,6 +608,16 @@ define([ this.get('username') ); + this.errorModel.set('username', errmsg); + return errmsg; + } + else if (!!this.get('username') && this.collection.nonFilter.where({ + 'username': this.get('username'), 'auth_source': OAUTH2, + }).length > 1) { + errmsg = gettext('The username %s already exists.', + this.get('username') + ); + this.errorModel.set('username', errmsg); return errmsg; } @@ -1053,7 +1064,7 @@ define([ saveUser: function(m) { var d = m.toJSON(true); - if((m.isNew() && (m.get('auth_source') == LDAP || m.get('auth_source') == KERBEROS) && (!m.get('username') || !m.get('auth_source') || !m.get('role'))) + if((m.isNew() && (m.get('auth_source') == LDAP || m.get('auth_source') == KERBEROS || m.get('auth_source') == OAUTH2) && (!m.get('username') || !m.get('auth_source') || !m.get('role'))) || (m.isNew() && m.get('auth_source') == DEFAULT_AUTH_SOURCE && (!m.get('email') || !m.get('role') || !m.get('newPassword') || !m.get('confirmPassword') || m.get('newPassword') != m.get('confirmPassword'))) || (!m.isNew() && m.get('newPassword') != m.get('confirmPassword'))) { diff --git a/web/pgadmin/utils/constants.py b/web/pgadmin/utils/constants.py index 67cc690e983..bee5bbffb26 100644 --- a/web/pgadmin/utils/constants.py +++ b/web/pgadmin/utils/constants.py @@ -55,10 +55,12 @@ INTERNAL = 'internal' LDAP = 'ldap' KERBEROS = 'kerberos' +OAUTH2 = "oauth2" SUPPORTED_AUTH_SOURCES = [INTERNAL, LDAP, - KERBEROS] + KERBEROS, + OAUTH2] BINARY_PATHS = { "as_bin_paths": [ diff --git a/web/pgadmin/utils/driver/psycopg2/connection.py b/web/pgadmin/utils/driver/psycopg2/connection.py index 3bddb9fa54f..69512427358 100644 --- a/web/pgadmin/utils/driver/psycopg2/connection.py +++ b/web/pgadmin/utils/driver/psycopg2/connection.py @@ -319,7 +319,7 @@ def connect(self, **kwargs): config.APP_NAME, conn_id) if config.SERVER_MODE and \ - session['_auth_source_manager_obj']['current_source'] == \ + session['auth_source_manager']['current_source'] == \ KERBEROS and 'KRB5CCNAME' in session\ and manager.kerberos_conn: lock.acquire() @@ -353,7 +353,7 @@ def connect(self, **kwargs): self._wait(pg_conn) if config.SERVER_MODE and \ - session['_auth_source_manager_obj']['current_source'] == \ + session['auth_source_manager']['current_source'] == \ KERBEROS: environ['KRB5CCNAME'] = '' @@ -378,7 +378,7 @@ def connect(self, **kwargs): return False, msg finally: if config.SERVER_MODE and \ - session['_auth_source_manager_obj']['current_source'] == \ + session['auth_source_manager']['current_source'] == \ KERBEROS and lock.locked(): lock.release() diff --git a/web/pgadmin/utils/master_password.py b/web/pgadmin/utils/master_password.py index f962684ffd1..fcf15af53cd 100644 --- a/web/pgadmin/utils/master_password.py +++ b/web/pgadmin/utils/master_password.py @@ -31,13 +31,11 @@ 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 enc_key is None: return False, None - elif config.SERVER_MODE and \ - session['_auth_source_manager_obj']['current_source']\ - == KERBEROS: - return True, session['kerberos_key'] if 'kerberos_key' in session \ - else None + elif not config.MASTER_PASSWORD_REQUIRED and config.SERVER_MODE and \ + 'pass_enc_key' in session: + return True, session['pass_enc_key'] else: return True, enc_key diff --git a/web/regression/python_test_utils/csrf_test_client.py b/web/regression/python_test_utils/csrf_test_client.py index 5e52590147d..78189f238fa 100644 --- a/web/regression/python_test_utils/csrf_test_client.py +++ b/web/regression/python_test_utils/csrf_test_client.py @@ -92,9 +92,7 @@ def generate_csrf_token(self, *args, **kwargs): # and make a test request context that has those cookies in it. environ_overrides = {} self.cookie_jar.inject_wsgi(environ_overrides) - with self.app.test_request_context( - "/login", environ_overrides=environ_overrides, - ): + with self.app.test_request_context(): # Now, we call Flask-WTF's method of generating a CSRF token... csrf_token = generate_csrf() # ...which also sets a value in `flask.session`, so we need to @@ -106,18 +104,27 @@ def generate_csrf_token(self, *args, **kwargs): return csrf_token def login(self, email, password, _follow_redirects=False, - headers=None): + headers=None, extra_form_data=dict()): + csrf_token = None if config.SERVER_MODE is True: - res = self.get('/login', follow_redirects=True) + res = self.get('/login', + follow_redirects=_follow_redirects) csrf_token = self.fetch_csrf(res) - else: + + if csrf_token is None: csrf_token = self.generate_csrf_token() + form_data = dict( + email=email, + password=password, + csrf_token=csrf_token + ) + + if extra_form_data: + form_data.update(extra_form_data) + res = self.post( - '/authenticate/login', data=dict( - email=email, password=password, - csrf_token=csrf_token, - ), + '/authenticate/login', data=form_data, follow_redirects=_follow_redirects, headers=headers ) diff --git a/web/regression/python_test_utils/test_utils.py b/web/regression/python_test_utils/test_utils.py index 9dba4cf063b..d8b92dec6e5 100644 --- a/web/regression/python_test_utils/test_utils.py +++ b/web/regression/python_test_utils/test_utils.py @@ -1656,14 +1656,14 @@ def create_user(user_details): cur = conn.cursor() user_details = ( user_details['login_username'], user_details['login_username'], - user_details['login_password'], 1) + user_details['login_password'], 1, uuid.uuid4().hex) cur.execute( 'select * from user where username = "%s"' % user_details[0]) user = cur.fetchone() if user is None: - cur.execute('INSERT INTO user (username, email, password, active) ' - 'VALUES (?,?,?,?)', user_details) + cur.execute('INSERT INTO user (username, email, password, active,' + ' fs_uniquifier) VALUES (?,?,?,?,?)', user_details) user_id = cur.lastrowid conn.commit() else: diff --git a/web/regression/runtests.py b/web/regression/runtests.py index cea20e3cce9..7503f7c639d 100644 --- a/web/regression/runtests.py +++ b/web/regression/runtests.py @@ -97,6 +97,7 @@ from regression.python_test_utils.csrf_test_client import TestClient config.SETTINGS_SCHEMA_VERSION = SCHEMA_VERSION +from pgadmin.utils.constants import LDAP # Override some other defaults from logging import WARNING @@ -117,7 +118,7 @@ app.config['WTF_CSRF_ENABLED'] = True # Authentication sources -app.PGADMIN_EXTERNAL_AUTH_SOURCE = 'ldap' +app.PGADMIN_EXTERNAL_AUTH_SOURCE = LDAP app.test_client_class = TestClient test_client = app.test_client() diff --git a/web/yarn.lock b/web/yarn.lock index cd614283b82..5e1e721756b 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -9016,9 +9016,9 @@ watchpack@^2.0.0: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" -"webcabin-docker@git+https://github.com/EnterpriseDB/wcDocker/#89e006611f4d0fc24b0a098fa2041821d093be4f": +"webcabin-docker@git+https://github.com/EnterpriseDB/wcDocker/#06daee1a8111b4ed08d58670674a2967e4f68313": version "2.2.5" - resolved "git+https://github.com/EnterpriseDB/wcDocker/#89e006611f4d0fc24b0a098fa2041821d093be4f" + resolved "git+https://github.com/EnterpriseDB/wcDocker/#06daee1a8111b4ed08d58670674a2967e4f68313" dependencies: "@fortawesome/fontawesome-free" "^5.14.0" FileSaver "^0.10.0"