Added support for Two-factor authentication for improving security. Fixes #6543

This commit is contained in:
Ashesh Vashi 2021-12-02 16:47:18 +05:30 committed by Akshay Joshi
parent fe096116be
commit 36c9eb3dfd
56 changed files with 2770 additions and 119 deletions

View File

@ -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/**/*

View File

@ -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 \

View File

@ -33,6 +33,7 @@ Mode is pre-configured for security.
deployment
login
mfa
user_management
change_user_password
restore_locked_user

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

57
docs/en_US/mfa.rst Normal file
View File

@ -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 <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", "<APP_NAME> - 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 <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*.

View File

@ -9,6 +9,7 @@ This release contains a number of bug fixes and new features since the release o
New features
************
| `Issue #6543 <https://redmine.postgresql.org/issues/6543>`_ - Added support for Two-factor authentication for improving security.
| `Issue #6872 <https://redmine.postgresql.org/issues/6872>`_ - Include GSSAPI support in the PostgreSQL libraries and utilities on macOS.
Housekeeping

View File

@ -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}")" ;

View File

@ -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.*

View File

@ -4,3 +4,4 @@ vendor
templates/
templates\
ycache
regression/htmlcov

View File

@ -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: <APP_NAME> - Verification Code
# e.g. pgAdmin 4 - Verification Code
MFA_EMAIL_SUBJECT = None
##########################################################################
# PSQL tool settings
##########################################################################

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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 (
"<div class='form-group'>{auth_description}</div>"
"<div class='form-group'>"
" <input class='form-control' placeholder='{otp_placeholder}'"
" name='code' type='password' autofocus='' pattern='\\d*'"
" autocomplete='one-time-code' require/>"
"</div>"
).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([
"<h5 class='form-group text-center'>{auth_title}</h5>",
"<input type='hidden' name='{auth_method}' value='SETUP'/>",
"<input type='hidden' name='VALIDATE' value='validate'/>",
"<img src='data:image/jpeg;base64,{image}'" +
" alt='{qrcode_alt_text}' class='w-100'/>",
"<div class='form-group pt-3'>{auth_description}</div>",
"<div class='form-group'>",
"<input class='form-control' " +
" placeholder='{otp_placeholder}' name='code'" +
" type='password' autofocus='' autocomplete='one-time-code'" +
" pattern='\\d*' require>",
"</div>",
]).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

View File

@ -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([
"<h5 class='form-group text-center'>{label}</h5>",
"<input type='hidden' name='{auth_method}' value='SETUP'/>",
"<input type='hidden' name='validate' value='send_code'/>",
"<div class='form-group pt-3'>{description}</div>",
"<div class='form-group'>",
" <input class='form-control' name='send_to' type='email'",
" placeholder='{email_address_placeholder}'",
" autofocus='' value='{email_address}' required",
" pattern='[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{{2,}}$'/>",
"</div></div>",
"<div class='alert alert-warning alert-dismissible fade show'",
" role='alert'>",
" <strong>{note_label}:</strong><span>{note}</span>",
"</div>",
]).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([
"<h5 class='form-group text-center'>{label}</h5>",
"<input type='hidden' name='{auth_method}' value='SETUP'/>",
"<input type='hidden' name='validate' value='verify_code'/>",
"<div class='form-group pt-3'>{message}</div>",
"<div class='form-group'>",
" <input class='form-control' placeholder='{otp_placeholder}'",
" pattern='\\d{{6}}' type='password' autofocus=''",
" autocomplete='one-time-code' name='code' require>",
"</div>",
]).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")

View File

@ -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

View File

@ -0,0 +1,5 @@
<svg width="28" height="28" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.7889 23.7914H29.698V21.2005C29.698 20.2469 29.3606 19.4327 28.6859 18.758C28.0112 18.0833 27.197 17.7459 26.2434 17.7459C25.2898 17.7459 24.4757 18.0833 23.801 18.758C23.1262 19.4327 22.7889 20.2469 22.7889 21.2005V23.7914ZM34.0162 25.0868V32.8596C34.0162 33.2194 33.8902 33.5253 33.6383 33.7772C33.3864 34.0291 33.0806 34.155 32.7207 34.155H19.7662C19.4063 34.155 19.1004 34.0291 18.8485 33.7772C18.5967 33.5253 18.4707 33.2194 18.4707 32.8596V25.0868C18.4707 24.727 18.5967 24.4211 18.8485 24.1692C19.1004 23.9173 19.4063 23.7914 19.7662 23.7914H20.198V21.2005C20.198 19.5452 20.7917 18.1238 21.9792 16.9363C23.1667 15.7488 24.5881 15.155 26.2434 15.155C27.8987 15.155 29.3201 15.7488 30.5076 16.9363C31.6951 18.1238 32.2889 19.5452 32.2889 21.2005V23.7914H32.7207C33.0806 23.7914 33.3864 23.9173 33.6383 24.1692C33.8902 24.4211 34.0162 24.727 34.0162 25.0868Z" fill="#222222"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.76927 3.76903V4.43862C3.76927 6.36825 4.77367 8.15876 6.42037 9.16467L14.3561 14.0123C14.9469 14.3732 15.6899 14.3732 16.2807 14.0123L24.2165 9.16467C25.8632 8.15876 26.8676 6.36825 26.8676 4.43862V3.76903H3.76927ZM2.84626 1C1.82673 1 1.00024 1.82649 1.00024 2.84602V4.43862C1.00024 7.33307 2.50684 10.0188 4.9769 11.5277L12.9126 16.3753C14.3896 17.2775 16.2472 17.2775 17.7242 16.3753L25.66 11.5277C28.13 10.0188 29.6366 7.33307 29.6366 4.43862V2.84602C29.6366 1.82649 28.8101 1 27.7906 1H2.84626Z" fill="#222222"/>
<path d="M4.69253 3.76903H25.9448C26.4546 3.76903 26.8678 4.18228 26.8678 4.69204V12.6043H29.6368V4.69204C29.6368 2.65298 27.9839 1 25.9448 1H4.69253C2.65347 1 1.00049 2.65298 1.00049 4.69204V19.7679C1.00049 21.8069 2.65347 23.4599 4.69253 23.4599H16.3483V20.6909H4.69253C4.18276 20.6909 3.76952 20.2776 3.76952 19.7679V4.69204C3.76952 4.18227 4.18276 3.76903 4.69253 3.76903Z" fill="#222222"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,5 @@
<svg width="30" height="30" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.0323 22.1865H28.9414V19.5956C28.9414 18.642 28.604 17.8279 27.9293 17.1532C27.2546 16.4784 26.4404 16.1411 25.4868 16.1411C24.5332 16.1411 23.7191 16.4784 23.0444 17.1532C22.3697 17.8279 22.0323 18.642 22.0323 19.5956V22.1865ZM33.2596 23.482V31.2547C33.2596 31.6146 33.1336 31.9204 32.8817 32.1723C32.6298 32.4242 32.324 32.5502 31.9641 32.5502H19.0096C18.6497 32.5502 18.3438 32.4242 18.092 32.1723C17.8401 31.9204 17.7141 31.6146 17.7141 31.2547V23.482C17.7141 23.1221 17.8401 22.8163 18.092 22.5644C18.3438 22.3125 18.6497 22.1865 19.0096 22.1865H19.4414V19.5956C19.4414 17.9403 20.0351 16.5189 21.2226 15.3314C22.4101 14.1439 23.8315 13.5502 25.4868 13.5502C27.1422 13.5502 28.5636 14.1439 29.7511 15.3314C30.9386 16.5189 31.5323 17.9403 31.5323 19.5956V22.1865H31.9641C32.324 22.1865 32.6298 22.3125 32.8817 22.5644C33.1336 22.8163 33.2596 23.1221 33.2596 23.482Z" fill="#222222"/>
<path d="M15.9998 16.4644V8.96441C15.9998 8.80816 15.9495 8.67981 15.8491 8.57936C15.7486 8.47892 15.6203 8.42869 15.464 8.42869H14.3926C14.2364 8.42869 14.108 8.47892 14.0076 8.57936C13.9071 8.67981 13.8569 8.80816 13.8569 8.96441V14.8573H10.1069C9.95065 14.8573 9.8223 14.9075 9.72185 15.0079C9.62141 15.1084 9.57118 15.2367 9.57118 15.393V16.4644C9.57118 16.6207 9.62141 16.749 9.72185 16.8495C9.8223 16.9499 9.95065 17.0001 10.1069 17.0001H15.464C15.6203 17.0001 15.7486 16.9499 15.8491 16.8495C15.9495 16.749 15.9998 16.6207 15.9998 16.4644Z" fill="#222222"/>
<path d="M22.1674 11.1073C22.0407 10.8291 21.8989 10.5557 21.7419 10.287C20.9272 8.89186 19.8223 7.78695 18.4272 6.97222C17.0321 6.15749 15.5087 5.75012 13.8569 5.75012C12.2051 5.75012 10.6817 6.15749 9.28659 6.97222C7.8915 7.78695 6.78659 8.89186 5.97185 10.287C5.15712 11.682 4.74976 13.2055 4.74976 14.8573C4.74976 16.5091 5.15712 18.0325 5.97185 19.4276C6.78659 20.8227 7.8915 21.9276 9.28659 22.7423C10.6817 23.557 12.2051 23.9644 13.8569 23.9644C14.2821 23.9644 14.6987 23.9374 15.1069 23.8834V27.6578C14.6962 27.6955 14.2795 27.7144 13.8569 27.7144C11.5243 27.7144 9.37308 27.1396 7.40322 25.9901C5.43335 24.8405 3.87364 23.2808 2.72409 21.3109C1.57453 19.3411 0.999756 17.1899 0.999756 14.8573C0.999756 12.5247 1.57453 10.3734 2.72409 8.40358C3.87364 6.43372 5.43335 4.87401 7.40322 3.72445C9.37308 2.5749 11.5243 2.00012 13.8569 2.00012C16.1895 2.00012 18.3407 2.5749 20.3106 3.72445C22.2804 4.87401 23.8402 6.43372 24.9897 8.40358C25.4952 9.26976 25.8895 10.171 26.1727 11.1073H22.1674Z" fill="#222222"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -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);
});
}

View File

@ -0,0 +1,19 @@
<div class="form-group">
<div class="form-group">{{ message }}</div>
<div id="showme" class="hidden">
<div class="form-group pb-3">
<div class="card ">
<div class="card-body">
<div class="alert alert-warning d-flex flex-row mb-0">
<i class="fas fa-lg fa-exclamation-triangle mr-3 align-self-center"></i>
<div class="h-100">
<span class="text-primary">{{ _("Haven't received an email?") }} <a class="enable_me_in_20 alert-link" href="#" onClick="javascript:sendCodeToEmail()" disabled>{{ _("Send again") }}</a></span></div>
</div>
</div>
</div>
</div>
</div>
<div class='form-group'>
<input class='form-control' placeholder='{{ _("Enter code") }}' name='code' type='password' autofocus='' pattern='\d*' autocomplete="one-time-code">
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class="form-group">
<div class="form-group text-center h6">{{ _("Verify with Email Authentication") }}</div>
<div class="form-group" id="mfa_email_auth">
<button class="btn btn-primary btn-block btn-validate" id="btn_send_code"
type="button" onclick="sendCodeToEmail()">{{ _("Send Code") }}</button>
</div>
</div>

View File

@ -0,0 +1,78 @@
{% set auth_page = true %}
{% extends "security/panel.html" %}
{% block panel_image %}
<div class="pr-4">
<img src="{{ url_for('static', filename='img/login.svg') }}" alt="{{ _('Registration') }}">
</div>
{% endblock %}
{% block panel_title %}{{ _('Authentication registration') }}{% endblock %}
{% block panel_body %}
<style>
div.form-group > label > .icon {
min-width: 30px;
min-height: 35px;
}
{% if error_message is none %}
{% for mfa in mfa_list %}{% if mfa.icon|length > 0 %}
div#mfa-{{mfa.id | e}} .icon {
background: url({{ mfa.icon }}) 0% 0% no-repeat transparent;
min-height: 30px;
min-width: 30px;
}{% endif %}{% endfor %}
</style>
<form id='mfa_view' method='post'
action='{{ url_for("mfa.register") }}'>
<div class='form-group'>
{% if mfa_view is not defined or mfa_view is none %}
<div class='form-group'>
{% for mfa in mfa_list %}
<div id="mfa-{{ mfa.id }}">
<label class="my-1 d-flex align-items-center">
<i class="fas mr-2 icon"></i>
<span>{{ mfa.label | safe }}</span>
<span class="ml-auto">
<button class='btn btn-primary btn-block btn-validate' name='{{ mfa.id }}'
type='submit'
value='{% if mfa.registered %}DELETE{% else %}SETUP{% endif %}'>{% if mfa.registered %}{{ _("Delete") }}{% else %}{{ _("Setup") }}{% endif %}</button>
</span>
</label>
</div>
{% endfor %}
</div>
{% if next_url != 'internal' %}
<div class="row align-items-center p-2">
<button class='btn btn-primary btn-block btn-validate col' type='submit'
value='{{ _("Continue") }}'>{{ _("Continue") }}</button>
</div>
{% endif %}
{% else %}
<div class='form-group'>
{{ mfa_view | safe }}
</div>
<div class="row align-items-center p-2">
<button class="btn btn-primary col mr-1" type="submit" name="continue" value="Continue">{{ _("Continue") }}</button>
<button class="btn btn-secondary col" type="submit" name="cancel" value="Cancel">{{ _("Cancel") }}</button>
</div>
{% endif %}
<input type="hidden" name="next" value="{{ next_url | safe }}"/>
</div>
</form>
{% else %}
<div class="form-group pb-3">
<div class="card ">
<div class="card-body">
<div class="alert alert-warning d-flex flex-row mb-0">
<i class="fas fa-lg fa-exclamation-triangle mr-3 align-self-center"></i>
<div class="h-100">
<span class="text-primary text-danger">{{ error_message }}</a></span></div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,121 @@
{% extends "security/panel.html" %}
{% block panel_image %}
<div class="pr-4">
<img src="{{ url_for('static', filename='img/login.svg') }}" alt="{{ _('Authentication') }}">
</div>
{% endblock %}
{% block panel_title %}{{ _('Authentication') }}{% endblock %}
{% block panel_body %}
<script>
function onMFAChange(val) {
const mfa_methods = {
{% for key in views %}"{{views[key].id | e}}": { "label": "{{ views[key].label | e }}", "view": {{ views[key].view | tojson }}, "script": {{ views[key].script | tojson }} },
{% endfor %}
};
var method = mfa_methods[val];
if (method == undefined)
return false;
window.init_mfa_method = null;
// Reset form classes - to show the 'Validate' button by default.
document.getElementById(
"mfa_form"
).classList = ["show_validate_btn"];
document.getElementById("mfa_method_prepend").setAttribute(
'data-auth-method', val
);
document.getElementById("mfa_view").innerHTML = method.view;
var elem = document.getElementById("mfa_method_script");
if (elem) {
elem.remove();
}
clear_error();
if (method.script) {
var elem = document.createElement('script');
elem.src = method.script;
elem.id = "mfa_method_script";
document.body.appendChild(elem);
}
}
function render_error(err) {
let divElem = document.createElement('div');
divElem.setAttribute(
'style',
'position: fixed; top: 20px; right: 20px; width: 400px; z-index: 9999'
);
divElem.setAttribute('id', 'alert-container');
divElem.innerHTML = [
"<div class='alert alert-danger alert-dismissible fade show'",
" role='alert'>",
" <span id='alert_msg'></span>",
" <button onclick='hide()' type='button' class='close'",
" data-dismiss='alert' aria-label='Close'>",
" <span aria-hidden='true'>×</span>",
" </button>",
"</div>",
].join('')
var alertContainer = document.getElementById("alert-container");
if (alertContainer) {
alertContainer.remove();
}
document.body.appendChild(divElem);
var alertMsg = document.getElementById("alert_msg");
alertMsg.innerHTML = err;
};
function clear_error() {
var alertContainer = document.getElementById("alert-container");
if (alertContainer) {
alertContainer.remove();
}
}
window.onload = () => {
{% for key in views %}{% if views[key].selected is true %}onMFAChange("{{ views[key].id | e }}");{% endif %}{% endfor %}
};
</script>
<style>
form #validate-btn-group {
display: none;
}
form.show_validate_btn #validate-btn-group {
display: block;
}
{% for key in views %}{% if views[key].icon|length > 0 %}
form #mfa_method_prepend[data-auth-method={{views[key].id | e}}] {
background: url({{ views[key].icon }}) 0% 0% no-repeat #eee;
}{% endif %}{% endfor %}
</style>
<form action="{{ url_for('mfa.validate') }}" method="POST"
name="mfa_form" id="mfa_form" class="show_validate_btn">
<div class="form-group">
<div class="from-group">
<div class="input-group pb-2">
<div class="input-group-prepend">
<label class="input-group-text" for="mfa_method" id="mfa_method_prepend">&nbsp;&nbsp;&nbsp;</label>
</div>
<select name="mfa_method" id="mfa_method" class="auth-select custom-select" onchange="onMFAChange(this.value);">
{% for key in views %}<option value="{{views[key].id | e}}" {% if views[key].selected is true %}selected{% endif %}>{{ views[key].label | e }}</option>{% endfor %}
</select>
</div>
<div class="from-group pt-2 pb-2" id="mfa_view"></div>
</div>
<div class="row align-items-center p-2" id='validate-btn-group'>
<button class="col btn btn-primary btn-block btn-validate" type="submit" value="{{ _('Validate') }}">{{ _('Validate') }}</button>
</div>
<div class="form-group text-right p-2"><a class="text-right" role="link" href="{{ logout_url }}">{{ _('Logout') }}</a></div>
</form>
{% endblock %}

View File

@ -0,0 +1,2 @@
Please use the following code for authentication.
{{ code }}

View File

@ -0,0 +1,2 @@
Please use the following code for authentication.
{{ code }}

View File

@ -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",
),
),
]

View File

@ -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)

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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("<html><body>logged in</body></html>")
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

View File

@ -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:
{
<auth_method_name>: {
"mfa": <MFA Auth Object>,
"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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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,
};

View File

@ -152,6 +152,17 @@ window.onload = function(e){
</li>
<li class="dropdown-divider"></li>
{% endif %}
{% if mfa_enabled is defined and mfa_enabled is true %}
<li>
<a class="dropdown-item" href="#" role="menuitem"
onclick="javascript:pgAdmin.ui.dialogs.url_dialog(
'{{ _("Authentiction") }}',
'{{ login_url("mfa.register", next_url="internal") }}',
'mfa.html', '90%', '90%'
);">{{ _('Two-Factor Authentication') }}</a>
</li>
<li class="dropdown-divider"></li>
{% endif %}
{% if is_admin %}
<li><a class="dropdown-item" href="#" role="menuitem" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li>
<li class="dropdown-divider"></li>

View File

@ -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)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -178,5 +178,7 @@ define([], function() {
};
}
pgAdmin.ui = {dialogs: {}};
return pgAdmin;
});

View File

@ -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;

View File

@ -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)

View File

@ -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;

View File

@ -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;

View File

@ -32,6 +32,7 @@
window.resourceBasePath = "{{ url_for('static', filename='js') }}/generated/";
</script>
<!-- Base template scripts -->
{% if requirejs is defined and requirejs is true %}
<script type="application/javascript"
src="{{ url_for('static', filename='vendor/require/require.js' if config.DEBUG else 'vendor/require/require.min.js') }}"></script>
<script type="application/javascript">
@ -57,11 +58,13 @@
});
</script>
{% endif %}
{% if basejs is defined and basejs is true %}
<!-- View specified scripts -->
<script type="application/javascript" src="{{ url_for('static', filename='js/generated/vendor.main.js') }}" ></script>
<script type="application/javascript" src="{{ url_for('static', filename='js/generated/vendor.others.js') }}" ></script>
<script type="application/javascript" src="{{ url_for('static', filename='js/generated/pgadmin_commons.js') }}" ></script>
{% endif %}
</head>
<body>
@ -74,7 +77,6 @@
<div id="notifierContainer"></div>
<script type="application/javascript">
{% block init_script %}{% endblock %}
</script>
</body>

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% from "security/fields.html" import render_field_with_errors %}
{% block body %}
<div class="container-fluid change_pass">
<div class="container-fluid change_pass auth_page">
{% include "security/messages.html" %}
<div class="row align-items-center h-100">
<div class="col-md-4"></div>
@ -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") }}
<input class="btn btn-primary btn-block btn-change-pass" type="submit" value="{{ _('Change Password') }}"
title="{{ _('Change Password') }}">
title="{{ _('Change Password') }}" aria-label="{{ _('Change Password') }}>
</fieldset>
</form>
{% endif %}

View File

@ -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 %}
<div class="container-fluid h-100 login_page">
<div class="container-fluid h-100 login_page{% if auth_page is defined and auth_page is true %} auth_page{% endif %}">
{% if config.LOGIN_BANNER is defined and config.LOGIN_BANNER != "" %}
<div class="login_banner alert alert-danger" role="alert">
{{ config.LOGIN_BANNER|safe }}
@ -10,13 +10,22 @@
{% endif %}
{% include "security/messages.html" %}
<div class="row h-100 align-items-center justify-content-center">
<div class="col-md-6">{% block panel_image %}{% endblock %}</div>
<div class="col-md-3">
<div class="panel-header text-color h4"><i class="app-icon pg-icon" aria-hidden="true"></i> {{ _('%(appname)s', appname=config.APP_NAME) }}</div>
<div class="panel-body">
<div class="d-block text-color pb-3 h5">{% block panel_title %}{% endblock %}</div>
{% block panel_body %}
{% endblock %}
<div class="d-none d-md-block col-md-6">{% block panel_image %}{% endblock %}</div>
<div class="col-md-3 panel-container mh-100">
<div class="panel-header h4 pt-2 pb-1 m-0 rounded-top">
<span class="d-flex justify-content-center pgadmin_header_logo"
onclick="return false;"
href="#"
title="{{ _('%(appname)s', appname=config.APP_NAME) }}"
aria-label="{{ _('%(appname)s', appname=config.APP_NAME) }} logo">
<i class="app-icon pg-icon"
aria-hidden="true"
style="width: auto; background-size: auto; min-width: 100px;"></i>
</span>
</div>
<div class="panel-body p-3 overflow-auto" style="max-height: calc(100vh - 3em);">
<div class="d-block text-color pb-2 h4 text-center">{% block panel_title %}{% endblock %}</div>
{% block panel_body %}{% endblock %}
</div>
</div>
</div>

View File

@ -291,6 +291,8 @@ def panel(trans_id):
bgcolor=bgcolor,
fgcolor=fgcolor,
layout=layout,
requirejs=True,
basejs=True,
macros=macros
)

View File

@ -682,6 +682,8 @@ def direct_new(trans_id):
is_linux=is_linux_platform,
client_platform=user_agent.platform,
function_name_with_arguments=function_name_with_arguments,
requirejs=True,
basejs=True,
layout=layout
)

View File

@ -447,6 +447,8 @@ def panel(trans_id):
"erd/index.html",
title=underscore_unescape(params['title']),
close_url=close_url,
requirejs=True,
basejs=True,
params=json.dumps(params),
)

View File

@ -127,6 +127,8 @@ def panel(trans_id):
title=underscore_unescape(params['title']),
theme=params['theme'],
o_db_name=o_db_name,
requirejs=True,
basejs=True,
platform=_platform
)

View File

@ -135,6 +135,8 @@ def panel(trans_id, editor_title):
"schema_diff/index.html",
_=gettext,
trans_id=trans_id,
requirejs=True,
basejs=True,
editor_title=editor_title
)

View File

@ -286,7 +286,8 @@ def start_view_data(trans_id):
'filter_applied': filter_applied,
'limit': limit, 'can_edit': can_edit,
'can_filter': can_filter, 'sql': sql,
'info_notifier_timeout': blueprint.info_notifier_timeout.get() * 1000
'info_notifier_timeout':
blueprint.info_notifier_timeout.get() * 1000
}
)

View File

@ -13,10 +13,10 @@ define([
'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'pgadmin.alertifyjs',
'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node', 'pgadmin.backform',
'pgadmin.user_management.current_user', 'sources/utils', 'pgadmin.browser.constants',
'backgrid.select.all', 'backgrid.filter',
'pgadmin.browser.dialog','backgrid.select.all', 'backgrid.filter',
], function(
gettext, url_for, $, _, alertify, pgBrowser, Backbone, Backgrid, Backform,
pgNode, pgBackform, userInfo, commonUtils, pgConst,
pgNode, pgBackform, userInfo, commonUtils, pgConst, pgDialog,
) {
// if module is already initialized, refer to that.
@ -89,93 +89,9 @@ define([
// Callback to draw change password Dialog.
change_password: function(url) {
var title = gettext('Change Password');
if (!alertify.ChangePassword) {
alertify.dialog('ChangePassword', function factory() {
return {
main: function(alertTitle, alertUrl) {
this.set({
'title': alertTitle,
'url': alertUrl,
});
},
build: function() {
alertify.pgDialogBuild.apply(this);
},
settings: {
url: undefined,
},
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: gettext('Change Password'),
url: url_for(
'help.static', {
'filename': 'change_user_password.html',
}),
},
}, {
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.ChangePassword().destroy();
}), 500);
},
},
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.ChangePassword(title, url).resizeTo(pgBrowser.stdW.lg, pgBrowser.stdH.md);
pgDialog.url_dialog(
gettext('Chagne Password'), url, 'change_user_password.html',
);
},
isPgaLoginRequired(xhr) {

View File

@ -204,6 +204,7 @@ var webpackShimConfig = {
'pgadmin.browser.activity': path.join(__dirname, './pgadmin/browser/static/js/activity'),
'pgadmin.browser.quick_search': path.join(__dirname, './pgadmin/browser/static/js/quick_search'),
'pgadmin.browser.messages': '/browser/js/messages',
'pgadmin.browser.dialog': path.join(__dirname, './pgadmin/browser/static/js/dialog'),
'pgadmin.browser.node': path.join(__dirname, './pgadmin/browser/static/js/node'),
'pgadmin.browser.node.ui': path.join(__dirname, './pgadmin/browser/static/js/node.ui'),
'pgadmin.browser.dependencies': path.join(__dirname, './pgadmin/misc/dependencies/static/js/dependencies'),