From 7739bf7cfcc2522934b2f8c80c2805c8902c4a9a Mon Sep 17 00:00:00 2001 From: Khanh Ngo Date: Sat, 21 Dec 2019 21:43:03 +0700 Subject: [PATCH 1/4] Add user email verification --- configs/development.py | 28 ++- configs/docker_config.py | 20 +- .../3f76448bb6de_add_user_confirmed_column.py | 28 +++ powerdnsadmin/__init__.py | 4 + powerdnsadmin/decorators.py | 8 +- powerdnsadmin/models/setting.py | 2 + powerdnsadmin/models/user.py | 11 + powerdnsadmin/routes/admin.py | 2 +- powerdnsadmin/routes/index.py | 54 +++++ powerdnsadmin/services/email.py | 27 +++ powerdnsadmin/services/token.py | 18 ++ powerdnsadmin/templates/base.html | 2 +- .../templates/email_confirmation.html | 40 ++++ .../emails/account_verification.html | 219 ++++++++++++++++++ powerdnsadmin/templates/login.html | 4 + powerdnsadmin/templates/register.html | 3 +- .../templates/resend_confirmation_email.html | 44 ++++ requirements.txt | 1 + 18 files changed, 495 insertions(+), 20 deletions(-) create mode 100644 migrations/versions/3f76448bb6de_add_user_confirmed_column.py create mode 100644 powerdnsadmin/services/email.py create mode 100644 powerdnsadmin/services/token.py create mode 100644 powerdnsadmin/templates/email_confirmation.html create mode 100644 powerdnsadmin/templates/emails/account_verification.html create mode 100644 powerdnsadmin/templates/resend_confirmation_email.html diff --git a/configs/development.py b/configs/development.py index 39c8f6d..b793995 100644 --- a/configs/development.py +++ b/configs/development.py @@ -20,6 +20,16 @@ SQLALCHEMY_TRACK_MODIFICATIONS = True ### DATABASE - SQLite SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') +### SMTP config +# MAIL_SERVER = 'localhost' +# MAIL_PORT = 25 +# MAIL_DEBUG = False +# MAIL_USE_TLS = False +# MAIL_USE_SSL = False +# MAIL_USERNAME = None +# MAIL_PASSWORD = None +# MAIL_DEFAULT_SENDER = ('PowerDNS-Admin', 'noreply@domain.ltd') + # SAML Authnetication SAML_ENABLED = False # SAML_DEBUG = True @@ -47,20 +57,20 @@ SAML_ENABLED = False # Following parameter defines RequestedAttributes section in SAML metadata # since certain iDPs require explicit attribute request. If not provided section # will not be available in metadata. -# +# # Possible attributes: # name (mandatory), nameFormat, isRequired, friendlyName -# +# # NOTE: This parameter requires to be entered in valid JSON format as displayed below # and multiple attributes can given -# +# # Following example: -# +# # SAML_SP_REQUESTED_ATTRIBUTES = '[ \ # {"name": "urn:oid:0.9.2342.19200300.100.1.3", "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "isRequired": true, "friendlyName": "email"}, \ # {"name": "mail", "isRequired": false, "friendlyName": "test-field"} \ -# ]' -# +# ]' +# # produces following metadata section: # # @@ -108,13 +118,13 @@ SAML_ENABLED = False # SAML_SP_CONTACT_MAIL = '' # Configures the path to certificate file and it's respective private key file -# This pair is used for signing metadata, encrypting tokens and all other signing/encryption +# This pair is used for signing metadata, encrypting tokens and all other signing/encryption # tasks during communication between iDP and SP -# NOTE: if this two parameters aren't explicitly provided, self-signed certificate-key pair +# NOTE: if this two parameters aren't explicitly provided, self-signed certificate-key pair # will be generated in "PowerDNS-Admin" root directory # ########################################################################################### # CAUTION: For production use, usage of self-signed certificates it's highly discouraged. -# Use certificates from trusted CA instead +# Use certificates from trusted CA instead # ########################################################################################### # SAML_CERT_FILE = '/etc/pki/powerdns-admin/cert.crt' # SAML_CERT_KEY = '/etc/pki/powerdns-admin/key.pem' diff --git a/configs/docker_config.py b/configs/docker_config.py index db51446..b4869b4 100644 --- a/configs/docker_config.py +++ b/configs/docker_config.py @@ -1,6 +1,6 @@ # Defaults for Docker image -BIND_ADDRESS='0.0.0.0' -PORT=80 +BIND_ADDRESS = '0.0.0.0' +PORT = 80 legal_envvars = ( 'SECRET_KEY', @@ -10,6 +10,14 @@ legal_envvars = ( 'SALT', 'SQLALCHEMY_TRACK_MODIFICATIONS', 'SQLALCHEMY_DATABASE_URI', + 'MAIL_SERVER', + 'MAIL_PORT', + 'MAIL_DEBUG', + 'MAIL_USE_TLS', + 'MAIL_USE_SSL', + 'MAIL_USERNAME', + 'MAIL_PASSWORD', + 'MAIL_DEFAULT_SENDER', 'SAML_ENABLED', 'SAML_DEBUG', 'SAML_PATH', @@ -37,14 +45,14 @@ legal_envvars = ( 'SAML_LOGOUT_URL', ) -legal_envvars_int = ( - 'PORT', - 'SAML_METADATA_CACHE_LIFETIME', -) +legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME') legal_envvars_bool = ( 'SQLALCHEMY_TRACK_MODIFICATIONS', 'HSTS_ENABLED', + 'MAIL_DEBUG', + 'MAIL_USE_TLS', + 'MAIL_USE_SSL', 'SAML_ENABLED', 'SAML_DEBUG', 'SAML_SIGN_REQUEST', diff --git a/migrations/versions/3f76448bb6de_add_user_confirmed_column.py b/migrations/versions/3f76448bb6de_add_user_confirmed_column.py new file mode 100644 index 0000000..c67243c --- /dev/null +++ b/migrations/versions/3f76448bb6de_add_user_confirmed_column.py @@ -0,0 +1,28 @@ +"""Add user.confirmed column + +Revision ID: 3f76448bb6de +Revises: b0fea72a3f20 +Create Date: 2019-12-21 17:11:36.564632 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3f76448bb6de' +down_revision = 'b0fea72a3f20' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('confirmed', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'confirmed') + # ### end Alembic commands ### diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py index 2f2c977..8281794 100755 --- a/powerdnsadmin/__init__.py +++ b/powerdnsadmin/__init__.py @@ -2,6 +2,7 @@ import os import logging from flask import Flask from flask_seasurf import SeaSurf +from flask_mail import Mail from werkzeug.middleware.proxy_fix import ProxyFix from .lib import utils @@ -64,6 +65,9 @@ def create_app(config=None): from flask_sslify import SSLify _sslify = SSLify(app) # lgtm [py/unused-local-variable] + # SMTP + app.mail = Mail(app) + # Load app's components assets.init_app(app) models.init_app(app) diff --git a/powerdnsadmin/decorators.py b/powerdnsadmin/decorators.py index d34af32..2e0918a 100644 --- a/powerdnsadmin/decorators.py +++ b/powerdnsadmin/decorators.py @@ -4,7 +4,7 @@ from functools import wraps from flask import g, request, abort, current_app, render_template from flask_login import current_user -from .models import User, ApiKey, Setting, Domain +from .models import User, ApiKey, Setting, Domain, Setting from .lib.errors import RequestIsNotJSON, NotEnoughPrivileges from .lib.errors import DomainAccessForbidden @@ -121,6 +121,12 @@ def api_basic_auth(f): plain_text_password=password) try: + if Setting().get('verify_user_email') and user.email and not user.confirmed: + current_app.logger.warning( + 'Basic authentication failed for user {} because of unverified email address' + .format(username)) + abort(401) + auth_method = request.args.get('auth_method', 'LOCAL') auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL' auth = user.is_validate(method=auth_method, diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index 376dbbd..991c2ae 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -25,6 +25,7 @@ class Setting(db.Model): 'allow_user_create_domain': False, 'bg_domain_updates': False, 'site_name': 'PowerDNS-Admin', + 'site_url': 'http://localhost:9191', 'session_timeout': 10, 'warn_session_timeout': True, 'pdns_api_url': '', @@ -33,6 +34,7 @@ class Setting(db.Model): 'pdns_version': '4.1.1', 'local_db_enabled': True, 'signup_enabled': True, + 'verify_user_email': False, 'ldap_enabled': False, 'ldap_type': 'ldap', 'ldap_uri': '', diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py index 24c8c5c..aa7ccad 100644 --- a/powerdnsadmin/models/user.py +++ b/powerdnsadmin/models/user.py @@ -26,6 +26,7 @@ class User(db.Model): lastname = db.Column(db.String(64)) email = db.Column(db.String(128)) otp_secret = db.Column(db.String(16)) + confirmed = db.Column(db.Boolean, default=False) role_id = db.Column(db.Integer, db.ForeignKey('role.id')) def __init__(self, @@ -38,6 +39,7 @@ class User(db.Model): role_id=None, email=None, otp_secret=None, + confirmed=False, reload_info=True): self.id = id self.username = username @@ -48,6 +50,7 @@ class User(db.Model): self.role_id = role_id self.email = email self.otp_secret = otp_secret + self.confirmed = confirmed if reload_info: user_info = self.get_user_info_by_id( @@ -61,6 +64,7 @@ class User(db.Model): self.email = user_info.email self.role_id = user_info.role_id self.otp_secret = user_info.otp_secret + self.confirmed = user_info.confirmed def is_authenticated(self): return True @@ -524,6 +528,13 @@ class User(db.Model): db.session.rollback() return False + def update_confirmed(self, confirmed): + """ + Update user email confirmation status + """ + self.confirmed = confirmed + db.session.commit() + def get_domains(self): """ Get list of domains which the user is granted to have diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index efcaac5..cd3cbe8 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -501,7 +501,7 @@ def setting_basic(): 'pretty_ipv6_ptr', 'dnssec_admins_only', 'allow_user_create_domain', 'bg_domain_updates', 'site_name', 'session_timeout', 'warn_session_timeout', 'ttl_options', - 'pdns_api_timeout' + 'pdns_api_timeout', 'verify_user_email' ] return render_template('admin_setting_basic.html', settings=settings) diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index de36314..419eea7 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -29,6 +29,8 @@ from ..services.github import github_oauth from ..services.azure import azure_oauth from ..services.oidc import oidc_oauth from ..services.saml import SAML +from ..services.token import confirm_token +from ..services.email import send_account_verification google = None github = None @@ -280,6 +282,12 @@ def login(): plain_text_password=password) try: + if Setting().get('verify_user_email') and user.email and not user.confirmed: + return render_template( + 'login.html', + saml_enabled=SAML_ENABLED, + error='Please confirm your email address first') + auth = user.is_validate(method=auth_method, src_ip=request.remote_addr) if auth == False: @@ -411,6 +419,8 @@ def register(): try: result = user.create_local_user() if result and result['status']: + if Setting().get('verify_user_email'): + send_account_verification(email) return redirect(url_for('index.login')) else: return render_template('register.html', @@ -421,6 +431,50 @@ def register(): return render_template('errors/404.html'), 404 +@index_bp.route('/confirm/', methods=['GET']) +def confirm_email(token): + email = confirm_token(token) + if not email: + # Cannot confirm email + return render_template('email_confirmation.html', status=0) + + user = User.query.filter_by(email=email).first_or_404() + if user.confirmed: + # Already confirmed + current_app.logger.info( + "User email {} already confirmed".format(email)) + return render_template('email_confirmation.html', status=2) + else: + # Confirm email is valid + user.update_confirmed(confirmed=True) + current_app.logger.info( + "User email {} confirmed successfully".format(email)) + return render_template('email_confirmation.html', status=1) + + +@index_bp.route('/resend-confirmation-email', methods=['GET', 'POST']) +def resend_confirmation_email(): + if current_user.is_authenticated: + return redirect(url_for('index.index')) + if request.method == 'GET': + return render_template('resend_confirmation_email.html') + elif request.method == 'POST': + email = request.form.get('email') + user = User.query.filter(User.email == email).first() + if not user: + # Email not found + status = 0 + elif user.confirmed: + # Email already confirmed + status = 1 + else: + # Send new confirmed email + send_account_verification(user.email) + status = 2 + + return render_template('resend_confirmation_email.html', status=status) + + @index_bp.route('/nic/checkip.html', methods=['GET', 'POST']) def dyndns_checkip(): # This route covers the default ddclient 'web' setting for the checkip service diff --git a/powerdnsadmin/services/email.py b/powerdnsadmin/services/email.py new file mode 100644 index 0000000..64003db --- /dev/null +++ b/powerdnsadmin/services/email.py @@ -0,0 +1,27 @@ +import traceback +from flask_mail import Message +from flask import current_app, render_template, url_for + +from .token import generate_confirmation_token +from ..models.setting import Setting + + +def send_account_verification(user_email): + """ + Send welcome message for the new registration + """ + try: + token = generate_confirmation_token(user_email) + verification_link = url_for('index.confirm_email', token=token, _external=True) + + subject = "Welcome to {}".format(Setting().get('site_name')) + msg = Message(subject=subject) + msg.recipients = [user_email] + msg.body = "Please access the following link verify your email address. {}".format( + verification_link) + msg.html = render_template('emails/account_verification.html', + verification_link=verification_link) + current_app.mail.send(msg) + except Exception as e: + current_app.logger.error("Cannot send account verification email. Error: {}".format(e)) + current_app.logger.debug(traceback.format_exc()) diff --git a/powerdnsadmin/services/token.py b/powerdnsadmin/services/token.py new file mode 100644 index 0000000..e4e98ff --- /dev/null +++ b/powerdnsadmin/services/token.py @@ -0,0 +1,18 @@ +from flask import current_app +from itsdangerous import URLSafeTimedSerializer + + +def generate_confirmation_token(email): + serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY']) + return serializer.dumps(email, salt=current_app.config['SALT']) + + +def confirm_token(token, expiration=86400): + serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY']) + try: + email = serializer.loads(token, + salt=current_app.config['SALT'], + max_age=expiration) + except: + return False + return email diff --git a/powerdnsadmin/templates/base.html b/powerdnsadmin/templates/base.html index 2374b99..d73aa8f 100644 --- a/powerdnsadmin/templates/base.html +++ b/powerdnsadmin/templates/base.html @@ -177,7 +177,7 @@ {% block scripts %} {% assets "js_main" -%} - {% if SETTING.get('warn_session_timeout') %} + {% if SETTING.get('warn_session_timeout') and current_user.is_authenticated %}