Add user email verification

This commit is contained in:
Khanh Ngo 2019-12-21 21:43:03 +07:00
parent 49908b9039
commit 7739bf7cfc
No known key found for this signature in database
GPG Key ID: A945965CD6351844
18 changed files with 495 additions and 20 deletions

View File

@ -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:
# <md:AttributeConsumingService index="1">
# <md:RequestedAttribute Name="urn:oid:0.9.2342.19200300.100.1.3" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="email" isRequired="true"/>
@ -108,13 +118,13 @@ SAML_ENABLED = False
# SAML_SP_CONTACT_MAIL = '<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'

View File

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

View File

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

View File

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

View File

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

View File

@ -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': '',

View File

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

View File

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

View File

@ -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/<token>', 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

View File

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

View File

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

View File

@ -177,7 +177,7 @@
{% block scripts %}
{% assets "js_main" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% if SETTING.get('warn_session_timeout') %}
{% if SETTING.get('warn_session_timeout') and current_user.is_authenticated %}
<script>
// close the session warning popup when receive
// a boradcast message

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}<title>Email verification - {{ SITE_NAME }}</title>{% endblock %}
{% block dashboard_stat %}
{% endblock %}
{% block content %}
<!-- Main content -->
<section class="content">
<div class="error-page">
<div class="error-content">
{% if status == 1 %}
<h3>
<i class="fa fa-thumbs-o-up text-success"></i> Email verification successful!
</h3>
<p>
You have confirmed your account. <a href="{{ url_for('index.login') }}">Click here</a> to login.
</p>
{% elif status == 2 %}
<h3>
<i class="fa fa-hand-stop-o text-info"></i> Already verified!
</h3>
<p>
You have confirmed your account already. <a href="{{ url_for('index.login') }}">Click here</a> to login.
</p>
{% else %}
<h3>
<i class="fa fa-warning text-yellow"></i> Email verification failed!
</h3>
<p>
The confirmation link is invalid or has expired. <a href="{{ url_for('index.resend_confirmation_email') }}">Click here</a> if you want to resend a new link.
</p>
{% endif %}
</div>
<!-- /.error-content -->
</div>
<!-- /.error-page -->
</section>
<!-- /.content -->
{% endblock %}

View File

@ -0,0 +1,219 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>{{ SITE_NAME }} user verification email</title>
<style>
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body class=""
style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<span class="preheader"
style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">This
is preheader text. Some clients will show this text as a preview.</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f6f6f6; width: 100%;"
width="100%" bgcolor="#f6f6f6">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
<td class="container"
style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; max-width: 580px; padding: 10px; width: 580px; margin: 0 auto;"
width="580" valign="top">
<div class="content"
style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 580px; padding: 10px;">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;"
width="100%">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper"
style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;"
valign="top">
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;"
width="100%">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"
valign="top">
<p
style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
Hello!</p>
<p
style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
Please click the button below to verify your email address.</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
class="btn btn-primary"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; box-sizing: border-box; width: 100%;"
width="100%">
<tbody>
<tr>
<td align="left"
style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;"
valign="top">
<table role="presentation" border="0" cellpadding="0"
cellspacing="0"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; border-radius: 5px; text-align: center; background-color: #3498db;"
valign="top" align="center"
bgcolor="#3498db"> <a
href="{{ verification_link }}"
target="_blank"
style="border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; text-transform: capitalize; background-color: #3498db; border-color: #3498db; color: #ffffff;">Verify
Email Address</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p
style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
Your confirmation link will be expired in 24 hours. If you did not create an account, no further action is required.</p>
<p
style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
Cheers!</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer" style="clear: both; margin-top: 10px; text-align: center; width: 100%;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;"
width="100%">
<tr>
<td class="content-block"
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;"
valign="top" align="center">
<span class="apple-link"
style="color: #999999; font-size: 12px; text-align: center;">This email was sent
from <a href="{{ SETTING.get('site_url') }}"
target="_blank">{{ SITE_NAME }}</a></span>
</td>
</tr>
<tr>
<td class="content-block powered-by"
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;"
valign="top" align="center">
Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin"
style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">PowerDNS-Admin</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@ -120,6 +120,10 @@
{% if SETTING.get('signup_enabled') %}
<br>
<a href="{{ url_for('index.register') }}" class="text-center">Create an account </a>
{% if SETTING.get('verify_user_email') %}
<br/>
<a href="{{ url_for('index.resend_confirmation_email') }}" class="text-center">Resend confirmation email</a>
{% endif %}
{% endif %}
</div>
<!-- /.login-box-body -->

View File

@ -48,8 +48,7 @@
</div>
<div class="form-group has-feedback">
<input type="email" class="form-control" placeholder="Email" name="email"
data-error="Please input your valid email address"
pattern="^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$" required>
data-error="Please input your valid email address" required>
<span class="glyphicon glyphicon-envelope form-control-feedback"></span>
<span class="help-block with-errors"></span>
</div>

View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}<title>Resend confirmation email - {{ SITE_NAME }}</title>{% endblock %}
{% block dashboard_stat %}
{% endblock %}
{% block content %}
<!-- Main content -->
<section class="content">
<div class="error-page">
<div class="error-content">
<h3>
<i class="fa fa-hand-o-right text-info"></i> Resend a confirmation email
</h3>
<p>
Enter your email address to get new account confirmation link.
</p>
<form class="search-form" method="post">
<div class="input-group">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="text" name="email" class="form-control" placeholder="Email address" data-error="Please input your email" required>
<div class="input-group-btn">
<button type="submit" name="submit" class="btn btn-success btn-flat"><i class="fa fa-mail-reply"></i>
</button>
</div>
</div>
<!-- /.input-group -->
<p>
{% if status == 0 %}
<font color="red">Email not found!</font>
{% elif status == 1 %}
<font color="red">Email already confirmed!</font>
{% elif status == 2 %}
<font color="green">Confirmation email sent!</font>
{% endif %}
</p>
</form>
</div>
<!-- /.error-content -->
</div>
<!-- /.error-page -->
</section>
<!-- /.content -->
{% endblock %}

View File

@ -26,3 +26,4 @@ pytest==5.0.1
pytimeparse==1.1.8
PyYAML==5.1.1
Flask-SSLify==0.1.5
Flask-Mail==0.9.1