Merge branch 'PowerDNS-Admin:master' into autoprovisioning_help

This commit is contained in:
Kostas Mparmparousis 2021-12-21 08:35:56 -05:00 committed by GitHub
commit 83af183950
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 181 additions and 40 deletions

View file

@ -23,6 +23,7 @@ css_login = Bundle('node_modules/bootstrap/dist/css/bootstrap.css',
js_login = Bundle('node_modules/jquery/dist/jquery.js', js_login = Bundle('node_modules/jquery/dist/jquery.js',
'node_modules/bootstrap/dist/js/bootstrap.js', 'node_modules/bootstrap/dist/js/bootstrap.js',
'node_modules/icheck/icheck.js', 'node_modules/icheck/icheck.js',
'custom/js/custom.js',
filters=(ConcatFilter, 'jsmin'), filters=(ConcatFilter, 'jsmin'),
output='generated/login.js') output='generated/login.js')

View file

@ -189,6 +189,7 @@ class Setting(db.Model):
'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours', 'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours',
'otp_field_enabled': True, 'otp_field_enabled': True,
'custom_css': '', 'custom_css': '',
'otp_force': False,
'max_history_records': 1000 'max_history_records': 1000
} }

View file

@ -8,6 +8,9 @@ import ldap.filter
from flask import current_app from flask import current_app
from flask_login import AnonymousUserMixin from flask_login import AnonymousUserMixin
from sqlalchemy import orm from sqlalchemy import orm
import qrcode as qrc
import qrcode.image.svg as qrc_svg
from io import BytesIO
from .base import db from .base import db
from .role import Role from .role import Role
@ -633,6 +636,13 @@ class User(db.Model):
for q in query: for q in query:
accounts.append(q[1]) accounts.append(q[1])
return accounts return accounts
def get_qrcode_value(self):
img = qrc.make(self.get_totp_uri(),
image_factory=qrc_svg.SvgPathImage)
stream = BytesIO()
img.save(stream)
return stream.getvalue()
def read_entitlements(self, key): def read_entitlements(self, key):
@ -802,7 +812,4 @@ def getUserInfo(DomainsOrAccounts):
current=[] current=[]
for DomainOrAccount in DomainsOrAccounts: for DomainOrAccount in DomainsOrAccounts:
current.append(DomainOrAccount.name) current.append(DomainOrAccount.name)
return current return current

View file

@ -1260,8 +1260,7 @@ def setting_basic():
'allow_user_create_domain', 'allow_user_remove_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name', 'allow_user_create_domain', 'allow_user_remove_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name',
'session_timeout', 'warn_session_timeout', 'ttl_options', 'session_timeout', 'warn_session_timeout', 'ttl_options',
'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email', 'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email',
'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records' 'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records', 'otp_force'
] ]
return render_template('admin_setting_basic.html', settings=settings) return render_template('admin_setting_basic.html', settings=settings)

View file

@ -4,6 +4,7 @@ import json
import traceback import traceback
import datetime import datetime
import ipaddress import ipaddress
import base64
from distutils.util import strtobool from distutils.util import strtobool
from yaml import Loader, load from yaml import Loader, load
from onelogin.saml2.utils import OneLogin_Saml2_Utils from onelogin.saml2.utils import OneLogin_Saml2_Utils
@ -167,10 +168,8 @@ def login():
return redirect(url_for('index.login')) return redirect(url_for('index.login'))
session['user_id'] = user.id session['user_id'] = user.id
login_user(user, remember=False)
session['authentication_type'] = 'OAuth' session['authentication_type'] = 'OAuth'
signin_history(user.username, 'Google OAuth', True) return authenticate_user(user, 'Google OAuth')
return redirect(url_for('index.index'))
if 'github_token' in session: if 'github_token' in session:
me = json.loads(github.get('user').text) me = json.loads(github.get('user').text)
@ -195,9 +194,7 @@ def login():
session['user_id'] = user.id session['user_id'] = user.id
session['authentication_type'] = 'OAuth' session['authentication_type'] = 'OAuth'
login_user(user, remember=False) return authenticate_user(user, 'Github OAuth')
signin_history(user.username, 'Github OAuth', True)
return redirect(url_for('index.index'))
if 'azure_token' in session: if 'azure_token' in session:
azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text
@ -366,10 +363,7 @@ def login():
history.add() history.add()
current_app.logger.warning('group info: {} '.format(account_id)) current_app.logger.warning('group info: {} '.format(account_id))
return authenticate_user(user, 'Azure OAuth')
login_user(user, remember=False)
signin_history(user.username, 'Azure OAuth', True)
return redirect(url_for('index.index'))
if 'oidc_token' in session: if 'oidc_token' in session:
me = json.loads(oidc.get('userinfo').text) me = json.loads(oidc.get('userinfo').text)
@ -433,9 +427,7 @@ def login():
session['user_id'] = user.id session['user_id'] = user.id
session['authentication_type'] = 'OAuth' session['authentication_type'] = 'OAuth'
login_user(user, remember=False) return authenticate_user(user, 'OIDC OAuth')
signin_history(user.username, 'OIDC OAuth', True)
return redirect(url_for('index.index'))
if request.method == 'GET': if request.method == 'GET':
return render_template('login.html', saml_enabled=SAML_ENABLED) return render_template('login.html', saml_enabled=SAML_ENABLED)
@ -512,9 +504,7 @@ def login():
user.revoke_privilege(True) user.revoke_privilege(True)
current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' ) current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' )
login_user(user, remember=remember_me) return authenticate_user(user, 'LOCAL', remember_me)
signin_history(user.username, 'LOCAL', True)
return redirect(session.get('next', url_for('index.index')))
def checkForPDAEntries(Entitlements, urn_value): def checkForPDAEntries(Entitlements, urn_value):
""" """
@ -584,6 +574,23 @@ def get_azure_groups(uri):
mygroups = [] mygroups = []
return mygroups return mygroups
# Handle user login, write history and, if set, handle showing the register_otp QR code.
# if Setting for OTP on first login is enabled, and OTP field is also enabled,
# but user isn't using it yet, enable OTP, get QR code and display it, logging the user out.
def authenticate_user(user, authenticator, remember=False):
login_user(user, remember=remember)
signin_history(user.username, authenticator, True)
if Setting().get('otp_force') and Setting().get('otp_field_enabled') and not user.otp_secret:
user.update_profile(enable_otp=True)
user_id = current_user.id
prepare_welcome_user(user_id)
return redirect(url_for('index.welcome'))
return redirect(url_for('index.login'))
# Prepare user to enter /welcome screen, otherwise they won't have permission to do so
def prepare_welcome_user(user_id):
logout_user()
session['welcome_user_id'] = user_id
@index_bp.route('/logout') @index_bp.route('/logout')
def logout(): def logout():
@ -674,7 +681,12 @@ def register():
if result and result['status']: if result and result['status']:
if Setting().get('verify_user_email'): if Setting().get('verify_user_email'):
send_account_verification(email) send_account_verification(email)
return redirect(url_for('index.login')) if Setting().get('otp_force') and Setting().get('otp_field_enabled'):
user.update_profile(enable_otp=True)
prepare_welcome_user(user.id)
return redirect(url_for('index.welcome'))
else:
return redirect(url_for('index.login'))
else: else:
return render_template('register.html', return render_template('register.html',
error=result['msg']) error=result['msg'])
@ -684,6 +696,28 @@ def register():
return render_template('errors/404.html'), 404 return render_template('errors/404.html'), 404
# Show welcome page on first login if otp_force is enabled
@index_bp.route('/welcome', methods=['GET', 'POST'])
def welcome():
if 'welcome_user_id' not in session:
return redirect(url_for('index.index'))
user = User(id=session['welcome_user_id'])
encoded_img_data = base64.b64encode(user.get_qrcode_value())
if request.method == 'GET':
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user)
elif request.method == 'POST':
otp_token = request.form.get('otptoken', '')
if otp_token and otp_token.isdigit():
good_token = user.verify_totp(otp_token)
if not good_token:
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, error="Invalid token")
else:
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, error="Token required")
session.pop('welcome_user_id')
return redirect(url_for('index.index'))
@index_bp.route('/confirm/<token>', methods=['GET']) @index_bp.route('/confirm/<token>', methods=['GET'])
def confirm_email(token): def confirm_email(token):
email = confirm_token(token) email = confirm_token(token)
@ -1037,9 +1071,7 @@ def saml_authorized():
user.plain_text_password = None user.plain_text_password = None
user.update_profile() user.update_profile()
session['authentication_type'] = 'SAML' session['authentication_type'] = 'SAML'
login_user(user, remember=False) return authenticate_user(user, 'SAML')
signin_history(user.username, 'SAML', True)
return redirect(url_for('index.login'))
else: else:
return render_template('errors/SAML.html', errors=errors) return render_template('errors/SAML.html', errors=errors)

View file

@ -1,7 +1,4 @@
import datetime import datetime
import qrcode as qrc
import qrcode.image.svg as qrc_svg
from io import BytesIO
from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, current_app from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, current_app
from flask_login import current_user, login_required, login_manager from flask_login import current_user, login_required, login_manager
@ -94,13 +91,9 @@ def qrcode():
if not current_user: if not current_user:
return redirect(url_for('index')) return redirect(url_for('index'))
img = qrc.make(current_user.get_totp_uri(), return current_user.get_qrcode_value(), 200, {
image_factory=qrc_svg.SvgPathImage)
stream = BytesIO()
img.save(stream)
return stream.getvalue(), 200, {
'Content-Type': 'image/svg+xml', 'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache, no-store, must-revalidate', 'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Expires': '0' 'Expires': '0'
} }

View file

@ -285,4 +285,14 @@ function timer(elToUpdate, maxTime) {
}, 1000); }, 1000);
return interval; return interval;
} }
// copy otp secret code to clipboard
function copy_otp_secret_to_clipboard() {
var copyBox = document.getElementById("otp_secret");
copyBox.select();
copyBox.setSelectionRange(0, 99999); /* For mobile devices */
navigator.clipboard.writeText(copyBox.value);
$("#copy_tooltip").css("visibility", "visible");
setTimeout(function(){ $("#copy_tooltip").css("visibility", "collapse"); }, 2000);
}

View file

@ -50,7 +50,7 @@
</div> </div>
{% if SETTING.get('otp_field_enabled') %} {% if SETTING.get('otp_field_enabled') %}
<div class="form-group"> <div class="form-group">
<input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken"> <input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken" autocomplete="off">
</div> </div>
{% endif %} {% endif %}
{% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %} {% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %}

View file

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Welcome - {{ SITE_NAME }}</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Tell the browser to be responsive to screen width -->
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
{% assets "css_login" -%}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{%- endassets %}
{% if SETTING.get('custom_css') %}
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
{% endif %}
</head>
<body class="hold-transition register-page">
<div class="register-box">
<div class="register-logo">
<a><b>PowerDNS</b>-Admin</a>
</div>
<div class="register-box-body">
{% if error %}
<div class="alert alert-danger alert-dismissible">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
{{ error }}
</div>
{% endif %}
Welcome, {{user.firstname}}! <br />
You will need a Token on login. <br />
Your QR code is:
<div id="token_information">
{% if qrcode_image == None %}
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
{% else %}
<p><img id="qrcode" src="data:image/svg+xml;utf8;base64, {{qrcode_image}}"></p>
{% endif %}
<p>
Your secret key is: <br />
<form>
<input type=text id="otp_secret" value={{user.otp_secret}} readonly>
<button type=button style="position:relative; right:28px" onclick="copy_otp_secret_to_clipboard()"> <i class="fa fa-clipboard"></i> </button>
<br /><font color="red" id="copy_tooltip" style="visibility:collapse">Copied.</font>
</form>
</p>
You can use Google Authenticator (<a target="_blank"
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
- <a target="_blank"
href="https://apps.apple.com/us/app/google-authenticator/id388497605">iOS</a>)
<br />
or FreeOTP (<a target="_blank"
href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp&hl=en">Android</a>
- <a target="_blank"
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
on your smartphone <br /> to scan the QR code or type the secret key.
<br /> <br />
<font color="red"><strong><i>Make sure only you can see this QR Code <br />
and secret key, and nobody can capture them.</i></strong></font>
</div>
</br>
Please input your OTP token to continue, to ensure the seed has been scanned correctly.
<form action="" method="post" data-toggle="validator">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<input type="text" class="form-control" placeholder="OTP Token" name="otptoken"
data-error="Please input your OTP token" required>
</div>
<div class="row">
<div class="col-xs-4">
<button type="submit" class="btn btn-flat btn-primary btn-block">Continue</button>
</div>
</div>
</form>
</div>
<div class="login-box-footer">
<center>
<p>Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></p>
</center>
</div>
</div>
</body>
{% assets "js_login" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
{% assets "js_validation" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
</html>

View file

@ -93,6 +93,14 @@
{% if current_user.otp_secret %} {% if current_user.otp_secret %}
<div id="token_information"> <div id="token_information">
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p> <p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
<div style="position: relative; left: 15px">
Your secret key is: <br />
<form>
<input type=text id="otp_secret" value={{current_user.otp_secret}} readonly>
<button type=button style="position:relative; right:28px" onclick="copy_otp_secret_to_clipboard()"> <i class="fa fa-clipboard"></i> </button>
<br /><font color="red" id="copy_tooltip" style="visibility:collapse">Copied.</font>
</form>
</div>
You can use Google Authenticator (<a target="_blank" You can use Google Authenticator (<a target="_blank"
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a> href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
- <a target="_blank" - <a target="_blank"
@ -103,8 +111,8 @@
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>) href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
on your smartphone to scan the QR code. on your smartphone to scan the QR code.
<br /> <br />
<font color="red"><strong><i>Make sure only you can see this QR Code and <font color="red"><strong><i>Make sure only you can see this QR Code and secret key and
nobody can capture it.</i></strong></font> nobody can capture them.</i></strong></font>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -8,7 +8,7 @@ mysqlclient==2.0.1
configobj==5.0.6 configobj==5.0.6
bcrypt>=3.1.7 bcrypt>=3.1.7
requests==2.24.0 requests==2.24.0
python-ldap==3.3.1 python-ldap==3.4.0
pyotp==2.4.0 pyotp==2.4.0
qrcode==6.1 qrcode==6.1
dnspython>=1.16.0 dnspython>=1.16.0