优化邮箱认证功能

This commit is contained in:
wzj 2025-06-14 19:22:42 +08:00
parent 5854180c29
commit 01e546ab51
7 changed files with 359 additions and 41 deletions

39
.env Normal file
View File

@ -0,0 +1,39 @@
# [安全配置]
SECRET_KEY=your-unique-and-secure-secret-key-here # Flask应用加密密钥生产环境必须修改建议使用32位随机字符串
ADMIN_USERNAME=admin # 管理员名称
ADMIN_PASSWORD=123456 # 管理员初始密码(首次部署后必须修改)
# [数据库配置]
DB_HOST=192.168.31.11 # 数据库服务器IP地址或域名
DB_NAME=cert_manager # 数据库名称
DB_USER=certmgr # 数据库用户名
DB_PASSWORD=certmgr123 # 数据库密码(生产环境建议使用强密码)
DB_PORT=3306 # 数据库端口MySQL默认3306
# [应用配置]
APP_HOST=0.0.0.0 # 应用监听地址0.0.0.0表示允许所有IP访问
APP_PORT=9875 # 应用监听端口
DEBUG=False # 调试模式(生产环境必须关闭)
APP_DOMAIN=localhost # 应用对外域名(用于邮件链接生成)
APP_PROTOCOL=http # 应用协议http或https
# [注册配置]
REGISTRATION_OPEN=True # 是否开放用户注册True/False
EMAIL_VERIFICATION_REQUIRED=True # 注册是否需要邮箱验证True/False
# [密码策略]
PASSWORD_MIN_LENGTH=6 # 密码最小长度
PASSWORD_REQUIRE_UPPERCASE=False # 是否要求包含大写字母True/False
PASSWORD_REQUIRE_LOWERCASE=False # 是否要求包含小写字母True/False
PASSWORD_REQUIRE_DIGITS=True # 是否要求包含数字True/False
PASSWORD_REQUIRE_SPECIAL=False # 是否要求包含特殊字符True/False
# [邮件配置]
MAIL_SERVER=smtp.qq.com # SMTP服务器地址QQ邮箱为smtp.qq.com
MAIL_PORT=465 # SMTP端口QQ邮箱SSL端口为465
MAIL_USE_SSL=True # 是否使用SSL加密QQ邮箱必须开启
MAIL_USE_TLS=False # 是否使用TLS加密与SSL二选一
MAIL_USERNAME= # 发件邮箱地址
MAIL_PASSWORD= # SMTP授权码非邮箱密码
MAIL_DEFAULT_SENDER_EMAIL= # 默认发件邮箱
MAIL_DEFAULT_SENDER_NAME="Certificate Manager" # 发件人显示名称

184
app.py
View File

@ -2,7 +2,7 @@
import os
import subprocess
from datetime import datetime, timedelta
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, Response
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, Response, current_app
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
import mysql.connector
@ -14,17 +14,33 @@ import zipfile
import shutil
import re
from pypinyin import pinyin, Style
from flask import send_file
from flask_mail import Mail
from PIL import Image, ImageDraw, ImageFont
from flask_migrate import Migrate
from dotenv import load_dotenv
from pathlib import Path
from urllib.parse import urljoin
# 先加载环境变量必须在创建app之前
load_dotenv(Path(__file__).parent / '.env', override=True)
def generate_full_url(endpoint, **values):
"""生成完整的URL考虑自定义域名和协议"""
base_url = f"{current_app.config['APP_PROTOCOL']}://{current_app.config['APP_DOMAIN']}"
path = url_for(endpoint, **values)
return urljoin(base_url, path)
# 从配置文件中导入配置
from config import Config
app = Flask(__name__)
app = Flask(__name__, static_folder='static')
app.config.from_object(Config)
# 初始化邮件扩展
mail = Mail(app)
@app.context_processor
def inject_now():
return {'now': datetime.now()}
@ -643,14 +659,20 @@ def export_pkcs12(cert_id, password):
@app.route('/register', methods=['GET', 'POST'])
def register():
# 检查用户是否已登录
if current_user.is_authenticated:
return redirect(url_for('index'))
# 检查注册功能是否开放
if not current_app.config['REGISTRATION_OPEN']:
flash('系统暂未开放注册', 'warning')
return redirect(url_for('login'))
if request.method == 'POST':
username = request.form['username']
username = request.form['username'].strip()
password = request.form['password']
confirm_password = request.form['confirm_password']
email = request.form.get('email', '')
email = request.form.get('email', '').strip()
captcha = request.form['captcha']
# 验证验证码
@ -663,36 +685,170 @@ def register():
flash('两次输入的密码不匹配', 'danger')
return redirect(url_for('register'))
# 密码策略检查
password_errors = []
policy = current_app.config['PASSWORD_POLICY']
if len(password) < policy['min_length']:
password_errors.append(f"密码长度至少{policy['min_length']}")
if policy['require_uppercase'] and not re.search(r'[A-Z]', password):
password_errors.append("必须包含大写字母")
if policy['require_lowercase'] and not re.search(r'[a-z]', password):
password_errors.append("必须包含小写字母")
if policy['require_digits'] and not re.search(r'[0-9]', password):
password_errors.append("必须包含数字")
if policy['require_special_chars'] and not re.search(r'[^A-Za-z0-9]', password):
password_errors.append("必须包含特殊字符")
if password_errors:
flash('密码不符合要求: ' + ', '.join(password_errors), 'danger')
return redirect(url_for('register'))
# 验证用户名是否已存在
conn = get_db_connection()
if conn:
try:
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = %s", (username,))
# 检查用户名和邮箱是否已存在
cursor.execute("SELECT id FROM users WHERE username = %s OR email = %s",
(username, email))
if cursor.fetchone():
flash('用户名已存在', 'danger')
flash('用户名或邮箱已被注册', 'danger')
return redirect(url_for('register'))
# 根据配置决定是否立即激活账户
is_active = not current_app.config['EMAIL_VERIFICATION_REQUIRED']
verification_token = None
# 创建新用户
password_hash = generate_password_hash(password)
cursor.execute("""
INSERT INTO users (username, password_hash, email, is_admin, is_active)
VALUES (%s, %s, %s, %s, %s)
""", (username, password_hash, email, False, True))
INSERT INTO users (username, password_hash, email, is_admin, is_active, verification_token)
VALUES (%s, %s, %s, %s, %s, %s)
""", (username, password_hash, email, False, is_active, verification_token))
if current_app.config['EMAIL_VERIFICATION_REQUIRED']:
# 生成验证令牌
from itsdangerous import URLSafeTimedSerializer
serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
token = serializer.dumps({'username': username, 'email': email}, salt='email-verify')
# 更新用户验证令牌
cursor.execute("""
UPDATE users SET verification_token = %s WHERE username = %s
""", (token, username))
# 发送验证邮件
try:
from flask_mail import Message
msg = Message('请验证您的邮箱 - 自签证书管理系统',
sender=current_app.config['MAIL_DEFAULT_SENDER'],
recipients=[email])
confirm_url = generate_full_url('verify_email', token=token)
# 使用HTML格式的邮件内容
msg.html = f"""
<html>
<body>
<p>感谢注册</p>
<p>请点击以下链接完成邮箱验证:</p>
<p><a href="{confirm_url}">{confirm_url}</a></p>
<p>(链接24小时内有效)</p>
<p>如果链接无法点击请复制到浏览器地址栏访问</p>
</body>
</html>
"""
mail = Mail(current_app)
mail.send(msg)
flash('验证邮件已发送至您的邮箱,请查收并完成验证', 'info')
except Exception as e:
current_app.logger.error(f'邮件发送失败: {str(e)}')
conn.rollback()
flash('发送验证邮件失败,请稍后再试或联系管理员', 'danger')
return redirect(url_for('register'))
conn.commit()
flash('注册成功,请登录', 'success')
return redirect(url_for('login'))
if is_active:
flash('注册成功,请登录', 'success')
return redirect(url_for('login'))
else:
return redirect(url_for('register'))
except Error as e:
print(f"Database error: {e}")
conn.rollback()
current_app.logger.error(f'数据库错误: {str(e)}')
flash('注册失败,请稍后再试', 'danger')
finally:
if conn.is_connected():
cursor.close()
conn.close()
# 生成新验证码
captcha_code = generate_captcha()
return render_template('register.html', captcha_code=captcha_code)
return render_template('register.html',
captcha_code=captcha_code,
registration_open=current_app.config['REGISTRATION_OPEN'],
email_required=current_app.config['EMAIL_VERIFICATION_REQUIRED'])
@app.route('/verify-email/<token>')
def verify_email(token):
if current_user.is_authenticated:
return redirect(generate_full_url('index'))
try:
from itsdangerous import URLSafeTimedSerializer
serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
data = serializer.loads(token, salt='email-verify', max_age=86400) # 24小时有效期
username = data['username']
email = data['email']
conn = get_db_connection()
if conn:
try:
cursor = conn.cursor(dictionary=True)
# 验证令牌是否匹配
cursor.execute("""
SELECT id FROM users
WHERE username = %s AND email = %s AND verification_token = %s
""", (username, email, token))
user = cursor.fetchone()
if not user:
flash('无效的验证链接', 'danger')
return redirect(generate_full_url('login'))
# 激活账户
cursor.execute("""
UPDATE users
SET is_active = TRUE, verification_token = NULL
WHERE id = %s
""", (user['id'],))
conn.commit()
flash('邮箱验证成功,您现在可以登录了', 'success')
return redirect(generate_full_url('login'))
except Error as e:
conn.rollback()
current_app.logger.error(f'数据库错误: {str(e)}')
flash('验证过程中出现错误', 'danger')
finally:
if conn.is_connected():
cursor.close()
conn.close()
except Exception as e:
current_app.logger.error(f'令牌验证失败: {str(e)}')
flash('验证链接无效或已过期', 'danger')
return redirect(generate_full_url('login'))
# 路由定义
@app.route('/')

View File

@ -1,27 +1,86 @@
# config.py
import os
from dotenv import load_dotenv
from pathlib import Path
# 先加载环境变量必须在Config类之前
load_dotenv(Path(__file__).parent / '.env', override=True)
class Config:
# Flask配置
SECRET_KEY = os.getenv('SECRET_KEY', 'your-secret-key-here')
# Flask 安全配置
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key') # 生产环境必须覆盖
SESSION_COOKIE_SECURE = True # 只允许HTTPS传输
SESSION_COOKIE_HTTPONLY = True
PERMANENT_SESSION_LIFETIME = 3600 # 1小时会话有效期
# 数据库配置
# 数据库配置 (从环境变量读取)
DB_CONFIG = {
'host': '192.168.31.11',
'database': 'cert_manager',
'user': 'certmgr',
'password': 'certmgr123'
'host': os.getenv('DB_HOST', 'localhost'),
'database': os.getenv('DB_NAME', 'cert_manager'),
'user': os.getenv('DB_USER', 'certmgr'),
'password': os.getenv('DB_PASSWORD', ''),
'port': int(os.getenv('DB_PORT', '3306')),
'charset': 'utf8mb4',
'collation': 'utf8mb4_general_ci',
'autocommit': True
}
# 证书存储路径
CERT_STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cert_store')
# 证书存储路径 (使用Path更安全)
CERT_STORE = Path(os.getenv('CERT_STORE',
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cert_store')))
# 管理员初始凭据
# 确保证书存储目录存在
if not CERT_STORE.exists():
CERT_STORE.mkdir(mode=0o700, parents=True) # 设置严格权限
# 管理员配置
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', '123456')
ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', '') # 生产环境必须设置
ADMIN_EMAIL = os.getenv('ADMIN_EMAIL', 'admin@example.com')
# 应用运行配置
APP_HOST = '0.0.0.0'
APP_PORT = 9875
DEBUG = True
APP_HOST = os.getenv('APP_HOST', '0.0.0.0')
APP_PORT = int(os.getenv('APP_PORT', '9875'))
DEBUG = os.getenv('DEBUG', 'False') == 'True' # 生产环境应为False
# 注册相关配置
REGISTRATION_OPEN = os.getenv('REGISTRATION_OPEN', 'False') == 'True'
EMAIL_VERIFICATION_REQUIRED = os.getenv('EMAIL_VERIFICATION_REQUIRED', 'True') == 'True'
# 密码策略配置
PASSWORD_POLICY = {
'min_length': int(os.getenv('PASSWORD_MIN_LENGTH', '8')),
'require_uppercase': os.getenv('PASSWORD_REQUIRE_UPPERCASE', 'True') == 'True',
'require_lowercase': os.getenv('PASSWORD_REQUIRE_LOWERCASE', 'True') == 'True',
'require_digits': os.getenv('PASSWORD_REQUIRE_DIGITS', 'True') == 'True',
'require_special_chars': os.getenv('PASSWORD_REQUIRE_SPECIAL', 'True') == 'True'
}
# 邮件服务器配置
MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.qq.com')
MAIL_PORT = int(os.getenv('MAIL_PORT', '465'))
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'True') == 'True'
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'False') == 'True'
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = (
os.getenv('MAIL_DEFAULT_SENDER_EMAIL', 'noreply@example.com'),
os.getenv('MAIL_DEFAULT_SENDER_NAME', 'Certificate Manager')
)
# 应用URL配置
APP_DOMAIN = os.getenv('APP_DOMAIN', 'xunxian.liuyan.wang')
APP_PROTOCOL = os.getenv('APP_PROTOCOL', 'https')
SERVER_NAME = os.getenv('SERVER_NAME') # 用于URL生成
# 日志配置
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
LOG_FILE = os.getenv('LOG_FILE', 'app.log')
@property
def SQLALCHEMY_DATABASE_URI(self):
return f"mysql+pymysql://{self.DB_CONFIG['user']}:{self.DB_CONFIG['password']}@" \
f"{self.DB_CONFIG['host']}:{self.DB_CONFIG['port']}/{self.DB_CONFIG['database']}"
@property
def BASE_URL(self):
return f"{self.APP_PROTOCOL}://{self.APP_DOMAIN}"

View File

@ -33,6 +33,7 @@ def create_database():
email VARCHAR(100),
is_admin BOOLEAN DEFAULT FALSE,
is_active tinyint(1) DEFAULT '1',
verification_token VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",

1
static/favicon.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1749894447194" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4365" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M640 768.042667c35.626667 26.794667 80 42.666667 128 42.666666a212.394667 212.394667 0 0 0 128-42.581333v138.496a32 32 0 0 1-46.506667 28.544l-3.84-2.346667L768 878.549333l-77.653333 54.272a32 32 0 0 1-50.005334-21.76l-0.298666-4.437333L640 768z m181.333333-639.872a117.333333 117.333333 0 0 1 117.12 110.208l0.213334 7.125333 0.042666 223.829333a214.442667 214.442667 0 0 0-64-56.746666L874.666667 245.461333a53.333333 53.333333 0 0 0-47.872-53.034666l-5.461334-0.298667H202.666667a53.333333 53.333333 0 0 0-53.077334 47.872l-0.256 5.461333v405.333334c0 27.605333 20.992 50.346667 47.872 53.077333l5.461334 0.256h380.586666c4.266667 7.338667 8.96 14.421333 14.08 21.205333v42.794667H202.666667a117.333333 117.333333 0 0 1-117.12-110.165333L85.333333 650.837333v-405.333333A117.333333 117.333333 0 0 1 195.498667 128.426667l7.168-0.213334h618.666666zM768 426.666667a170.666667 170.666667 0 1 1 0 341.376A170.666667 170.666667 0 0 1 768 426.666667z m-288 106.666666a32 32 0 0 1 4.352 63.701334L480 597.333333h-192a32 32 0 0 1-4.352-63.701333l4.352-0.298667h192z m256-234.666666a32 32 0 0 1 4.352 63.701333l-4.352 0.298667H288a32 32 0 0 1-4.352-63.701334L288 298.666667h448z" fill="#212121" p-id="4366"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>证书管理系统 - {% block title %}{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" rel="stylesheet">
<style>
@ -51,7 +52,7 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand fw-bold" href="{{ url_for('index') }}">
<i class="fas fa-shield-alt me-2"></i>证书管理系统
<i class="fas fa-shield-alt me-2"></i>自签证书管理系统
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
@ -137,8 +138,8 @@
<footer class="footer">
<div class="container text-center text-muted">
<small>
<p class="mb-1">证书管理系统 &copy; {{ now.year }} - 基于Flask构建</p>
<p class="mb-0">版本 1.0.0</p>
<p class="mb-1">自签证书管理系统 &copy; {{ now.year }} - 基于Flask构建</p>
<p class="mb-0">版本 6.0.0</p>
</small>
</div>
</footer>

View File

@ -12,30 +12,60 @@
</h4>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('register') }}">
<form method="POST" action="{{ url_for('register') }}" id="registerForm">
<div class="mb-3">
<label for="username" class="form-label">
<i class="fas fa-user me-1 text-muted"></i>用户名
</label>
<input type="text" class="form-control" id="username" name="username" required
placeholder="4-20位字母、数字或下划线">
<small class="form-text text-muted">请输入4-20位的字母、数字或下划线</small>
pattern="[a-zA-Z0-9_]{4,20}"
title="4-20位字母、数字或下划线">
<small class="form-text text-muted">4-20位字母、数字或下划线</small>
</div>
<div class="mb-3">
<label for="password" class="form-label">
<i class="fas fa-lock me-1 text-muted"></i>密码
</label>
<input type="password" class="form-control" id="password" name="password" required
placeholder="至少8位字符">
<small class="form-text text-muted">至少8位字符包含字母和数字</small>
minlength="{{ config.PASSWORD_POLICY.min_length }}"
pattern="{% if config.PASSWORD_POLICY.require_uppercase %}(?=.*[A-Z]){% endif %}
{% if config.PASSWORD_POLICY.require_lowercase %}(?=.*[a-z]){% endif %}
{% if config.PASSWORD_POLICY.require_digits %}(?=.*\d]){% endif %}
{% if config.PASSWORD_POLICY.require_special_chars %}(?=.*[!@#$%^&*]){% endif %}.*"
title="{% if config.PASSWORD_POLICY.require_uppercase %}必须包含大写字母{% endif %}
{% if config.PASSWORD_POLICY.require_lowercase %}必须包含小写字母{% endif %}
{% if config.PASSWORD_POLICY.require_digits %}必须包含数字{% endif %}
{% if config.PASSWORD_POLICY.require_special_chars %}必须包含特殊字符{% endif %}">
<small class="form-text text-muted">
密码要求:至少{{ config.PASSWORD_POLICY.min_length }}位
{% if config.PASSWORD_POLICY.require_uppercase %},包含大写字母{% endif %}
{% if config.PASSWORD_POLICY.require_lowercase %},包含小写字母{% endif %}
{% if config.PASSWORD_POLICY.require_digits %},包含数字{% endif %}
{% if config.PASSWORD_POLICY.require_special_chars %},包含特殊字符(!@#$%^&*){% endif %}
</small>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">
<i class="fas fa-lock me-1 text-muted"></i>确认密码
</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required
placeholder="再次输入密码">
<input type="password" class="form-control" id="confirm_password"
name="confirm_password" required
oninput="checkPasswordMatch()">
<small id="passwordMatchError" class="text-danger d-none">两次输入的密码不一致</small>
</div>
{% if config.EMAIL_VERIFICATION_REQUIRED %}
<div class="mb-3">
<label for="email" class="form-label">
<i class="fas fa-envelope me-1 text-muted"></i>邮箱
</label>
<input type="email" class="form-control" id="email" name="email" required
placeholder="example@domain.com">
<small class="form-text text-muted">用于接收验证邮件</small>
</div>
{% else %}
<div class="mb-3">
<label for="email" class="form-label">
<i class="fas fa-envelope me-1 text-muted"></i>邮箱(可选)
@ -43,6 +73,8 @@
<input type="email" class="form-control" id="email" name="email"
placeholder="example@domain.com">
</div>
{% endif %}
<div class="mb-4">
<label for="captcha" class="form-label">
<i class="fas fa-shield-alt me-1 text-muted"></i>验证码
@ -57,6 +89,7 @@
title="点击刷新验证码">
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-user-plus me-1"></i> 注册
@ -70,10 +103,38 @@
</div>
</div>
</div>
<script>
function refreshCaptcha() {
var captchaImage = document.getElementById('captcha-image');
captchaImage.src = "{{ url_for('captcha') }}?" + new Date().getTime();
document.getElementById('captcha-image').src = "{{ url_for('captcha') }}?" + Date.now();
}
function checkPasswordMatch() {
const password = document.getElementById('password');
const confirmPassword = document.getElementById('confirm_password');
const errorElement = document.getElementById('passwordMatchError');
if (password.value !== confirmPassword.value) {
errorElement.classList.remove('d-none');
confirmPassword.setCustomValidity("密码不匹配");
} else {
errorElement.classList.add('d-none');
confirmPassword.setCustomValidity("");
}
}
// 实时验证密码复杂度
document.getElementById('password').addEventListener('input', function() {
const password = this.value;
const policy = {
minLength: {{ config.PASSWORD_POLICY.min_length }},
requireUpper: {{ config.PASSWORD_POLICY.require_uppercase|lower }},
requireLower: {{ config.PASSWORD_POLICY.require_lowercase|lower }},
requireDigit: {{ config.PASSWORD_POLICY.require_digits|lower }},
requireSpecial: {{ config.PASSWORD_POLICY.require_special_chars|lower }}
};
// 可以在这里添加更复杂的实时验证逻辑
});
</script>
{% endblock %}