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 ### DATABASE - SQLite
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') 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 Authnetication
SAML_ENABLED = False SAML_ENABLED = False
# SAML_DEBUG = True # SAML_DEBUG = True
@ -47,20 +57,20 @@ SAML_ENABLED = False
# Following parameter defines RequestedAttributes section in SAML metadata # Following parameter defines RequestedAttributes section in SAML metadata
# since certain iDPs require explicit attribute request. If not provided section # since certain iDPs require explicit attribute request. If not provided section
# will not be available in metadata. # will not be available in metadata.
# #
# Possible attributes: # Possible attributes:
# name (mandatory), nameFormat, isRequired, friendlyName # name (mandatory), nameFormat, isRequired, friendlyName
# #
# NOTE: This parameter requires to be entered in valid JSON format as displayed below # NOTE: This parameter requires to be entered in valid JSON format as displayed below
# and multiple attributes can given # and multiple attributes can given
# #
# Following example: # Following example:
# #
# SAML_SP_REQUESTED_ATTRIBUTES = '[ \ # 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": "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"} \ # {"name": "mail", "isRequired": false, "friendlyName": "test-field"} \
# ]' # ]'
# #
# produces following metadata section: # produces following metadata section:
# <md:AttributeConsumingService index="1"> # <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"/> # <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>' # SAML_SP_CONTACT_MAIL = '<contact mail>'
# Configures the path to certificate file and it's respective private key file # 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 # 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 # will be generated in "PowerDNS-Admin" root directory
# ########################################################################################### # ###########################################################################################
# CAUTION: For production use, usage of self-signed certificates it's highly discouraged. # 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_FILE = '/etc/pki/powerdns-admin/cert.crt'
# SAML_CERT_KEY = '/etc/pki/powerdns-admin/key.pem' # SAML_CERT_KEY = '/etc/pki/powerdns-admin/key.pem'

View File

@ -1,6 +1,6 @@
# Defaults for Docker image # Defaults for Docker image
BIND_ADDRESS='0.0.0.0' BIND_ADDRESS = '0.0.0.0'
PORT=80 PORT = 80
legal_envvars = ( legal_envvars = (
'SECRET_KEY', 'SECRET_KEY',
@ -10,6 +10,14 @@ legal_envvars = (
'SALT', 'SALT',
'SQLALCHEMY_TRACK_MODIFICATIONS', 'SQLALCHEMY_TRACK_MODIFICATIONS',
'SQLALCHEMY_DATABASE_URI', '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_ENABLED',
'SAML_DEBUG', 'SAML_DEBUG',
'SAML_PATH', 'SAML_PATH',
@ -37,14 +45,14 @@ legal_envvars = (
'SAML_LOGOUT_URL', 'SAML_LOGOUT_URL',
) )
legal_envvars_int = ( legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME')
'PORT',
'SAML_METADATA_CACHE_LIFETIME',
)
legal_envvars_bool = ( legal_envvars_bool = (
'SQLALCHEMY_TRACK_MODIFICATIONS', 'SQLALCHEMY_TRACK_MODIFICATIONS',
'HSTS_ENABLED', 'HSTS_ENABLED',
'MAIL_DEBUG',
'MAIL_USE_TLS',
'MAIL_USE_SSL',
'SAML_ENABLED', 'SAML_ENABLED',
'SAML_DEBUG', 'SAML_DEBUG',
'SAML_SIGN_REQUEST', '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 import logging
from flask import Flask from flask import Flask
from flask_seasurf import SeaSurf from flask_seasurf import SeaSurf
from flask_mail import Mail
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from .lib import utils from .lib import utils
@ -64,6 +65,9 @@ def create_app(config=None):
from flask_sslify import SSLify from flask_sslify import SSLify
_sslify = SSLify(app) # lgtm [py/unused-local-variable] _sslify = SSLify(app) # lgtm [py/unused-local-variable]
# SMTP
app.mail = Mail(app)
# Load app's components # Load app's components
assets.init_app(app) assets.init_app(app)
models.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 import g, request, abort, current_app, render_template
from flask_login import current_user 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 RequestIsNotJSON, NotEnoughPrivileges
from .lib.errors import DomainAccessForbidden from .lib.errors import DomainAccessForbidden
@ -121,6 +121,12 @@ def api_basic_auth(f):
plain_text_password=password) plain_text_password=password)
try: 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 = request.args.get('auth_method', 'LOCAL')
auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL' auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL'
auth = user.is_validate(method=auth_method, auth = user.is_validate(method=auth_method,

View File

@ -25,6 +25,7 @@ class Setting(db.Model):
'allow_user_create_domain': False, 'allow_user_create_domain': False,
'bg_domain_updates': False, 'bg_domain_updates': False,
'site_name': 'PowerDNS-Admin', 'site_name': 'PowerDNS-Admin',
'site_url': 'http://localhost:9191',
'session_timeout': 10, 'session_timeout': 10,
'warn_session_timeout': True, 'warn_session_timeout': True,
'pdns_api_url': '', 'pdns_api_url': '',
@ -33,6 +34,7 @@ class Setting(db.Model):
'pdns_version': '4.1.1', 'pdns_version': '4.1.1',
'local_db_enabled': True, 'local_db_enabled': True,
'signup_enabled': True, 'signup_enabled': True,
'verify_user_email': False,
'ldap_enabled': False, 'ldap_enabled': False,
'ldap_type': 'ldap', 'ldap_type': 'ldap',
'ldap_uri': '', 'ldap_uri': '',

View File

@ -26,6 +26,7 @@ class User(db.Model):
lastname = db.Column(db.String(64)) lastname = db.Column(db.String(64))
email = db.Column(db.String(128)) email = db.Column(db.String(128))
otp_secret = db.Column(db.String(16)) otp_secret = db.Column(db.String(16))
confirmed = db.Column(db.Boolean, default=False)
role_id = db.Column(db.Integer, db.ForeignKey('role.id')) role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
def __init__(self, def __init__(self,
@ -38,6 +39,7 @@ class User(db.Model):
role_id=None, role_id=None,
email=None, email=None,
otp_secret=None, otp_secret=None,
confirmed=False,
reload_info=True): reload_info=True):
self.id = id self.id = id
self.username = username self.username = username
@ -48,6 +50,7 @@ class User(db.Model):
self.role_id = role_id self.role_id = role_id
self.email = email self.email = email
self.otp_secret = otp_secret self.otp_secret = otp_secret
self.confirmed = confirmed
if reload_info: if reload_info:
user_info = self.get_user_info_by_id( user_info = self.get_user_info_by_id(
@ -61,6 +64,7 @@ class User(db.Model):
self.email = user_info.email self.email = user_info.email
self.role_id = user_info.role_id self.role_id = user_info.role_id
self.otp_secret = user_info.otp_secret self.otp_secret = user_info.otp_secret
self.confirmed = user_info.confirmed
def is_authenticated(self): def is_authenticated(self):
return True return True
@ -524,6 +528,13 @@ class User(db.Model):
db.session.rollback() db.session.rollback()
return False return False
def update_confirmed(self, confirmed):
"""
Update user email confirmation status
"""
self.confirmed = confirmed
db.session.commit()
def get_domains(self): def get_domains(self):
""" """
Get list of domains which the user is granted to have 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', 'pretty_ipv6_ptr', 'dnssec_admins_only',
'allow_user_create_domain', 'bg_domain_updates', 'site_name', 'allow_user_create_domain', 'bg_domain_updates', 'site_name',
'session_timeout', 'warn_session_timeout', 'ttl_options', '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) 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.azure import azure_oauth
from ..services.oidc import oidc_oauth from ..services.oidc import oidc_oauth
from ..services.saml import SAML from ..services.saml import SAML
from ..services.token import confirm_token
from ..services.email import send_account_verification
google = None google = None
github = None github = None
@ -280,6 +282,12 @@ def login():
plain_text_password=password) plain_text_password=password)
try: 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, auth = user.is_validate(method=auth_method,
src_ip=request.remote_addr) src_ip=request.remote_addr)
if auth == False: if auth == False:
@ -411,6 +419,8 @@ def register():
try: try:
result = user.create_local_user() result = user.create_local_user()
if result and result['status']: if result and result['status']:
if Setting().get('verify_user_email'):
send_account_verification(email)
return redirect(url_for('index.login')) return redirect(url_for('index.login'))
else: else:
return render_template('register.html', return render_template('register.html',
@ -421,6 +431,50 @@ def register():
return render_template('errors/404.html'), 404 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']) @index_bp.route('/nic/checkip.html', methods=['GET', 'POST'])
def dyndns_checkip(): def dyndns_checkip():
# This route covers the default ddclient 'web' setting for the checkip service # 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 %} {% block scripts %}
{% assets "js_main" -%} {% assets "js_main" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script> <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> <script>
// close the session warning popup when receive // close the session warning popup when receive
// a boradcast message // 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') %} {% if SETTING.get('signup_enabled') %}
<br> <br>
<a href="{{ url_for('index.register') }}" class="text-center">Create an account </a> <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 %} {% endif %}
</div> </div>
<!-- /.login-box-body --> <!-- /.login-box-body -->

View File

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