From 01e546ab514fdee09e410116669a88ca2ed44737 Mon Sep 17 00:00:00 2001 From: wzj <244142824@qq.com> Date: Sat, 14 Jun 2025 19:22:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=82=AE=E7=AE=B1=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 39 +++++++++ app.py | 184 +++++++++++++++++++++++++++++++++++++--- config.py | 89 +++++++++++++++---- database.py | 1 + static/favicon.svg | 1 + templates/base.html | 7 +- templates/register.html | 79 +++++++++++++++-- 7 files changed, 359 insertions(+), 41 deletions(-) create mode 100644 .env create mode 100644 static/favicon.svg diff --git a/.env b/.env new file mode 100644 index 0000000..d77376d --- /dev/null +++ b/.env @@ -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" # 发件人显示名称 \ No newline at end of file diff --git a/app.py b/app.py index 1c520f0..6172971 100644 --- a/app.py +++ b/app.py @@ -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""" + + +

感谢注册!

+

请点击以下链接完成邮箱验证:

+

{confirm_url}

+

(链接24小时内有效)

+

如果链接无法点击,请复制到浏览器地址栏访问

+ + + """ + + 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/') +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('/') diff --git a/config.py b/config.py index ea87452..146bf94 100644 --- a/config.py +++ b/config.py @@ -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 \ No newline at end of file + 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}" \ No newline at end of file diff --git a/database.py b/database.py index d9c49ff..9143ed1 100644 --- a/database.py +++ b/database.py @@ -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 ) """, diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..a01d4af --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index a2921bf..470a737 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,6 +4,7 @@ 证书管理系统 - {% block title %}{% endblock %} +