diff --git a/powerdnsadmin/assets.py b/powerdnsadmin/assets.py index dfe79ff..e7c6354 100644 --- a/powerdnsadmin/assets.py +++ b/powerdnsadmin/assets.py @@ -23,6 +23,7 @@ css_login = Bundle('node_modules/bootstrap/dist/css/bootstrap.css', js_login = Bundle('node_modules/jquery/dist/jquery.js', 'node_modules/bootstrap/dist/js/bootstrap.js', 'node_modules/icheck/icheck.js', + 'custom/js/custom.js', filters=(ConcatFilter, 'jsmin'), output='generated/login.js') diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index 3a84b95..2f32cbb 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -193,6 +193,7 @@ class Setting(db.Model): 'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours', 'otp_field_enabled': True, 'custom_css': '', + 'otp_force': False, 'max_history_records': 1000 } diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py index 1d318a9..80a79e8 100644 --- a/powerdnsadmin/models/user.py +++ b/powerdnsadmin/models/user.py @@ -8,6 +8,9 @@ import ldap.filter from flask import current_app from flask_login import AnonymousUserMixin from sqlalchemy import orm +import qrcode as qrc +import qrcode.image.svg as qrc_svg +from io import BytesIO from .base import db from .role import Role @@ -633,6 +636,13 @@ class User(db.Model): for q in query: accounts.append(q[1]) 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): @@ -793,7 +803,4 @@ def getUserInfo(DomainsOrAccounts): current=[] for DomainOrAccount in DomainsOrAccounts: current.append(DomainOrAccount.name) - return current - - - + return current \ No newline at end of file diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 414bac4..2f9a034 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -1260,8 +1260,7 @@ def setting_basic(): 'allow_user_create_domain', 'allow_user_remove_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name', 'session_timeout', 'warn_session_timeout', 'ttl_options', '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) diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 2c2abe5..e5e7feb 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -4,6 +4,7 @@ import json import traceback import datetime import ipaddress +import base64 from distutils.util import strtobool from yaml import Loader, load from onelogin.saml2.utils import OneLogin_Saml2_Utils @@ -167,10 +168,8 @@ def login(): return redirect(url_for('index.login')) session['user_id'] = user.id - login_user(user, remember=False) session['authentication_type'] = 'OAuth' - signin_history(user.username, 'Google OAuth', True) - return redirect(url_for('index.index')) + return authenticate_user(user, 'Google OAuth') if 'github_token' in session: me = json.loads(github.get('user').text) @@ -195,9 +194,7 @@ def login(): session['user_id'] = user.id session['authentication_type'] = 'OAuth' - login_user(user, remember=False) - signin_history(user.username, 'Github OAuth', True) - return redirect(url_for('index.index')) + return authenticate_user(user, 'Github OAuth') if 'azure_token' in session: azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text @@ -366,10 +363,7 @@ def login(): history.add() current_app.logger.warning('group info: {} '.format(account_id)) - - login_user(user, remember=False) - signin_history(user.username, 'Azure OAuth', True) - return redirect(url_for('index.index')) + return authenticate_user(user, 'Azure OAuth') if 'oidc_token' in session: me = json.loads(oidc.get('userinfo').text) @@ -451,9 +445,7 @@ def login(): session['user_id'] = user.id session['authentication_type'] = 'OAuth' - login_user(user, remember=False) - signin_history(user.username, 'OIDC OAuth', True) - return redirect(url_for('index.index')) + return authenticate_user(user, 'OIDC OAuth') if request.method == 'GET': return render_template('login.html', saml_enabled=SAML_ENABLED) @@ -530,9 +522,7 @@ def login(): user.revoke_privilege(True) current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' ) - login_user(user, remember=remember_me) - signin_history(user.username, 'LOCAL', True) - return redirect(session.get('next', url_for('index.index'))) + return authenticate_user(user, 'LOCAL', remember_me) def checkForPDAEntries(Entitlements, urn_value): """ @@ -602,6 +592,23 @@ def get_azure_groups(uri): 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') def logout(): @@ -692,7 +699,12 @@ def register(): if result and result['status']: if Setting().get('verify_user_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: return render_template('register.html', error=result['msg']) @@ -702,6 +714,28 @@ def register(): 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/', methods=['GET']) def confirm_email(token): email = confirm_token(token) @@ -1055,9 +1089,7 @@ def saml_authorized(): user.plain_text_password = None user.update_profile() session['authentication_type'] = 'SAML' - login_user(user, remember=False) - signin_history(user.username, 'SAML', True) - return redirect(url_for('index.login')) + return authenticate_user(user, 'SAML') else: return render_template('errors/SAML.html', errors=errors) diff --git a/powerdnsadmin/routes/user.py b/powerdnsadmin/routes/user.py index 6ca927b..f411c29 100644 --- a/powerdnsadmin/routes/user.py +++ b/powerdnsadmin/routes/user.py @@ -1,7 +1,4 @@ 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_login import current_user, login_required, login_manager @@ -94,13 +91,9 @@ def qrcode(): if not current_user: return redirect(url_for('index')) - img = qrc.make(current_user.get_totp_uri(), - image_factory=qrc_svg.SvgPathImage) - stream = BytesIO() - img.save(stream) - return stream.getvalue(), 200, { + return current_user.get_qrcode_value(), 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' - } + } \ No newline at end of file diff --git a/powerdnsadmin/static/custom/js/custom.js b/powerdnsadmin/static/custom/js/custom.js index 9bd9669..395e1ac 100644 --- a/powerdnsadmin/static/custom/js/custom.js +++ b/powerdnsadmin/static/custom/js/custom.js @@ -285,4 +285,14 @@ function timer(elToUpdate, maxTime) { }, 1000); return interval; -} \ No newline at end of file +} + +// 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); + } \ No newline at end of file diff --git a/powerdnsadmin/templates/login.html b/powerdnsadmin/templates/login.html index dcf96cf..6b017af 100644 --- a/powerdnsadmin/templates/login.html +++ b/powerdnsadmin/templates/login.html @@ -50,7 +50,7 @@ {% if SETTING.get('otp_field_enabled') %}
- +
{% endif %} {% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %} diff --git a/powerdnsadmin/templates/register_otp.html b/powerdnsadmin/templates/register_otp.html new file mode 100755 index 0000000..68e7ab8 --- /dev/null +++ b/powerdnsadmin/templates/register_otp.html @@ -0,0 +1,90 @@ + + + + + + + Welcome - {{ SITE_NAME }} + + + + {% assets "css_login" -%} + + {%- endassets %} + {% if SETTING.get('custom_css') %} + + {% endif %} + + + +
+ +
+ {% if error %} +
+ + {{ error }} +
+ {% endif %} + Welcome, {{user.firstname}}!
+ You will need a Token on login.
+ Your QR code is: +
+ {% if qrcode_image == None %} +

+ {% else %} +

+ {% endif %} +

+ Your secret key is:
+

+ + +
Copied. +
+

+ You can use Google Authenticator (Android + - iOS) +
+ or FreeOTP (Android + - iOS) + on your smartphone
to scan the QR code or type the secret key. +

+ Make sure only you can see this QR Code
+ and secret key, and nobody can capture them.
+
+
+ Please input your OTP token to continue, to ensure the seed has been scanned correctly. +
+ +
+ +
+
+
+ +
+
+
+
+ +
+ + {% assets "js_login" -%} + + {%- endassets %} + {% assets "js_validation" -%} + + {%- endassets %} + \ No newline at end of file diff --git a/powerdnsadmin/templates/user_profile.html b/powerdnsadmin/templates/user_profile.html index a13d3d3..8570f7e 100644 --- a/powerdnsadmin/templates/user_profile.html +++ b/powerdnsadmin/templates/user_profile.html @@ -93,6 +93,14 @@ {% if current_user.otp_secret %}

+
+ Your secret key is:
+
+ + +
Copied. +
+
You can use Google Authenticator (Android - iOS) on your smartphone to scan the QR code.
- Make sure only you can see this QR Code and - nobody can capture it. + Make sure only you can see this QR Code and secret key and + nobody can capture them.
{% endif %} diff --git a/requirements.txt b/requirements.txt index 5e97a12..c33d0ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ mysqlclient==2.0.1 configobj==5.0.6 bcrypt>=3.1.7 requests==2.24.0 -python-ldap==3.3.1 +python-ldap==3.4.0 pyotp==2.4.0 qrcode==6.1 dnspython>=1.16.0