Added support for Two-factor authentication for improving security. Fixes #6543
|
@ -33,6 +33,7 @@ Mode is pre-configured for security.
|
|||
|
||||
deployment
|
||||
login
|
||||
mfa
|
||||
user_management
|
||||
change_user_password
|
||||
restore_locked_user
|
||||
|
|
After Width: | Height: | Size: 96 KiB |
After Width: | Height: | Size: 75 KiB |
After Width: | Height: | Size: 96 KiB |
After Width: | Height: | Size: 67 KiB |
|
@ -0,0 +1,88 @@
|
|||
.. _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)."
|
||||
|
||||
|
||||
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*.
|
||||
|
||||
Email authentication
|
||||
====================
|
||||
|
||||
To setup email authentication click on the `Setup` button besides of the
|
||||
'Email authentication' label.
|
||||
|
||||
.. image:: images/mfa_email.png
|
||||
:alt: Configure two-factor authentication
|
||||
:align: center
|
||||
|
||||
Enter the valid email address to send the validation code. Once you get the
|
||||
validation code enter that code to setup the email authentication.
|
||||
|
||||
*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).*
|
||||
|
||||
Authenticator App
|
||||
=================
|
||||
|
||||
To setup using any authenticator application which supports Time based One
|
||||
Time Password (TOTP) click on the `Setup` button besides of the
|
||||
'Authenticator App' label.
|
||||
|
||||
.. image:: images/mfa_auth_app.png
|
||||
:alt: Configure two-factor authentication
|
||||
:align: center
|
||||
|
||||
After the setup when you logged in to the pgAdmin 4 again, it will provide
|
||||
the option to authenticate using email or authenticator app.
|
||||
|
||||
.. image:: images/mfa_login.png
|
||||
:alt: Configure two-factor authentication
|
||||
:align: center
|
|
@ -10,6 +10,7 @@ New features
|
|||
************
|
||||
|
||||
| `Issue #4211 <https://redmine.postgresql.org/issues/4211>`_ - Added support for OWNED BY Clause for sequences.
|
||||
| `Issue #6543 <https://redmine.postgresql.org/issues/6543>`_ - Added support for Two-factor authentication for improving security.
|
||||
|
||||
Housekeeping
|
||||
************
|
||||
|
|
|
@ -43,3 +43,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.*
|
||||
|
|
|
@ -4,3 +4,4 @@ vendor
|
|||
templates/
|
||||
templates\
|
||||
ycache
|
||||
regression/htmlcov
|
||||
|
|
|
@ -729,6 +729,29 @@ OAUTH2_CONFIG = [
|
|||
|
||||
OAUTH2_AUTO_CREATE_USER = True
|
||||
|
||||
##########################################################################
|
||||
# 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
|
||||
##########################################################################
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 14
|
||||
|
||||
Added a table `user_mfa` for saving the options on MFA for different sources.
|
||||
|
||||
Revision ID: 15c88f765bc8
|
||||
Revises: 6650c52670c2
|
||||
Create Date: 2021-06-22 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
|
|
@ -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):
|
||||
|
|
|
@ -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_babelex 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)
|
|
@ -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_babelex 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 then 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
|
|
@ -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_babelex 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 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}'",
|
||||
" name='code' type='password' autofocus=''",
|
||||
" autocomplete='one-time-code' pattern='\\d{{6}}' "
|
||||
" 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")
|
|
@ -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
|
|
@ -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 |
|
@ -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 |
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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"> </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 %}
|
|
@ -0,0 +1,2 @@
|
|||
Please use the following code for authentication.
|
||||
{{ code }}
|
|
@ -0,0 +1,2 @@
|
|||
Please use the following code for authentication.
|
||||
{{ code }}
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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)
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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
|
|
@ -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_babelex 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)
|
|
@ -25,6 +25,7 @@ from flask import current_app, render_template, url_for, make_response, \
|
|||
from flask_babelex 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
|
||||
|
@ -754,7 +758,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
|
||||
))
|
||||
|
@ -848,7 +856,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
|
||||
|
|
|
@ -19,7 +19,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(
|
||||
|
@ -160,7 +160,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)
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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', '80%', '80%'
|
||||
);">{{ _('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>
|
||||
|
|
|
@ -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)
|
||||
|
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
@ -178,5 +178,7 @@ define([], function() {
|
|||
};
|
||||
}
|
||||
|
||||
pgAdmin.ui = {dialogs: {}};
|
||||
|
||||
return pgAdmin;
|
||||
});
|
||||
|
|
|
@ -932,19 +932,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 {
|
||||
|
@ -978,8 +990,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;
|
||||
|
|
|
@ -364,6 +364,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)
|
||||
|
|
|
@ -81,6 +81,8 @@ $color-editor-operator: #d6aaaa;
|
|||
$color-editor-foldmarker: #0000FF !default;
|
||||
$color-editor-activeline: #323e43 !default;
|
||||
|
||||
$login-page-background: $color-bg;
|
||||
|
||||
$explain-sev-2-bg: #ded17e;
|
||||
$explain-sev-3-bg: #824d18;
|
||||
$explain-sev-4-bg: #880000;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
@ -73,7 +76,6 @@
|
|||
{% block body %}{% endblock %}
|
||||
<script type="application/javascript">
|
||||
{% block init_script %}{% endblock %}
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -291,6 +291,8 @@ def panel(trans_id):
|
|||
bgcolor=bgcolor,
|
||||
fgcolor=fgcolor,
|
||||
layout=layout,
|
||||
requirejs=True,
|
||||
basejs=True,
|
||||
macros=macros
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -11,10 +11,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.
|
||||
|
@ -87,93 +87,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) {
|
||||
|
|
|
@ -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'),
|
||||
|
|