diff --git a/.dockerignore b/.dockerignore index 93ac96f9b..a395c4982 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,12 @@ web/node_modules web/*.log web/regression web/**/tests/ +web/**/*.pyc +web/**/__pycache__ .DS_Store web/pgadmin/messages.pot web/pgadmin/translations/??/LC_MESSAGES/messages.po +Dockerfile +docs/en_US/_build/**/* +src-build/**/* +dist/**/* diff --git a/Dockerfile b/Dockerfile index ee5c447d1..a265e6a2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,6 +76,9 @@ RUN apk add --no-cache \ krb5-dev \ rust \ cargo \ + zlib-dev \ + libjpeg-turbo-dev \ + libpng-dev \ python3-dev && \ python3 -m venv --system-site-packages --without-pip /venv && \ /venv/bin/python3 -m pip install --no-cache-dir -r requirements.txt && \ @@ -177,6 +180,7 @@ RUN apk add \ py3-pip \ postfix \ krb5-libs \ + libjpeg-turbo \ shadow \ sudo \ libedit \ diff --git a/docs/en_US/getting_started.rst b/docs/en_US/getting_started.rst index 92967847e..11646b6ea 100644 --- a/docs/en_US/getting_started.rst +++ b/docs/en_US/getting_started.rst @@ -33,6 +33,7 @@ Mode is pre-configured for security. deployment login + mfa user_management change_user_password restore_locked_user diff --git a/docs/en_US/images/mfa_registration.png b/docs/en_US/images/mfa_registration.png new file mode 100644 index 000000000..12b5b1759 Binary files /dev/null and b/docs/en_US/images/mfa_registration.png differ diff --git a/docs/en_US/mfa.rst b/docs/en_US/mfa.rst new file mode 100644 index 000000000..cf95f4063 --- /dev/null +++ b/docs/en_US/mfa.rst @@ -0,0 +1,57 @@ +.. _mfa: + +************************************************* +`Enabling two-factor authentication (2FA)`:index: +************************************************* + +About two-factor authentication +=============================== +Two-factor authentication (2FA) is an extra layer of security used when logging +into websites or apps. With 2FA, you have to log in with your username and +password and provide another form of authentication that only you know or have +access to. + + +Setup two-factor authentication +=============================== +To set up 2FA for pgAdmin 4, you must configure the Two-factor Authentication +settings in *config_local.py* or *config_system.py* (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 + + "MFA_ENABLED","The default value for this parameter is False. + To enable 2FA, set the value to *True*" + "SUPPORTED_MFA_LIST", "Set the authentication methods to be supported " + "MFA_EMAIL_SUBJECT", " - Verification Code e.g. pgAdmin 4 - + Verification Code" + "MFA_FORCE_REGISTRATION", "Force the user to configure the authentication + method on login (if no authentication is already configured)." + +*NOTE: You must set the 'Mail server settings' in config_local.py or +config_system.py in order to use 'email' as two-factor authentication method +(see the* :ref:`config.py ` *documentation).* + + +Configure two-factor authentication +=================================== +To configure 2FA for a user, you must click on 'Two-factor Authentication' +in the `User` menu in right-top corner. It will list down all the supported +multi factor authentication methods. Click on 'Setup' of one of those methods +and follow the steps for each authentication method. You will see the `Delete` +button for the authentication method, which is already been configured. +Clicking on `Delete` button will deregister the authentication method for the +current user. + +.. image:: images/mfa_registration.png + :alt: Configure two-factor authentication + :align: center + +You can also force users to configure the two-factor +authentication methods on login by setting *MFA_FORCE_REGISTRATION* parameter +to *True*. diff --git a/docs/en_US/release_notes_6_3.rst b/docs/en_US/release_notes_6_3.rst index 9d233d2ff..b52766f3d 100644 --- a/docs/en_US/release_notes_6_3.rst +++ b/docs/en_US/release_notes_6_3.rst @@ -9,6 +9,7 @@ This release contains a number of bug fixes and new features since the release o New features ************ +| `Issue #6543 `_ - Added support for Two-factor authentication for improving security. | `Issue #6872 `_ - Include GSSAPI support in the PostgreSQL libraries and utilities on macOS. Housekeeping diff --git a/pkg/mac/build-functions.sh b/pkg/mac/build-functions.sh index 35502265b..10f27ac0f 100644 --- a/pkg/mac/build-functions.sh +++ b/pkg/mac/build-functions.sh @@ -1,10 +1,11 @@ _setup_env() { - APP_RELEASE=`grep "^APP_RELEASE" web/config.py | cut -d"=" -f2 | sed 's/ //g'` - APP_REVISION=`grep "^APP_REVISION" web/config.py | cut -d"=" -f2 | sed 's/ //g'` - APP_NAME=`grep "^APP_NAME" web/config.py | cut -d"=" -f2 | sed "s/'//g" | sed 's/^ //'` + FUNCS_DIR=$(cd `dirname $0` && pwd)/../.. + APP_RELEASE=`grep "^APP_RELEASE" ${FUNCS_DIR}/web/config.py | cut -d"=" -f2 | sed 's/ //g'` + APP_REVISION=`grep "^APP_REVISION" ${FUNCS_DIR}/web/config.py | cut -d"=" -f2 | sed 's/ //g'` + APP_NAME=`grep "^APP_NAME" ${FUNCS_DIR}/web/config.py | cut -d"=" -f2 | sed "s/'//g" | sed 's/^ //'` APP_LONG_VERSION=${APP_RELEASE}.${APP_REVISION} APP_SHORT_VERSION=`echo ${APP_LONG_VERSION} | cut -d . -f1,2` - APP_SUFFIX=`grep "^APP_SUFFIX" web/config.py | cut -d"=" -f2 | sed 's/ //g' | sed "s/'//g"` + APP_SUFFIX=`grep "^APP_SUFFIX" ${FUNCS_DIR}/web/config.py | cut -d"=" -f2 | sed 's/ //g' | sed "s/'//g"` if [ ! -z ${APP_SUFFIX} ]; then APP_LONG_VERSION=${APP_LONG_VERSION}-${APP_SUFFIX} fi @@ -182,7 +183,7 @@ _fixup_imports() { for LIB in $( otool -L ${TODO_OBJ} | \ sed -n 's|^.*[[:space:]]\([^[:space:]]*\.dylib\).*$|\1|p' | \ - egrep -v '^(/usr/lib)|(/System)|@executable_path' \ + egrep -v '^(/usr/lib)|(/System)|@executable_path|@loader_path|/DLC/PIL/' \ ); do # Copy in any required dependencies LIB_BN="$(basename "${LIB}")" ; diff --git a/requirements.txt b/requirements.txt index 06b04f886..256598289 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,3 +42,6 @@ user-agents==2.2.0 pywinpty==1.1.1; sys_platform=="win32" Authlib==0.15.* requests==2.25.* +pyotp==2.* +qrcode==7.* +Pillow==8.3.* diff --git a/web/.eslintignore b/web/.eslintignore index 676a4f03f..eab18762b 100644 --- a/web/.eslintignore +++ b/web/.eslintignore @@ -4,3 +4,4 @@ vendor templates/ templates\ ycache +regression/htmlcov diff --git a/web/config.py b/web/config.py index 2d6b544d4..c0079762a 100644 --- a/web/config.py +++ b/web/config.py @@ -743,6 +743,29 @@ WEBSERVER_AUTO_CREATE_USER = True WEBSERVER_REMOTE_USER = 'REMOTE_USER' +########################################################################## +# Two-factor Authentication Configuration +########################################################################## + +# Set it to True, to enable the two-factor authentication +MFA_ENABLED = True + +# Set it to True, to ask the users to register forcefully for the +# two-authentication methods on logged-in. +MFA_FORCE_REGISTRATION = False + +# pgAdmin supports Two-factor authentication by either sending an one-time code +# to an email, or using the TOTP based application like Google Authenticator. +MFA_SUPPORTED_METHODS = ["email", "authenticator"] + +# NOTE: Please set the 'Mail server settings' to use 'email' as two-factor +# authentication method. + +# Subject for the email verification code +# Default: - Verification Code +# e.g. pgAdmin 4 - Verification Code +MFA_EMAIL_SUBJECT = None + ########################################################################## # PSQL tool settings ########################################################################## diff --git a/web/migrations/script.py.mako b/web/migrations/script.py.mako index 8cda71380..340757c99 100644 --- a/web/migrations/script.py.mako +++ b/web/migrations/script.py.mako @@ -14,8 +14,7 @@ Revises: ${down_revision | comma,n} Create Date: ${create_date} """ -from alembic import op -import sqlalchemy as sa +from pgadmin.model import db ${imports if imports else ""} # revision identifiers, used by Alembic. diff --git a/web/migrations/versions/15c88f765bc8_.py b/web/migrations/versions/15c88f765bc8_.py new file mode 100644 index 000000000..7887ca9d8 --- /dev/null +++ b/web/migrations/versions/15c88f765bc8_.py @@ -0,0 +1,44 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Update DB to version 31 + +Added a table `user_mfa` for saving the options on MFA for different sources. + +Revision ID: 15c88f765bc8 +Revises: 6650c52670c2 +Create Date: 2021-11-24 17:33:12.533825 + +""" +from pgadmin.model import db + + +# revision identifiers, used by Alembic. +revision = '15c88f765bc8' +down_revision = '6650c52670c2' +branch_labels = None +depends_on = None + + +def upgrade(): + db.engine.execute(""" +CREATE TABLE user_mfa( + user_id INTEGER NOT NULL, + mfa_auth VARCHAR(256) NOT NULL, + options TEXT, + PRIMARY KEY (user_id, mfa_auth), + FOREIGN KEY(user_id) REFERENCES user (id) +) + """) + # ### end Alembic commands ### + + +def downgrade(): + # pgAdmin only upgrades, downgrade not implemented. + pass diff --git a/web/migrations/versions/6650c52670c2_.py b/web/migrations/versions/6650c52670c2_.py index 90cc17d36..4f6f508e1 100644 --- a/web/migrations/versions/6650c52670c2_.py +++ b/web/migrations/versions/6650c52670c2_.py @@ -1,5 +1,13 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## -"""empty message +"""Update DB to version 30 Revision ID: 6650c52670c2 Revises: c465fee44968 diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py index e41242ac3..58d4930b4 100644 --- a/web/pgadmin/authenticate/__init__.py +++ b/web/pgadmin/authenticate/__init__.py @@ -19,7 +19,7 @@ from flask_security.views import _security from flask_security.utils import get_post_logout_redirect, \ get_post_login_redirect, logout_user -from pgadmin import db, User +from pgadmin.model import db, User from pgadmin.utils import PgAdminModule from pgadmin.utils.constants import KERBEROS, INTERNAL, OAUTH2, LDAP from pgadmin.authenticate.registry import AuthSourceRegistry @@ -27,6 +27,26 @@ from pgadmin.authenticate.registry import AuthSourceRegistry MODULE_NAME = 'authenticate' auth_obj = None +_URL_WITH_NEXT_PARAM = "{0}?next={1}" + + +def get_logout_url() -> str: + """ + Returns the logout url based on the current authentication method. + + Returns: + str: logout url + """ + BROWSER_INDEX = 'browser.index' + if config.SERVER_MODE and\ + session['auth_source_manager']['current_source'] == \ + KERBEROS: + return _URL_WITH_NEXT_PARAM.format(url_for( + 'authenticate.kerberos_logout'), url_for(BROWSER_INDEX)) + + return _URL_WITH_NEXT_PARAM.format( + url_for('security.logout'), url_for(BROWSER_INDEX)) + class AuthenticateModule(PgAdminModule): def get_exposed_url_endpoints(self): diff --git a/web/pgadmin/authenticate/mfa/__init__.py b/web/pgadmin/authenticate/mfa/__init__.py new file mode 100644 index 000000000..51dd12658 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/__init__.py @@ -0,0 +1,110 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +"""Multi-factor Authentication (MFA) implementation""" + +from flask import Blueprint, session, Flask +from flask_babel import gettext as _ + +import config +from .utils import mfa_enabled, segregate_valid_and_invalid_mfa_methods + +from .registry import MultiFactorAuthRegistry +from .views import validate_view, registration_view + + +def __create_blueprint() -> Blueprint: + """ + Geneates the blueprint for 'mfa' endpoint, and also - define the required + endpoints within that blueprint. + + Returns: + Blueprint: MFA blueprint object + """ + blueprint = Blueprint( + "mfa", __name__, url_prefix="/mfa", + static_folder="static", + template_folder="templates" + ) + + blueprint.add_url_rule( + "/validate", "validate", validate_view, methods=("GET", "POST",) + ) + + blueprint.add_url_rule( + "/register", "register", registration_view, methods=("GET", "POST",) + ) + + return blueprint + + +def init_app(app: Flask): + """ + Initialize the flask application for the multi-faction authentication + end-points, when the SERVER_MODE is set to True, and MFA_ENABLED is set to + True in the configuration file. + + Args: + app (Flask): Flask Application object + """ + + if getattr(config, "SERVER_MODE", False) is False and \ + getattr(config, "MFA_ENABLED", False) is False: + return + + MultiFactorAuthRegistry.load_modules(app) + + def exclude_invalid_mfa_auth_methods(): + """ + Exclude the invalid MFA auth methods specified in MFA_SUPPORTED_METHODS + configuration. + """ + + supported_methods = getattr(config, "MFA_SUPPORTED_METHODS", []) + invalid_auth_methods = [] + + supported_methods, invalid_auth_methods = \ + segregate_valid_and_invalid_mfa_methods(supported_methods) + + for auth_method in invalid_auth_methods: + app.logger.warning(_( + "'{}' is not a valid multi-factor authentication method" + ).format(auth_method)) + + config.MFA_SUPPORTED_METHODS = supported_methods + blueprint = __create_blueprint() + + for mfa_method in supported_methods: + mfa = MultiFactorAuthRegistry.get(mfa_method) + mfa.register_url_endpoints(blueprint) + + app.register_blueprint(blueprint) + app.register_logout_hook(blueprint) + + from flask_login import user_logged_out + + @user_logged_out.connect_via(app) + def clear_session_on_login(sender, user): + session['mfa_authenticated'] = False + + def disable_mfa(): + """ + Set MFA_ENABLED configuration to False. + + Also - log a warning message about no valid authentication method found + during initialization. + """ + if getattr(config, 'MFA_ENABLED', False) is True and \ + getattr(config, 'SERVER_MODE', False) is True: + app.logger.warning(_( + "No valid multi-factor authentication found, hence - " + "disabling it." + )) + config.MFA_ENABLED = False + + mfa_enabled(exclude_invalid_mfa_auth_methods, disable_mfa) diff --git a/web/pgadmin/authenticate/mfa/authenticator.py b/web/pgadmin/authenticate/mfa/authenticator.py new file mode 100644 index 000000000..900166ec4 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/authenticator.py @@ -0,0 +1,222 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +"""Multi-factor Authentication implementation for Time-based One-Time Password +(TOTP) applications""" + +import base64 +from io import BytesIO +from typing import Union + +from flask import url_for, session, flash +from flask_babel import gettext as _ +from flask_login import current_user +import pyotp +import qrcode + +import config +from pgadmin.model import UserMFA + +from .registry import BaseMFAuth +from .utils import ValidationException, fetch_auth_option, mfa_add + + +_TOTP_AUTH_METHOD = "authenticator" +_TOTP_AUTHENTICATOR = _("Authenticator App") + + +class TOTPAuthenticator(BaseMFAuth): + """ + Authenction class for TOTP based authentication. + + Base Class: BaseMFAuth + """ + + @classmethod + def __create_topt_for_currentuser(cls) -> pyotp.TOTP: + """ + Create the TOPT object using the secret stored for the current user in + the configuration database. + + Assumption: Configuration database is not modified by anybody manually, + and removed the secrete for the current user. + + Raises: + ValidationException: Raises when user is not registered for this + authenction method. + + Returns: + pyotp.TOTP: TOTP object for the current user (if registered) + """ + options, found = fetch_auth_option(_TOTP_AUTH_METHOD) + + if found is False: + raise ValidationException(_( + "User has not registered the Time-based One-Time Password " + "(TOTP) Authenticator for authentication." + )) + + if options is None or options == '': + raise ValidationException(_( + "User does not have valid HASH to generate the OTP." + )) + + return pyotp.TOTP(options) + + @property + def name(self) -> str: + """ + Name of the authetication method for internal presentation. + + Returns: + str: Short name for this authentication method + """ + return _TOTP_AUTH_METHOD + + @property + def label(self) -> str: + """ + Label for the UI for this authentication method. + + Returns: + str: User presentable string for this auth method + """ + return _(_TOTP_AUTHENTICATOR) + + @property + def icon(self) -> str: + """ + Property for the icon url string for this auth method, to be used on + the authentication or registration page. + + Returns: + str: url for the icon representation for this auth method + """ + return url_for("mfa.static", filename="images/totp_lock.svg") + + def validate(self, **kwargs): + """ + Validate the code sent using the HTTP request. + + Raises: + ValidationException: Raises when code is not valid + """ + code = kwargs.get('code', None) + totp = TOTPAuthenticator.__create_topt_for_currentuser() + + if totp.verify(code) is False: + raise ValidationException("Invalid Code") + + def validation_view(self) -> str: + """ + Generate the portion of the view to render on the authentication page + + Returns: + str: Authentication view as a string + """ + return ( + "
{auth_description}
" + "
" + " " + "
" + ).format( + auth_description=_( + "Enter the code shown in your authenticator application for " + "TOTP (Time-based One-Time Password)" + ), + otp_placeholder=_("Enter code"), + ) + + def _registration_view(self) -> str: + """ + Internal function to generate a view for the registration page. + + View will contain the QRCode image for the TOTP based authenticator + applications to scan. + + Returns: + str: Registration view with QRcode for TOTP based applications + """ + + option = session.pop('mfa_authenticator_opt', None) + if option is None: + option = pyotp.random_base32() + session['mfa_authenticator_opt'] = option + totp = pyotp.TOTP(option) + + uri = totp.provisioning_uri( + current_user.username, issuer_name=getattr( + config, "APP_NAME", "pgAdmin 4" + ) + ) + + img = qrcode.make(uri) + buffered = BytesIO() + img.save(buffered, format="JPEG") + img_base64 = base64.b64encode(buffered.getvalue()) + + return "".join([ + "
{auth_title}
", + "", + "", + "{qrcode_alt_text}", + "
{auth_description}
", + "
", + "", + "
", + ]).format( + auth_title=_(_TOTP_AUTHENTICATOR), + auth_method=_TOTP_AUTH_METHOD, + image=img_base64.decode("utf-8"), + qrcode_alt_text=_("TOTP Authenticator QRCode"), + auth_description=_( + "Scan the QR code and the enter the code from the " + "TOTP Authenticator application" + ), otp_placeholder=_("Enter code") + ) + + def registration_view(self, form_data) -> Union[str, None]: + """ + Returns the registration view for this authentication method. + + It is also responsible for validating the code during the registration. + + Args: + form_data (dict): Form data as a dictionary sent from the + registration page for rendering or validation of + the code. + + Returns: + str: Registration view for the 'authenticator' method if it is not + a request for the validation of the code or the code sent is + not a valid TOTP code, otherwise - it will return None. + """ + + if 'VALIDATE' not in form_data: + return self._registration_view() + + code = form_data.get('code', None) + authenticator_opt = session.get('mfa_authenticator_opt', None) + if authenticator_opt is None or \ + pyotp.TOTP(authenticator_opt).verify(code) is False: + flash(_("Failed to validate the code"), "danger") + return self._registration_view() + + mfa_add(_TOTP_AUTH_METHOD, authenticator_opt) + flash(_( + "TOTP Authenticator registered successfully for authentication." + ), "success") + session.pop('mfa_authenticator_opt', None) + + return None diff --git a/web/pgadmin/authenticate/mfa/email.py b/web/pgadmin/authenticate/mfa/email.py new file mode 100644 index 000000000..b0a2e2709 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/email.py @@ -0,0 +1,310 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +"""Multi-factor Authentication implementation by sending OTP through email""" + +from flask import url_for, session, Response, render_template, current_app, \ + flash +from flask_babel import gettext as _ +from flask_login import current_user +from flask_security import send_mail + +import config +from pgadmin.utils.csrf import pgCSRFProtect +from .registry import BaseMFAuth +from .utils import ValidationException, mfa_add, fetch_auth_option + + +def __generate_otp() -> str: + """ + Generate a six-digits one-time-password (OTP) for the current user. + + Returns: + str: A six-digits OTP for the current user + """ + import time + import base64 + import codecs + import random + + code = codecs.encode("{}{}{}".format( + time.time(), current_user.username, random.randint(1000, 9999) + ).encode(), "hex") + + res = 0 + idx = 0 + + while idx < len(code): + res += int((code[idx:idx + 6]).decode('utf-8'), base=16) + res %= 1000000 + idx += 5 + + return str(res).zfill(6) + + +def _send_code_to_email(_email: str = None) -> (bool, int, str): + """ + Send the code to the email address, provided in the argument or to the + email address of the current user, provided during the registration. + + Args: + _email (str, optional): Email Address, where to send the OTP code. + Defaults to None. + + Returns: + (bool, int, str): Returns a set as (failed?, HTTP Code, message string) + If 'failed?' is True, message contains the error + message for the user, else it contains the success + message for the user to consume. + """ + + if not current_user.is_authenticated: + return False, 401, _("Not accessible") + + if _email is None: + _email = getattr(current_user, 'email', None) + + if _email is None: + return False, 401, _("No email address is available.") + + try: + session["mfa_email_code"] = __generate_otp() + subject = getattr(config, 'MFA_EMAIL_SUBJECT', None) + + if subject is None: + subject = _("{} - Verification Code").format(config.APP_NAME) + + send_mail( + subject, + _email, + "send_email_otp", + user=current_user, + code=session["mfa_email_code"] + ) + except OSError as ose: + current_app.logger.exception(ose) + return False, 503, _("Failed to send the code to email.") + \ + "\n" + str(ose) + + message = _( + "A verification code was sent to {}. Check your email and enter " + "the code." + ).format(_mask_email(_email)) + + return True, 200, message + + +def _mask_email(_email: str) -> str: + """ + + Args: + _email (str): Email address to be masked + + Returns: + str: Masked email address + """ + import re + email_split = re.split('@', _email) + username, domain = email_split + domain_front, *domain_back_list = re.split('[.]', domain) + users = re.split('[.]', username) + + def _mask_except_first_char(_str: str) -> str: + """ + Mask all characters except first character of the input string. + Args: + _str (str): Input string to be masked + + Returns: + str: Masked string + """ + return _str[0] + '*' * (len(_str) - 1) + + return '.'.join([_mask_except_first_char(user) for user in users]) + \ + '@' + _mask_except_first_char(domain_front) + '.' + \ + '.'.join(domain_back_list) + + +def send_email_code() -> Response: + """ + Send the code to the users' email address, stored during the registration. + + Raises: + ValidationException: Raise this exception when user is not registered + for this authentication method. + + Returns: + Flask.Response: Response containing the HTML portion after sending the + code to the registered email address of the user. + """ + + options, found = fetch_auth_option(EMAIL_AUTH_METHOD) + + if found is False: + raise ValidationException(_( + "User has not registered for email authentication" + )) + + success, http_code, message = _send_code_to_email(options) + + if success is False: + return Response(message, http_code, mimetype='text/html') + + return Response(render_template( + "mfa/email_code_sent.html", _=_, + message=message, + ), http_code, mimetype='text/html') + + +@pgCSRFProtect.exempt +def javascript() -> Response: + """ + Returns the javascript code for the email authentication method. + + Returns: + Flask.Response: Response object conataining the javscript code for the + email auth method. + """ + if not current_user.is_authenticated: + return Response(_("Not accessible"), 401, mimetype="text/plain") + + return Response(render_template( + "mfa/email.js", _=_, url_for=url_for, + ), 200, mimetype="text/javascript") + + +EMAIL_AUTH_METHOD = 'email' + + +def email_authentication_label(): + return _('Email Authentication') + + +class EmailAuthentication(BaseMFAuth): + + @property + def name(self): + return EMAIL_AUTH_METHOD + + @property + def label(self): + return email_authentication_label() + + def validate(self, **kwargs): + code = kwargs.get('code', None) + email_otp = session.get("mfa_email_code", None) + if code is not None and email_otp is not None and code == email_otp: + session.pop("mfa_email_code") + return + raise ValidationException("Invalid code") + + def validation_view(self): + session.pop("mfa_email_code", None) + return render_template( + "mfa/email_view.html", _=_ + ) + + def _registration_view(self): + email = getattr(current_user, 'email', '') + return "\n".join([ + "
{label}
", + "", + "", + "
{description}
", + "
", + " ", + "
", + "", + ]).format( + label=email_authentication_label(), + auth_method=EMAIL_AUTH_METHOD, + description=_("Enter the email address to send a code"), + email_address_placeholder=_("Email address"), + email_address=email, + note_label=_("Note"), + note=_( + "This email address will only be used by the authentication " + "purpose. It will not update the user's email address." + ), + ) + + def _registration_view_after_code_sent(self, _form_data): + + session['mfa_email_id'] = _form_data.get('send_to', None) + success, http_code, message = _send_code_to_email( + session['mfa_email_id'] + ) + + if success is False: + flash(message, 'danger') + return None + + return "\n".join([ + "
{label}
", + "", + "", + "
{message}
", + "
", + " ", + "
", + ]).format( + label=email_authentication_label(), + auth_method=EMAIL_AUTH_METHOD, + message=message, + otp_placeholder=_("Enter code here") + ) + + def registration_view(self, _form_data): + + if 'validate' in _form_data: + if _form_data['validate'] == 'send_code': + return self._registration_view_after_code_sent(_form_data) + + code = _form_data.get('code', 'unknown') + + if code is not None and \ + code == session.get("mfa_email_code", None) and \ + session.get("mfa_email_id", None) is not None: + mfa_add(EMAIL_AUTH_METHOD, session['mfa_email_id']) + + flash(_( + "Email Authentication registered successfully." + ), "success") + + session.pop('mfa_email_code', None) + + return None + + flash(_('Invalid code'), 'danger') + + return self._registration_view() + + def register_url_endpoints(self, blueprint): + blueprint.add_url_rule( + "/send_email_code", "send_email_code", send_email_code, + methods=("POST", ) + ) + blueprint.add_url_rule( + "/email.js", "email_js", javascript, methods=("GET", ) + ) + + @property + def icon(self): + return url_for("mfa.static", filename="images/email_lock.svg") + + @property + def validate_script(self): + return url_for("mfa.email_js") diff --git a/web/pgadmin/authenticate/mfa/registry.py b/web/pgadmin/authenticate/mfa/registry.py new file mode 100644 index 000000000..1469551fb --- /dev/null +++ b/web/pgadmin/authenticate/mfa/registry.py @@ -0,0 +1,167 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""External 2FA Authentication Registry.""" +from abc import abstractmethod, abstractproperty +import six +from typing import Union + +import flask + +from pgadmin.utils.dynamic_registry import create_registry_metaclass + + +""" +class: MultiFactorAuthRegistry + +An registry factory for the multi-factor authentication methods. +""" +MultiFactorAuthRegistry = create_registry_metaclass( + 'MultiFactorAuthRegistry', __package__, decorate_as_module=True +) + + +@six.add_metaclass(MultiFactorAuthRegistry) +class BaseMFAuth(): + """ + Base Multi-Factor Authentication (MFA) class + + A Class implements this class will be registered with + the registry class 'MultiFactorAuthRegistry', and it will be automatically + available as a MFA method. + """ + + @abstractproperty + def name(self) -> str: + """ + Represents the short name for the authentiation method. It can be used + in the MFA_SUPPORTED_METHODS parameter in the configuration as a + supported authentication method. + + Returns: + str: Short name for this authentication method + + NOTE: Name must not contain special characters + """ + pass + + @abstractproperty + def label(self) -> str: + """ + Represents the user visible name for the authentiation method. It will + be visible on the authentication page and registration page. + + Returns: + str: Value for the UI for the authentication method + """ + pass + + @property + def icon(self) -> str: + """ + A url for the icon for the authentication method. + + Returns: + str: Value for the UI for the authentication method + """ + return "" + + @property + def validate_script(self) -> Union[str, None]: + """ + A url route for the javscript required for the auth method. + + Override this method for the auth methods, when it required a + javascript on the authentication page. + + Returns: + Union[str, None]: Url for the auth method or None + """ + return None + + @abstractmethod + def validate(self, **kwargs) -> str: + """ + Validate the code/password sent using the HTTP request during the + authentication process. + + If the validation is not done successfully for some reason, it must + raise a ValidationException exception. + + Parameters: + kwargs: data sent during the authentication process + + Raises: + ValidationException: Raises when code/otp is not valid + """ + pass + + @abstractmethod + def validation_view(self) -> str: + """ + Authenction route (view) for the auth method. + """ + pass + + @abstractmethod + def registration_view(self, form_data) -> str: + """ + Registration View for the auth method. + + Must override this for rendering the registration page for the auth + method. + + Args: + form_data (dict): Form data sent from the registration page. + """ + pass + + def register_url_endpoints(self, blueprint: flask.Blueprint) -> None: + """ + Register the URL end-points for the auth method (special case). + + Args: + blueprint (flask.Blueprint): MFA blueprint for registering the + end-point for the method + + + NOTE: Override this method only when there is special need to expose + an url end-point for the auth method. + """ + pass + + def to_dict(self) -> dict: + """ + A diction representation for the auth method. + + Returns: + dict (id, label, icon): Diction representation for an auth method. + """ + return { + "id": self.name, + "label": self.label, + "icon": self.icon, + } + + def validation_view_dict(self, selected_mfa: str) -> dict: + """ + A diction representation for the auth method to be used on the + registration page. + + Returns: + dict: Diction representation for an auth method to be used on the + regisration page. + """ + res = self.to_dict() + + res['view'] = self.validation_view() + res['selected'] = selected_mfa == self.name + res['script'] = self.validate_script + + return res diff --git a/web/pgadmin/authenticate/mfa/static/images/email_lock.svg b/web/pgadmin/authenticate/mfa/static/images/email_lock.svg new file mode 100644 index 000000000..385b99b60 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/static/images/email_lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/pgadmin/authenticate/mfa/static/images/totp_lock.svg b/web/pgadmin/authenticate/mfa/static/images/totp_lock.svg new file mode 100644 index 000000000..fb3d685a9 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/static/images/totp_lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/pgadmin/authenticate/mfa/templates/mfa/email.js b/web/pgadmin/authenticate/mfa/templates/mfa/email.js new file mode 100644 index 000000000..e6fe6f57b --- /dev/null +++ b/web/pgadmin/authenticate/mfa/templates/mfa/email.js @@ -0,0 +1,66 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +var mfa_form_elem = document.getElementById('mfa_form'); + +if (mfa_form_elem) + mfa_form_elem.setAttribute('class', ''); + +function sendCodeToEmail(data, _json, _callback) { + const URL = '{{ url_for('mfa.send_email_code') }}'; + let accept = 'text/html; charset=utf-8;'; + + var btn_send_code_elem = document.getElementById('btn_send_code'); + if (btn_send_code_elem) btn_send_code_elem.disabled = true; + + if (!data) { + data = {'code': ''}; + } + + if (_json) { + accept = 'application/json; charset=utf-8;'; + } + + clear_error(); + + fetch(URL, { + method: 'POST', + mode: 'cors', + cache: 'no-cache', + headers: { + 'Accept': accept, + 'Content-Type': 'application/json; charset=utf-8;', + '{{ current_app.config.get('WTF_CSRF_HEADERS')[0] }}': '{{ csrf_token() }}' + }, + redirect: 'follow', + body: JSON.stringify(data) + }).then((resp) => { + if (_callback) { + setTimeout(() => (_callback(resp)), 1); + return null; + } + if (!resp.ok) { + var btn_send_code_elem = document.getElementById('btn_send_code'); + if (btn_send_code_elem) btn_send_code_elem.disabled = true; + resp.text().then(msg => render_error(msg)); + + return; + } + if (_json) return resp.json(); + return resp.text(); + }).then((string) => { + if (!string) + return; + document.getElementById("mfa_email_auth").innerHTML = string; + document.getElementById("mfa_form").classList = ["show_validate_btn"]; + setTimeout(() => { + document.getElementById("showme").classList = []; + }, 20000); + }); +} diff --git a/web/pgadmin/authenticate/mfa/templates/mfa/email_code_sent.html b/web/pgadmin/authenticate/mfa/templates/mfa/email_code_sent.html new file mode 100644 index 000000000..0325ac9e5 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/templates/mfa/email_code_sent.html @@ -0,0 +1,19 @@ +
+
{{ message }}
+ +
+ +
+
diff --git a/web/pgadmin/authenticate/mfa/templates/mfa/email_view.html b/web/pgadmin/authenticate/mfa/templates/mfa/email_view.html new file mode 100644 index 000000000..3ecf635fb --- /dev/null +++ b/web/pgadmin/authenticate/mfa/templates/mfa/email_view.html @@ -0,0 +1,7 @@ +
+
{{ _("Verify with Email Authentication") }}
+
+ +
+
diff --git a/web/pgadmin/authenticate/mfa/templates/mfa/register.html b/web/pgadmin/authenticate/mfa/templates/mfa/register.html new file mode 100644 index 000000000..7b5173e97 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/templates/mfa/register.html @@ -0,0 +1,78 @@ +{% set auth_page = true %} +{% extends "security/panel.html" %} +{% block panel_image %} +
+ {{ _('Registration') }} +
+{% endblock %} +{% block panel_title %}{{ _('Authentication registration') }}{% endblock %} +{% block panel_body %} + + +
+
+{% if mfa_view is not defined or mfa_view is none %} +
+ {% for mfa in mfa_list %} +
+ +
+ {% endfor %} +
+ {% if next_url != 'internal' %} +
+ +
+ {% endif %} +{% else %} +
+ {{ mfa_view | safe }} +
+
+ + +
+{% endif %} + +
+
+{% else %} +
+
+
+
+ +
+ {{ error_message }}
+
+
+
+
+{% endif %} +{% endblock %} diff --git a/web/pgadmin/authenticate/mfa/templates/mfa/validate.html b/web/pgadmin/authenticate/mfa/templates/mfa/validate.html new file mode 100644 index 000000000..acb07b004 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/templates/mfa/validate.html @@ -0,0 +1,121 @@ +{% extends "security/panel.html" %} +{% block panel_image %} +
+ {{ _('Authentication') }} +
+{% endblock %} +{% block panel_title %}{{ _('Authentication') }}{% endblock %} +{% block panel_body %} + + +
+
+
+
+
+ +
+ +
+
+
+
+ +
+ + +{% endblock %} diff --git a/web/pgadmin/authenticate/mfa/templates/security/email/send_email_otp.html b/web/pgadmin/authenticate/mfa/templates/security/email/send_email_otp.html new file mode 100644 index 000000000..f9bf492d2 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/templates/security/email/send_email_otp.html @@ -0,0 +1,2 @@ +Please use the following code for authentication. +{{ code }} diff --git a/web/pgadmin/authenticate/mfa/templates/security/email/send_email_otp.txt b/web/pgadmin/authenticate/mfa/templates/security/email/send_email_otp.txt new file mode 100644 index 000000000..f9bf492d2 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/templates/security/email/send_email_otp.txt @@ -0,0 +1,2 @@ +Please use the following code for authentication. +{{ code }} diff --git a/web/pgadmin/authenticate/mfa/tests/test_config.py b/web/pgadmin/authenticate/mfa/tests/test_config.py new file mode 100644 index 000000000..782dc923b --- /dev/null +++ b/web/pgadmin/authenticate/mfa/tests/test_config.py @@ -0,0 +1,154 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +from pgadmin.authenticate.mfa import mfa_enabled +import config + + +__MFA_ENABLED = 'MFA Enabled' +__MFA_DISABLED = 'MFA Disabled' +TEST_UTILS_AUTH_PKG = 'tests.utils' + + +def __mfa_is_enabled(): + return __MFA_ENABLED + + +def __mfa_is_disabled(): + return __MFA_DISABLED + + +def check_mfa_enabled(test): + config.MFA_ENABLED = test.enabled + config.MFA_SUPPORTED_METHODS = test.supported_list + + if mfa_enabled(__mfa_is_enabled, __mfa_is_disabled) != test.expected: + test.fail(test.fail_msg) + + +def log_message_in_init_app(test): + import types + from unittest.mock import patch + from .. import init_app + from .utils import test_create_dummy_app + + auth_method_msg = "'xyz' is not a valid multi-factor authentication method" + disabled_msg = \ + "No valid multi-factor authentication found, hence - disabling it." + warning_invalid_auth_found = False + warning_disable_auth = False + + dummy_app = test_create_dummy_app(test.name) + + def _log_warning_msg(_msg): + nonlocal warning_invalid_auth_found + nonlocal warning_disable_auth + + if auth_method_msg == _msg: + warning_invalid_auth_found = True + return + + if _msg == disabled_msg: + warning_disable_auth = True + + with patch.object( + dummy_app.logger, + 'warning', + new=_log_warning_msg + ): + config.MFA_ENABLED = True + config.MFA_SUPPORTED_METHODS = test.supported_list + init_app(dummy_app) + + if warning_invalid_auth_found is not test.warning_invalid_auth_found \ + or warning_disable_auth is not test.warning_disable_auth: + test.fail(test.fail_msg) + test.fail() + + +config_scenarios = [ + ( + "Check MFA enabled with no authenticators?", + dict( + check=check_mfa_enabled, enabled=True, supported_list=list(), + expected=__MFA_DISABLED, + fail_msg="MFA is enabled with no authenticators, but - " + "'execute_if_disabled' function is not called." + ), + ), + ( + "Check MFA enabled?", + dict( + check=check_mfa_enabled, enabled=True, + supported_list=[TEST_UTILS_AUTH_PKG], expected=__MFA_ENABLED, + fail_msg="MFA is enable, but - 'execute_if_enabled' function " + "is not called." + ), + ), + ( + "Check MFA disabled check functionality works?", + dict( + check=check_mfa_enabled, enabled=False, + supported_list=list(), + expected=__MFA_DISABLED, + fail_msg="MFA is disabled, but - 'execute_if_enabled' function " + "is called." + ), + ), + ( + "Check MFA in the supported MFA LIST is part of the registered one", + dict( + check=check_mfa_enabled, enabled=True, + supported_list=["not-in-list"], + expected=__MFA_DISABLED, + fail_msg="MFA is enabled with invalid authenticators, but - " + "'execute_if_enabled' function is called" + ), + ), + ( + "Check warning message with invalid method appended during " + "init_app(...)", + dict( + check=log_message_in_init_app, + supported_list=["xyz", TEST_UTILS_AUTH_PKG], + name="warning_app_having_invalid_method", + warning_invalid_auth_found=True, warning_disable_auth=False, + fail_msg="Warning for invalid auth is not found", + ), + ), + ( + "Check warning message with invalid method during " + "init_app(...) ", + dict( + check=log_message_in_init_app, supported_list=["xyz"], + name="warning_app_with_invalid_method", + warning_invalid_auth_found=False, warning_disable_auth=True, + fail_msg="Warning for invalid auth is not found", + ), + ), + ( + "Check warning message when empty supported mfa list during " + "init_app(...)", + dict( + check=log_message_in_init_app, supported_list=[""], + name="warning_app_with_empty_supported_list", + warning_invalid_auth_found=False, warning_disable_auth=True, + fail_msg="Warning not found with empty supported mfa methods", + ), + ), + ( + "No warning message should found with valid configurations during " + "init_app(...)", + dict( + check=log_message_in_init_app, name="no_warning_app", + supported_list=[TEST_UTILS_AUTH_PKG], + warning_invalid_auth_found=False, warning_disable_auth=False, + fail_msg="Warning found with valid configure", + ), + ), +] diff --git a/web/pgadmin/authenticate/mfa/tests/test_mfa.py b/web/pgadmin/authenticate/mfa/tests/test_mfa.py new file mode 100644 index 000000000..01264f611 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/tests/test_mfa.py @@ -0,0 +1,56 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +from pgadmin.utils.route import BaseTestGenerator +import config +from .test_config import config_scenarios +from .test_user_execution import user_execution_scenarios +from .test_mfa_view import validation_view_scenarios +from .utils import init_dummy_auth_class + + +test_scenarios = list() +test_scenarios += config_scenarios +test_scenarios += user_execution_scenarios +test_scenarios += validation_view_scenarios + + +class TestMFATests(BaseTestGenerator): + + scenarios = test_scenarios + + @classmethod + def setUpClass(cls): + config.MFA_ENABLED = True + init_dummy_auth_class() + + @classmethod + def tearDownClass(cls): + config.MFA_ENABLED = False + config.MFA_SUPPORTED_METHODS = [] + + def setUp(self): + config.MFA_SUPPORTED_METHODS = ['tests.utils'] + + start = getattr(self, 'start', None) + if start is not None: + start(self) + + super(BaseTestGenerator, self).setUp() + + def tearDown(self): + + finish = getattr(self, 'finish', None) + if finish is not None: + finish(self) + + config.MFA_SUPPORTED_METHODS = [] + super(BaseTestGenerator, self).tearDown() + + def runTest(self): + self.check(self) diff --git a/web/pgadmin/authenticate/mfa/tests/test_mfa_view.py b/web/pgadmin/authenticate/mfa/tests/test_mfa_view.py new file mode 100644 index 000000000..12590e10b --- /dev/null +++ b/web/pgadmin/authenticate/mfa/tests/test_mfa_view.py @@ -0,0 +1,66 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +from unittest.mock import patch +import config + +from .utils import setup_mfa_app, MockCurrentUserId, MockUserMFA +from pgadmin.authenticate.mfa.utils import ValidationException + + +__MFA_PACKAGE = '.'.join((__package__.split('.'))[:-1]) +__AUTH_PACKAGE = '.'.join((__package__.split('.'))[:-2]) + + +def check_validation_view_content(test): + user_mfa_test_data = [ + MockUserMFA(1, "dummy", ""), + MockUserMFA(1, "no-present-in-list", None), + ] + + def mock_log_exception(ex): + test.assertTrue(type(ex) == ValidationException) + + with patch( + __MFA_PACKAGE + ".utils.current_user", return_value=MockCurrentUserId() + ): + with patch(__MFA_PACKAGE + ".utils.UserMFA") as mock_user_mfa: + with test.app.test_request_context(): + with patch("flask.current_app") as mock_current_app: + mock_user_mfa.query.filter_by.return_value \ + .all.return_value = user_mfa_test_data + mock_current_app.logger.exception = mock_log_exception + + with patch(__AUTH_PACKAGE + ".session") as mock_session: + session = { + 'auth_source_manager': { + 'current_source': getattr( + test, 'auth_method', 'internal' + ) + } + } + + mock_session.__getitem__.side_effect = \ + session.__getitem__ + + response = test.tester.get("/mfa/validate") + + test.assertEquals(response.status_code, 200) + test.assertEquals( + response.headers["Content-Type"], "text/html; charset=utf-8" + ) + # test.assertTrue('Dummy' in response.data.decode('utf8')) + # End of test case - check_validation_view_content + + +validation_view_scenarios = [ + ( + "Validation view of a MFA method should return a HTML tags", + dict(start=setup_mfa_app, check=check_validation_view_content), + ), +] diff --git a/web/pgadmin/authenticate/mfa/tests/test_user_execution.py b/web/pgadmin/authenticate/mfa/tests/test_user_execution.py new file mode 100644 index 000000000..fbf3e8c61 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/tests/test_user_execution.py @@ -0,0 +1,125 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +from unittest.mock import patch +import config +from pgadmin.authenticate.mfa.utils import \ + mfa_user_force_registration_required +from pgadmin.authenticate.mfa.utils import mfa_user_registered, \ + user_supported_mfa_methods +from .utils import MockUserMFA, MockCurrentUserId + + +__MFA_PACKAGE = '.'.join((__package__.split('.'))[:-1]) + + +def __return_true(): + return True + + +def __return_false(): + return False + + +def check_user_registered(test): + + user_mfa_test_data = [ + MockUserMFA(1, "dummy", "Hello guys"), + MockUserMFA(1, "no-present-in-list", None), + ] + + with patch( + __MFA_PACKAGE + ".utils.current_user", return_value=MockCurrentUserId() + ): + with patch(__MFA_PACKAGE + ".utils.UserMFA") as mock_user_mfa: + mock_user_mfa.query.filter_by.return_value.all.return_value = \ + user_mfa_test_data + + ret = mfa_user_registered(__return_true, __return_false) + + if ret is None: + test.fail( + "User registration check has not called either " + "'is_registered' or 'is_not_registered' function" + ) + + if ret is False: + test.fail( + "Not expected to be called 'is_not_registered' function " + "as 'dummy' is in the supported MFA methods" + ) + + methods = user_supported_mfa_methods() + if "dummy" not in methods: + test.fail( + "User registration methods are not valid: {}".format( + methods + ) + ) + + # Removed the 'dummy' from the user's registered MFA list + user_mfa_test_data.pop(0) + ret = mfa_user_registered(__return_true, __return_false) + + if ret is None: + test.fail( + "User registration check has not called either " + "'is_registered' or 'is_not_registered' function" + ) + + if ret is True: + test.fail( + "Not expected to be called 'is_registered' function as " + "'not-present-in-list' is not a valid multi-factor " + "authentication method" + ) + + # End of test case - check_user_registered + + +def check_force_registration_required(test): + + if mfa_user_force_registration_required( + __return_false, __return_true + ) is None: + test.fail( + "User registration check did not call either register or " + "do_not_register function" + ) + + config.MFA_FORCE_REGISTRATION = False + if mfa_user_force_registration_required( + __return_true, __return_false + ) is True: + test.fail( + "User registration function should not be called, when " + "config.MFA_FORCE_REGISTRATION is True" + ) + + config.MFA_FORCE_REGISTRATION = True + if mfa_user_force_registration_required( + __return_true, __return_false + ) is False: + test.fail( + "'do_not_registration' function should not be called, when " + "config.MFA_FORCE_REGISTRATION is True" + ) + + # End of test case - check_force_registration_required + + +user_execution_scenarios = [ + ( + "Check user is registered to do MFA", + dict(check=check_user_registered), + ), + ( + "Require the forcefull registration for MFA?", + dict(check=check_force_registration_required), + ), +] diff --git a/web/pgadmin/authenticate/mfa/tests/utils.py b/web/pgadmin/authenticate/mfa/tests/utils.py new file mode 100644 index 000000000..1bd15ebf0 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/tests/utils.py @@ -0,0 +1,111 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +import types + +from flask import Flask, Response +import config + +from pgadmin.authenticate.mfa import init_app as mfa_init_app + + +def init_dummy_auth_class(): + from pgadmin.authenticate.mfa.registry import BaseMFAuth + + class DummyAuth(BaseMFAuth): # NOSONAR - S5603 + """ + A dummy authentication for testing the registry ability of adding + 'dummy' authentication method. + + Declaration is enough to use this class, we don't have to use it + directly, as it will be initialized automatically by the registry, and + ready to use. + """ + + @property + def name(self): + return "dummy" + + @property + def label(self): + return "Dummy" + + def validate(self, **kwargs): + return true + + def validation_view(self): + return "View" + + def registration_view(self): + return "Registration" + + def register_url_endpoints(self, blueprint): + print('Initialize the end-points for dummy auth') + + # FPSONAR_OFF + + +def test_create_dummy_app(name=__name__): + import os + import pgadmin + from pgadmin.misc.themes import themes + + def index(): + return Response("logged in") + + template_folder = os.path.join( + os.path.dirname(os.path.realpath(pgadmin.__file__)), 'templates' + ) + app = Flask(name, template_folder=template_folder) + config.MFA_ENABLED = True + config.MFA_SUPPORTED_METHODS = ['tests.utils'] + app.config.from_object(config) + app.config.update(dict(LOGIN_DISABLED=True)) + app.add_url_rule("/", "index", index, methods=("GET",)) + app.add_url_rule( + "/favicon.ico", "redirects.favicon", index, methods=("GET",) + ) + app.add_url_rule("/browser", "browser.index", index, methods=("GET",)) + app.add_url_rule("/tools", "tools.index", index, methods=("GET",)) + app.add_url_rule( + "/users", "user_management.index", index, methods=("GET",) + ) + app.add_url_rule( + "/login", "security.logout", index, methods=("GET",) + ) + app.add_url_rule( + "/kerberos_logout", "authenticate.kerberos_logout", index, + methods=("GET",) + ) + + def __dummy_logout_hook(self, blueprint): + pass # We don't need the logout url when dummy auth is enabled. + + app.register_logout_hook = types.MethodType(__dummy_logout_hook, app) + + themes(app) + + return app + + +def setup_mfa_app(test): + test.app = test_create_dummy_app() + mfa_init_app(test.app) + test.tester = test.app.test_client() + + +class MockUserMFA(): + """Mock user for UserMFA""" + def __init__(self, user_id, mfa_auth, options): + self.user_id = user_id + self.mfa_auth = mfa_auth + self.options = options + + +class MockCurrentUserId(): + id = 1 diff --git a/web/pgadmin/authenticate/mfa/utils.py b/web/pgadmin/authenticate/mfa/utils.py new file mode 100644 index 000000000..f3e8f9b53 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/utils.py @@ -0,0 +1,408 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +"""Multi-factor Authentication (MFA) utility functions""" + +from collections.abc import Callable +from functools import wraps + +from flask import url_for, session, request, redirect +from flask_login.utils import login_url +from flask_security import current_user + +import config +from pgadmin.model import UserMFA, db +from .registry import MultiFactorAuthRegistry + + +class ValidationException(Exception): + """ + class: ValidationException + Base class: Exception + + An exception class for raising validation issue. + """ + pass + + +def segregate_valid_and_invalid_mfa_methods( + mfa_supported_methods: list +) -> (list, list): + """ + Segregate the valid and invalid authentication methods from the given + methods. + + Args: + mfa_supported_methods (list): List of auth methods + + Returns: + list, list: Set of valid & invalid auth methods + """ + + invalid_auth_methods = [] + valid_auth_methods = [] + + for mfa in mfa_supported_methods: + + # Put invalid MFA method in separate list + if mfa not in MultiFactorAuthRegistry._registry: + if mfa not in invalid_auth_methods: + invalid_auth_methods.append(mfa) + continue + + # Exclude the duplicate entries + if mfa in valid_auth_methods: + continue + + valid_auth_methods.append(mfa) + + return valid_auth_methods, invalid_auth_methods + + +def mfa_suppored_methods() -> dict: + """ + Returns the dictionary containing information on all supported methods with + information about whether they're registered for the current user, or not. + + It returns information in this format: + { + : { + "mfa": , + "registered": True|False + }, + ... + } + + Returns: + dict: List of all supported MFA methods with the flag for the + registered with the current user or not. + """ + supported_mfa_auth_methods = dict() + + for auth_method in config.MFA_SUPPORTED_METHODS: + registry = MultiFactorAuthRegistry.get(auth_method) + supported_mfa_auth_methods[registry.name] = { + "mfa": registry, "registered": False + } + + auths = UserMFA.query.filter_by(user_id=current_user.id).all() + + for auth in auths: + if auth.mfa_auth in supported_mfa_auth_methods: + supported_mfa_auth_methods[auth.mfa_auth]['registered'] = True + + return supported_mfa_auth_methods + + +def user_supported_mfa_methods(): + """ + Returns the dict for the authentication methods, registered for the + current user, among the list of supported. + + Returns: + dict: dict for the auth methods + """ + auths = UserMFA.query.filter_by(user_id=current_user.id).all() + res = dict() + supported_mfa_auth_methods = dict() + + if len(auths) > 0: + for auth_method in config.MFA_SUPPORTED_METHODS: + registry = MultiFactorAuthRegistry.get(auth_method) + supported_mfa_auth_methods[registry.name] = registry + + for auth in auths: + if auth.mfa_auth in supported_mfa_auth_methods: + res[auth.mfa_auth] = \ + supported_mfa_auth_methods[auth.mfa_auth] + + return res + + +def is_mfa_session_authenticated() -> bool: + """ + Checks if this session is authenticated, or not. + + Returns: + bool: Is this session authenticated? + """ + return session.get('mfa_authenticated', False) is True + + +def mfa_enabled(execute_if_enabled, execute_if_disabled) -> None: + """ + A ternary method to enable calling either of the methods based on the + configuration for the MFA. + + When MFA is enabled and has a valid supported auth methods, + 'execute_if_enabled' method is executed, otherwise - + 'execute_if_disabled' method is executed. + + Args: + execute_if_enabled (Callable[[], None]): Method to executed when MFA + is enabled. + execute_if_disabled (Callable[[], None]): Method to be executed when + MFA is disabled. + + Returns: + None: Expecting the methods to return None as it will not be consumed. + + NOTE: Removed the typing anotation as it was giving errors. + """ + + is_server_mode = getattr(config, 'SERVER_MODE', False) + enabled = getattr(config, "MFA_ENABLED", False) + supported_methods = getattr(config, "MFA_SUPPORTED_METHODS", []) + + if is_server_mode is True and enabled is True and \ + type(supported_methods) == list: + supported_methods, _ = segregate_valid_and_invalid_mfa_methods( + supported_methods + ) + + if len(supported_methods) > 0: + return execute_if_enabled() + + return execute_if_disabled() + + +def mfa_user_force_registration_required(register, not_register) -> None: + """ + A ternary method to cenable calling either of the methods based on the + condition force registration is required. + + When force registration is enabled, and the current user has not registered + for any of the supported authentication method, then the 'register' method + is executed, otherwise - 'not_register' method is executed. + + Args: + register (Callable[[], None]) : Method to be executed when for + registration required and user has + not registered for any auth method. + not_register (Callable[[], None]): Method to be executed otherwise. + + Returns: + None: Expecting the methods to return None as it will not be consumed. + """ + return register() \ + if getattr(config, "MFA_FORCE_REGISTRATION", False) is True else \ + not_register() + + +def mfa_user_registered(registered, not_registered) -> None: + """ + A ternary method to enable calling either of the methods based on the + condition - if the user is registed for any of the auth methods. + + When current user is registered for any of the supported auth method, then + the 'registered' method is executed, otherwise - 'not_registered' method is + executed. + + Args: + registered (Callable[[], None]) : Method to be executed when + registered. + not_registered (Callable[[], None]): Method to be executed when not + registered + + Returns: + None: Expecting the methods to return None as it will not be consumed. + + NOTE: Removed the typing anotation as it was giving errors. + """ + + return registered() if len(user_supported_mfa_methods()) > 0 else \ + not_registered() + + +def mfa_session_authenticated(authenticated, unauthenticated): + """ + A ternary method to enable calling either of the methods based on the + condition - if the user has already authenticated, or not. + + When current user is already authenticated, then 'authenticated' method is + executed, otherwise - 'unauthenticated' method is executed. + + Args: + authenticated (Callable[[], None]) : Method to be executed when + user is authenticated. + unauthenticated (Callable[[], None]): Method to be executed when the + user is not passed the + authentication. + + Returns: + None: Expecting the methods to return None as it will not be consumed. + + NOTE: Removed the typing anotation as it was giving errors. + """ + return authenticated() if session.get('mfa_authenticated', False) is True \ + else unauthenticated() + + +def mfa_required(wrapped): + """ + A decorator do decide the next course of action when a page is being + opened, it will open the appropriate page in case the 2FA is not passed. + + Function executed + | + Check for MFA Enabled? --------+ + | | + | No | + | | Yes + Run the wrapped function [END] | + | + Is user has registered for at least one MFA method? -+ + | | + | No | + | | + Is force registration required? -+ | + | | | Yes + | No | | + | | Yes | + Run the wrapped function [END] | | + | | + Open Registration page [END] | + | + Open the authentication page [END] + + Args: + func(Callable[..]): Method to be called if authentcation is passed + """ + + def get_next_url(): + next_url = request.url + registration_url = url_for('mfa.register') + + if next_url.startswith(registration_url): + return url('browser.index') + + return next_url + + def redirect_to_mfa_validate_url(): + return redirect(login_url("mfa.validate", next_url=get_next_url())) + + def redirect_to_mfa_registration(): + return redirect(login_url("mfa.register", next_url=get_next_url())) + + @wraps(wrapped) + def inner(*args, **kwargs): + + def execute_func(): + session['mfa_authenticated'] = True + return wrapped(*args, **kwargs) + + def if_else_func(_func, first, second): + def if_else_func_inner(): + return _func(first, second) + return if_else_func_inner + + return mfa_enabled( + if_else_func( + mfa_session_authenticated, + execute_func, + if_else_func( + mfa_user_registered, + redirect_to_mfa_validate_url, + if_else_func( + mfa_user_force_registration_required, + redirect_to_mfa_registration, + execute_func + ) + ) + ), + execute_func + ) + + return inner + + +def is_mfa_enabled() -> bool: + """ + Returns True if MFA is enabled otherwise False + + Returns: + bool: Is MFA Enabled? + """ + return mfa_enabled(lambda: True, lambda: False) + + +def mfa_delete(auth_name: str) -> bool: + """ + A utility function to delete the auth method for the current user from the + configuration database. + + Args: + auth_name (str): Name of the argument + + Returns: + bool: True if auth method was registered for the current user, and + delete successfully, otherwise - False + """ + auth = UserMFA.query.filter_by( + user_id=current_user.id, mfa_auth=auth_name + ) + + if auth.count() != 0: + auth.delete() + db.session.commit() + + return True + + return False + + +def mfa_add(auth_name: str, options: str) -> None: + """ + A utility funtion to add/update the auth method in the configuration + database for the current user with the method specific options. + + e.g. email-address for 'email' method, and 'secret' for the 'authenticator' + + Args: + auth_name (str): Name of the auth method + options (str) : A data options specific to the auth method + """ + auth = UserMFA.query.filter_by( + user_id=current_user.id, mfa_auth=auth_name + ).first() + + if auth is None: + auth = UserMFA( + user_id=current_user.id, + mfa_auth=auth_name, + options=options + ) + db.session.add(auth) + + # We will override the existing options + auth.options = options + + db.session.commit() + + +def fetch_auth_option(auth_name: str) -> (str, bool): + """ + A utility function to fetch the extra data, stored as options, for the + given auth method for the current user. + + Returns a set as (data, Auth method registered?) + + Args: + auth_name (str): Name of the auth method + + Returns: + (str, bool): (data, has current user registered for the auth method?) + """ + auth = UserMFA.query.filter_by( + user_id=current_user.id, mfa_auth=auth_name + ).first() + + if auth is None: + return None, False + + return auth.options, True diff --git a/web/pgadmin/authenticate/mfa/views.py b/web/pgadmin/authenticate/mfa/views.py new file mode 100644 index 000000000..c017a4c27 --- /dev/null +++ b/web/pgadmin/authenticate/mfa/views.py @@ -0,0 +1,346 @@ +############################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2021, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +############################################################################## +"""Multi-factor Authentication (MFA) views""" + +import base64 +from typing import Union + +from flask import Response, render_template, request, flash, \ + current_app, url_for, redirect, session +from flask_babel import gettext as _ +from flask_login import current_user, login_required +from flask_login.utils import login_url + +from pgadmin.utils.csrf import pgCSRFProtect +from pgadmin.utils.ajax import bad_request +from .utils import user_supported_mfa_methods, mfa_user_registered, \ + mfa_suppored_methods, ValidationException, mfa_delete, is_mfa_enabled, \ + is_mfa_session_authenticated + + +_INDEX_URL = "browser.index" +_NO_CACHE_HEADERS = dict({ + "Cache-Control": "no-cache, no-store, must-revalidate, public, max-age=0", + "Pragma": "no-cache", + "Expires": "0", +}) + + +def __handle_mfa_validation_request( + mfa_method: str, user_mfa_auths: dict, form_data: dict +) -> None: + """ + An internal utlity function to execute mfa.validate(...) method in case, it + matched the following conditions: + 1. Method specified is a valid and in the supported methods list. + 2. User has registered for this auth method. + + Otherwise, raise an exception with appropriate error message. + + Args: + mfa_method (str) : Name of the authentication method + user_mfa_auths (dict): List of the user supported authentication method + form_data (dict) : Form data in the request + + Raises: + ValidationException: Raise the exception when user is not registered + for the given method, or not a valid MFA method. + """ + + if mfa_method is None: + raise ValidationException(_("No authentication method provided.")) + + mfa_auth = user_mfa_auths.get(mfa_method, None) + + if mfa_auth is None: + raise ValidationException(_( + "No user supported authentication method provided" + )) + + mfa_auth.validate(**form_data) + + +@pgCSRFProtect.exempt +@login_required +def validate_view() -> Response: + """ + An end-point to render the authentication view. + + It supports two HTTP methods: + 1. GET : Generate the view listing all the supported auth methods. + 2. POST: Validate the code/OTP, or whatever data the selected auth method + supports. + + Returns: + Response: Redirect to 'next' url in case authentication validate, + otherwise - a view with listing down all the supported auth + methods, and it's supporting views. + """ + + # Load at runtime to avoid circular dependency + from pgadmin.authenticate import get_logout_url + + next_url = request.args.get("next", None) + + if next_url is None or next_url == url_for('mfa.register') or \ + next_url == url_for('mfa.validate'): + next_url = url_for(_INDEX_URL) + + if session.get('mfa_authenticated', False) is True: + return redirect(next_url) + + return_code = 200 + mfa_method = None + user_mfa_auths = user_supported_mfa_methods() + + if request.method == 'POST': + try: + form_data = {key: request.form[key] for key in request.form} + next_url = form_data.pop('next', url_for(_INDEX_URL)) + mfa_method = form_data.pop('mfa_method', None) + + __handle_mfa_validation_request( + mfa_method, user_mfa_auths, form_data + ) + + session['mfa_authenticated'] = True + + return redirect(next_url) + + except ValidationException as ve: + current_app.logger.warning(( + "MFA validation failed for the user '{}' with an error: " + "{}" + ).format(current_user.username, str(ve))) + flash(str(ve), "danger") + return_code = 401 + except Exception as ex: + current_app.logger.exception(ex) + flash(str(ex), "danger") + return_code = 500 + + mfa_views = { + key: user_mfa_auths[key].validation_view_dict(mfa_method) + for key in user_mfa_auths + } + + if mfa_method is None and len(mfa_views) > 0: + list(mfa_views.items())[0][1]['selected'] = True + + return Response(render_template( + "mfa/validate.html", _=_, views=mfa_views, base64=base64, + logout_url=get_logout_url() + ), return_code, headers=_NO_CACHE_HEADERS, mimetype="text/html") + + +def _mfa_registration_view( + supported_mfa: dict, form_data: dict +) -> Union[str, None]: + """ + An internal utility function to generate the registration view, or + unregister for the given MFA object (passed as a dict). + + It will call 'registration_view' function, specific for the MFA method, + only if User has clicked on 'Setup' button on the registration page, and + current user is not already registered for the Auth method. + + If the user has not clicked on the 'Setup' button, we assume that he has + clicked on the 'Delete' button for a specific auth method. + + Args: + supported_mfa (dict): [description] + form_data (dict): [description] + + Returns: + Union[str, None]: When registration for the Auth method is completed, + it could return None, otherwise view for the + registration view. + """ + mfa = supported_mfa['mfa'] + + if form_data[mfa.name] == 'SETUP': + if supported_mfa['registered'] is True: + flash(_("'{}' is already registerd'").format(mfa.label), "success") + return None + + return mfa.registration_view(form_data) + + if mfa_delete(mfa.name) is True: + flash(_( + "'{}' unregistered from the authentication list." + ).format(mfa.label), "success") + + return None + + flash(_( + "'{}' is not found in the authentication list." + ).format(mfa.label), "warning") + + return None + + +def _registration_view_or_deregister( + _auth_list: dict +) -> Union[str, bool, None]: + """ + An internal utility function to parse the request, and act upon it: + 1. Find the auth method in the request, and call the + '_mfa_registration_view' internal utility function for the same, and + return the result of it. + + It could return a registration view as a string, or None (on + deregistering). + + Args: + _auth_list (dict): List of all supported methods with a flag for the + current user registration. + + Returns: + Union[str, bool, None]: When no valid request found, it will return + False, otherwise the response of the + '_mfa_registration_view(...)' method call. + """ + + for key in _auth_list: + if key in request.form: + return _mfa_registration_view( + _auth_list[key], request.form + ) + + return False + + +def __handle_registration_view_for_post_method( + _next_url: str, _mfa_auths: dict +) -> (Union[str, None], Union[Response, None], Union[dict, None]): + """ + An internal utility function to handle the POST method for the registration + view. It will pass on the request data to the appropriate Auth method, and + may generate further registration view. When registration is completed, it + will redirect to the 'next_url' in case the registration page is not opened + from the internal dialog through menu, which can be identified by the + 'next_url' value is equal to 'internal'. + + Args: + _next_url (str) : Redirect to which url, when clicked on the + 'continue' button on the registration page. + _mfa_auths (dict): A dict object returned by the method - + 'mfa_suppored_methods'. + + Returns: + (Union[str, None], Union[Response, None], Union[dict, None]): + Possibilities: + 1. Returns (None, redirect response to 'next' url, None) in case there + is not valid 'auth' method found in the request. + 2. Returns (None, Registration view as Response, None) in case when + valid method found, and it has returned a view to render. + 3. Otherwise - Returns the set as + (updated 'next' url, None, updated Auth method list) + """ + + next_url = request.form.get("next", None) + + if next_url is None or next_url == url_for('mfa.validate'): + next_url = url_for(_INDEX_URL) + + if request.form.get('cancel', None) is None: + view = _registration_view_or_deregister(_mfa_auths) + + if view is False: + if next_url != 'internal': + return None, redirect(next_url), None + flash(_("Please close the dialog."), "info") + + if view is not None: + return None, Response( + render_template( + "mfa/register.html", _=_, + mfa_list=list(), mfa_view=view, + next_url=next_url, + error_message=None + ), 200, + headers=_NO_CACHE_HEADERS + ), None + + # Regenerate the supported MFA list after + # registration/deregistration. + _mfa_auths = mfa_suppored_methods() + + return next_url, None, _mfa_auths + + +@pgCSRFProtect.exempt +@login_required +def registration_view() -> Response: + """ + A url end-point to register/deregister an authentication method. + + It supports two HTTP methods: + * GET : Generate a view listing all the suppoted list with 'Setup', + or 'Delete' buttons. If user has registered for the auth method, it + will render a 'Delete' button next to it, and 'Setup' button + otherwise. + * POST: This handles multiple scenarios on the registration page: + 1. Clicked on the 'Delete' button, it will deregister the user for + the specific auth method, and render the view same as for the + 'GET' method. + 2. Clicked on the 'Setup' button, it will render the registration + view for the authentication method. + 3. Clicked 'Continue' button, redirect it to the url specified by + 'next' url. + 4. Clicking on 'Cancel' button on the Auth method specific view + will render the view by 'GET' HTTP method. + 5. A registration method can run like a wizard, and generate + different views based on the request data. + + Returns: + Response: A response object with list of auth methods, a registration + view, or redirect to 'next' url + """ + mfa_auths = mfa_suppored_methods() + mfa_list = list() + + next_url = request.args.get("next", None) + + if request.method == 'POST': + next_url, response, mfa_auths = \ + __handle_registration_view_for_post_method(next_url, mfa_auths) + + if response is not None: + return response + + if next_url is None: + next_url = url_for(_INDEX_URL) + + error_message = None + found_one_mfa = False + + for key in mfa_auths: + mfa = mfa_auths[key]['mfa'] + mfa = mfa.to_dict() + mfa["registered"] = mfa_auths[key]["registered"] + mfa_list.append(mfa) + found_one_mfa = found_one_mfa or mfa["registered"] + + if request.method == 'GET': + if is_mfa_enabled() is False: + error_message = _( + "Can't access this page, when multi factor authentication is " + "disabled." + ) + elif is_mfa_session_authenticated() is False and \ + found_one_mfa is True: + flash(_("Complete the authentication process first"), "danger") + return redirect(login_url("mfa.validate", next_url=next_url)) + + return Response(render_template( + "mfa/register.html", _=_, + mfa_list=mfa_list, mfa_view=None, next_url=next_url, + error_message=error_message + ), 200 if error_message is None else 401, headers=_NO_CACHE_HEADERS) diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py index fc24b546d..dda238d4d 100644 --- a/web/pgadmin/browser/__init__.py +++ b/web/pgadmin/browser/__init__.py @@ -25,6 +25,7 @@ from flask import current_app, render_template, url_for, make_response, \ from flask_babel import gettext from flask_gravatar import Gravatar from flask_login import current_user, login_required +from flask_login.utils import login_url from flask_security.changeable import change_user_password from flask_security.decorators import anonymous_user_required from flask_security.recoverable import reset_password_token_status, \ @@ -38,6 +39,8 @@ from werkzeug.datastructures import MultiDict import config from pgadmin import current_blueprint +from pgadmin.authenticate import get_logout_url +from pgadmin.authenticate.mfa.utils import mfa_required, is_mfa_enabled from pgadmin.settings import get_setting, store_setting from pgadmin.utils import PgAdminModule from pgadmin.utils.ajax import make_json_response @@ -695,6 +698,7 @@ def check_browser_upgrade(): @blueprint.route("/") @pgCSRFProtect.exempt @login_required +@mfa_required def index(): """Render and process the main browser window.""" # Register Gravatar module with the app only if required @@ -753,7 +757,11 @@ def index(): username=current_user.username, auth_source=auth_source, is_admin=current_user.has_role("Administrator"), - logout_url=_get_logout_url(), + logout_url=get_logout_url(), + requirejs=True, + basejs=True, + mfa_enabled=is_mfa_enabled(), + login_url=login_url, _=gettext, auth_only_internal=auth_only_internal )) @@ -843,7 +851,7 @@ def utils(): app_version_int=config.APP_VERSION_INT, pg_libpq_version=pg_libpq_version, support_ssh_tunnel=config.SUPPORT_SSH_TUNNEL, - logout_url=_get_logout_url(), + logout_url=get_logout_url(), platform=sys.platform, qt_default_placeholder=QT_DEFAULT_PLACEHOLDER, enable_psql=config.ENABLE_PSQL diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 7553a0e23..316d3577c 100644 --- a/web/pgadmin/browser/static/js/browser.js +++ b/web/pgadmin/browser/static/js/browser.js @@ -22,7 +22,7 @@ define('pgadmin.browser', [ 'pgadmin.browser.menu', 'pgadmin.browser.panel', 'pgadmin.browser.layout', 'pgadmin.browser.runtime', 'pgadmin.browser.error', 'pgadmin.browser.frame', 'pgadmin.browser.node', 'pgadmin.browser.collection', 'pgadmin.browser.activity', - 'sources/codemirror/addon/fold/pgadmin-sqlfoldcode', + 'sources/codemirror/addon/fold/pgadmin-sqlfoldcode', 'pgadmin.browser.dialog', 'pgadmin.browser.keyboard', 'sources/tree/pgadmin_tree_save_state','jquery.acisortable', 'jquery.acifragment', ], function( @@ -172,7 +172,7 @@ define('pgadmin.browser', [ let ih = window.innerHeight; if (ih > passed_height){ return passed_height; - }else{ + } else { if (ih > pgAdmin.Browser.stdH.lg) return pgAdmin.Browser.stdH.lg; else if (ih > pgAdmin.Browser.stdH.md) diff --git a/web/pgadmin/browser/static/js/dialog.js b/web/pgadmin/browser/static/js/dialog.js new file mode 100644 index 000000000..17ca7b17a --- /dev/null +++ b/web/pgadmin/browser/static/js/dialog.js @@ -0,0 +1,110 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import * as alertify from 'pgadmin.alertifyjs'; +import url_for from 'sources/url_for'; +import pgAdmin from 'sources/pgadmin'; + +let counter = 1; + +function url_dialog(_title, _url, _help_filename, _width, _height) { + + let pgBrowser = pgAdmin.Browser; + + const dlgName = 'UrlDialog' + counter++; + + alertify.dialog(dlgName, function factory() { + return { + main: function(_title) { + this.set({'title': _title}); + }, + build: function() { + alertify.pgDialogBuild.apply(this); + }, + settings: { + url: _url, + title: _title, + }, + setup: function() { + return { + buttons: [{ + text: '', + key: 112, + className: 'btn btn-primary-icon pull-left fa fa-question pg-alertify-icon-button', + attrs: { + name: 'dialog_help', + type: 'button', + label: _title, + url: url_for('help.static', { + 'filename': _help_filename, + }), + }, + }, { + text: gettext('Close'), + key: 27, + className: 'btn btn-secondary fa fa-lg fa-times pg-alertify-button', + attrs: { + name: 'close', + type: 'button', + }, + }], + // Set options for dialog + options: { + //disable both padding and overflow control. + padding: !1, + overflow: !1, + modal: false, + resizable: true, + maximizable: true, + pinnable: false, + closableByDimmer: false, + closable: false, + }, + }; + }, + hooks: { + // Triggered when the dialog is closed + onclose: function() { + // Clear the view + return setTimeout((function() { + return (alertify[dlgName]()).destroy(); + }), 1000); + }, + }, + prepare: function() { + // create the iframe element + var iframe = document.createElement('iframe'); + iframe.frameBorder = 'no'; + iframe.width = '100%'; + iframe.height = '100%'; + iframe.src = this.setting('url'); + // add it to the dialog + this.elements.content.appendChild(iframe); + }, + callback: function(e) { + if (e.button.element.name == 'dialog_help') { + e.cancel = true; + pgBrowser.showHelp( + e.button.element.name, e.button.element.getAttribute('url'), + null, null + ); + return; + } + }, + }; + }); + (alertify[dlgName](_title)).show().resizeTo(_width || pgBrowser.stdW.lg, _height || pgBrowser.stdH.md); +} + +pgAdmin.ui.dialogs.url_dialog = url_dialog; + +export { + url_dialog, +}; diff --git a/web/pgadmin/browser/templates/browser/index.html b/web/pgadmin/browser/templates/browser/index.html index c1387dad5..ee8fc6652 100644 --- a/web/pgadmin/browser/templates/browser/index.html +++ b/web/pgadmin/browser/templates/browser/index.html @@ -152,6 +152,17 @@ window.onload = function(e){ {% endif %} + {% if mfa_enabled is defined and mfa_enabled is true %} +
  • + {{ _('Two-Factor Authentication') }} +
  • + + {% endif %} {% if is_admin %}
  • {{ _('Users') }}
  • diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index 58c309bbd..cfe1a6435 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -473,3 +473,11 @@ class UserMacros(db.Model): ) name = db.Column(db.String(1024), nullable=False) sql = db.Column(db.Text(), nullable=False) + + +class UserMFA(db.Model): + """Stores the options for the MFA for a particular user.""" + __tablename__ = 'user_mfa' + user_id = db.Column(db.Integer, db.ForeignKey(USER_ID), primary_key=True) + mfa_auth = db.Column(db.String(64), primary_key=True) + options = db.Column(db.Text(), nullable=True) diff --git a/web/pgadmin/static/img/login.svg b/web/pgadmin/static/img/login.svg index f502c59e0..64e5c4737 100644 --- a/web/pgadmin/static/img/login.svg +++ b/web/pgadmin/static/img/login.svg @@ -1 +1 @@ -login_graphic \ No newline at end of file +login_graphic diff --git a/web/pgadmin/static/js/pgadmin.js b/web/pgadmin/static/js/pgadmin.js index 4377b0a27..a9abcfa90 100644 --- a/web/pgadmin/static/js/pgadmin.js +++ b/web/pgadmin/static/js/pgadmin.js @@ -178,5 +178,7 @@ define([], function() { }; } + pgAdmin.ui = {dialogs: {}}; + return pgAdmin; }); diff --git a/web/pgadmin/static/scss/_pgadmin.style.scss b/web/pgadmin/static/scss/_pgadmin.style.scss index c61e140aa..8b1e7bf8d 100644 --- a/web/pgadmin/static/scss/_pgadmin.style.scss +++ b/web/pgadmin/static/scss/_pgadmin.style.scss @@ -931,19 +931,31 @@ table.table-empty-rows{ } .login_page { - background-color: $color-primary; + background-color: $login-page-background; height: 100%; position:relative; z-index:1; color: $security-text-color; + & a { + color: $security-text-color; + } + + & .panel-container { + background-color: rgba($security-btn-color, 0.25); + padding: 0px; + } + & .panel-header { padding-bottom: 1.0rem; } & .panel-body { padding-bottom: 0.8rem; } - & .btn-login { + & .btn-login { + background-color: $security-btn-color; + } + & .btn-validate { background-color: $security-btn-color; } & .btn-oauth { @@ -977,8 +989,16 @@ table.table-empty-rows{ z-index: 100; } -.change_pass { +.auth_page { background-color: $color-gray-light; + + & .panel-container { + background-color: rgba($color-gray-dark, 0.75); + border-radius: $border-radius * 2; + } +} + +.change_pass { height: 100%; position:relative; z-index:1; diff --git a/web/pgadmin/static/scss/resources/_default.variables.scss b/web/pgadmin/static/scss/resources/_default.variables.scss index 0f885ccab..dbc7e12ab 100644 --- a/web/pgadmin/static/scss/resources/_default.variables.scss +++ b/web/pgadmin/static/scss/resources/_default.variables.scss @@ -366,6 +366,7 @@ $erd-canvas-grid: $color-gray !default; $erd-link-color: $color-fg !default; $erd-link-selected-color: $color-fg !default; +$login-page-background: $color-primary !default; @function url-friendly-colour($colour) { @return '%23' + str-slice('#{$colour}', 2, -1) diff --git a/web/pgadmin/static/scss/resources/dark/_theme.variables.scss b/web/pgadmin/static/scss/resources/dark/_theme.variables.scss index 2fb335047..fa703decd 100644 --- a/web/pgadmin/static/scss/resources/dark/_theme.variables.scss +++ b/web/pgadmin/static/scss/resources/dark/_theme.variables.scss @@ -83,6 +83,8 @@ $color-editor-activeline: #323e43 !default; $color-editor-activeline-light: $color-editor-activeline; $color-editor-activeline-border-color: none; +$login-page-background: $color-bg; + $explain-sev-2-bg: #ded17e; $explain-sev-3-bg: #824d18; $explain-sev-4-bg: #880000; diff --git a/web/pgadmin/static/scss/resources/high_contrast/_theme.variables.scss b/web/pgadmin/static/scss/resources/high_contrast/_theme.variables.scss index 2f6c1f9d5..225945933 100644 --- a/web/pgadmin/static/scss/resources/high_contrast/_theme.variables.scss +++ b/web/pgadmin/static/scss/resources/high_contrast/_theme.variables.scss @@ -37,6 +37,8 @@ $color-gray: #1F2932; $color-gray-light: #2D3A48; $color-gray-lighter: #8B9CAD; +$login-page-background: $color-bg; + $sql-gutters-bg: $color-gray-light; $sql-title-bg: #1F2932; $sql-title-fg: $color-fg; diff --git a/web/pgadmin/templates/base.html b/web/pgadmin/templates/base.html index 65515d58a..8fce32a8e 100644 --- a/web/pgadmin/templates/base.html +++ b/web/pgadmin/templates/base.html @@ -32,6 +32,7 @@ window.resourceBasePath = "{{ url_for('static', filename='js') }}/generated/"; +{% if requirejs is defined and requirejs is true %} - +{% endif %} +{% if basejs is defined and basejs is true %} +{% endif %} @@ -74,7 +77,6 @@
    diff --git a/web/pgadmin/templates/security/change_password.html b/web/pgadmin/templates/security/change_password.html index 0420e9059..5917b799c 100644 --- a/web/pgadmin/templates/security/change_password.html +++ b/web/pgadmin/templates/security/change_password.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% from "security/fields.html" import render_field_with_errors %} {% block body %} -
    +
    {% include "security/messages.html" %}
    @@ -18,7 +18,7 @@ {{ render_field_with_errors(change_password_form.new_password, "password") }} {{ render_field_with_errors(change_password_form.new_password_confirm, "password") }} + title="{{ _('Change Password') }}" aria-label="{{ _('Change Password') }}> {% endif %} diff --git a/web/pgadmin/templates/security/panel.html b/web/pgadmin/templates/security/panel.html index 46aa0524c..d0fdef76e 100644 --- a/web/pgadmin/templates/security/panel.html +++ b/web/pgadmin/templates/security/panel.html @@ -2,7 +2,7 @@ {% from "security/fields.html" import render_field_with_errors, render_username_with_errors %} {% block title %}{{ config.APP_NAME }}{% endblock %} {% block body %} -