Compare commits

...

31 Commits
v5.2 ... master

Author SHA1 Message Date
wzj
f64e7b18b0 docker支持 2025-09-06 09:50:47 +08:00
wzj
9c92be13d9 修复验证码bug 2025-06-16 12:53:16 +08:00
wzj
89cb0af79a 修复验证码bug 2025-06-16 12:50:36 +08:00
wzj
9cbb6b022c 修复验证码bug 2025-06-16 12:49:25 +08:00
wzj
375816df87 修复验证码bug 2025-06-16 12:04:59 +08:00
wzj
2b36de569d 调整描述 2025-06-15 10:13:19 +08:00
wzj
21142a6104 调整描述 2025-06-15 10:10:18 +08:00
wzj
f3a2f05b1c 调整描述 2025-06-15 10:08:25 +08:00
wzj
722d88a017 开源协议 2025-06-15 09:54:09 +08:00
wzj
179400f9ed 调整应用信息 2025-06-15 09:27:35 +08:00
wzj
cc2b18ba80 显示证书创建人 2025-06-15 09:23:42 +08:00
wzj
fc3801f0f2 显示证书创建人 2025-06-15 09:21:44 +08:00
wzj
c2ca546955 添加配置模板 2025-06-15 08:57:10 +08:00
wzj
f711ca8280 Remove .env and add to gitignore 2025-06-15 08:51:36 +08:00
wzj
6993679971 Remove .idea directory from repository 2025-06-15 08:46:01 +08:00
wzj
2ae605e60b Remove .idea directory from repository 2025-06-15 08:45:12 +08:00
wzj
117a7885d7 del __pycache__ 2025-06-15 08:42:14 +08:00
wzj
5aeaab5747 CA详情页证书列表分页支持 2025-06-15 08:39:50 +08:00
wzj
35eea7a803 CA详情页证书列表分页支持 2025-06-15 08:36:37 +08:00
wzj
0001c8f0d3 支持批量删除 2025-06-15 08:00:03 +08:00
wzj
5c2381c3a8 支持批量删除 2025-06-15 07:56:35 +08:00
wzj
25add10b35 修复删除CA失败问题 2025-06-15 07:43:46 +08:00
wzj
6f578be53e 修复根证书信任问题 2025-06-15 07:33:08 +08:00
wzj
c5e8fd6e3f 修复根证书信任问题 2025-06-15 07:28:54 +08:00
wzj
47f0c572b3 修复根证书信任问题 2025-06-15 07:19:50 +08:00
wzj
afa61f7a04 修复根证书信任问题 2025-06-15 07:03:27 +08:00
wzj
35165bd58b 修复根证书信任问题 2025-06-15 06:51:55 +08:00
wzj
1ad7b4f642 修复验证码字体问题 2025-06-14 19:37:51 +08:00
wzj
61d9ba7db9 优化邮箱认证功能 2025-06-14 19:25:22 +08:00
wzj
6e3838b9f0 优化邮箱认证功能 2025-06-14 19:22:45 +08:00
wzj
01e546ab51 优化邮箱认证功能 2025-06-14 19:22:42 +08:00
30 changed files with 1319 additions and 280 deletions

39
.env.example 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=5000 # 应用监听端口
DEBUG=False # 调试模式(生产环境必须关闭)
APP_DOMAIN=localhost # 应用对外域名(用于邮件链接生成)
APP_PROTOCOL=http # 应用协议http或https
# [注册配置]
REGISTRATION_OPEN=True # 是否开放用户注册True/False
EMAIL_VERIFICATION_REQUIRED=False # 注册是否需要邮箱验证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" # 发件人显示名称

BIN
.gitignore vendored Normal file

Binary file not shown.

8
.idea/.gitignore generated vendored
View File

@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/certmgr.iml generated
View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

28
.idea/deployment.xml generated
View File

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PublishConfigData" remoteFilesAllowedToDisappearOnAutoupload="false">
<serverData>
<paths name="root@192.168.31.10:22 password">
<serverdata>
<mappings>
<mapping local="$PROJECT_DIR$" web="/" />
</mappings>
</serverdata>
</paths>
<paths name="root@192.168.31.10:22 password (2)">
<serverdata>
<mappings>
<mapping local="$PROJECT_DIR$" web="/" />
</mappings>
</serverdata>
</paths>
<paths name="root@192.168.31.11:22 password">
<serverdata>
<mappings>
<mapping local="$PROJECT_DIR$" web="/" />
</mappings>
</serverdata>
</paths>
</serverData>
</component>
</project>

View File

@ -1,16 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="2">
<item index="0" class="java.lang.String" itemvalue="APScheduler" />
<item index="1" class="java.lang.String" itemvalue="Flask" />
</list>
</value>
</option>
</inspection_tool>
</profile>
</component>

View File

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml generated
View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated
View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/certmgr.iml" filepath="$PROJECT_DIR$/.idea/certmgr.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# 使用官方 Python 镜像Debian 系)
FROM python:3.8-slim
# 设置时区为北京时间
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 安装系统依赖(仅运行时需要的库)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libjpeg-dev \
zlib1g-dev \
libtiff5-dev \
libfreetype6-dev \
liblcms2-dev \
libwebp-dev \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
# 复制依赖文件并安装
COPY requirements.txt .
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 5000
# 运行命令
CMD ["python", "app.py"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) [2025] [jeazw]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

131
README.md Normal file
View File

@ -0,0 +1,131 @@
# 自签名证书管理系统
![Python Version](https://img.shields.io/badge/Python-3.7+-blue)
![Flask](https://img.shields.io/badge/Flask-2.0+-green)
<div align="center">
<img src="./static/favicon.svg" alt="qilin SSL Logo" width="150">
<p>一款易用的自签证书管理系统</p>
</div>
一个基于Flask的Web应用程序用于管理自签名证书颁发机构(CA)和证书。
**全部代码由DeepSeek生成**
## 功能特性
- **证书颁发机构管理**
- 创建和管理自签名CA
- 查看CA详情及相关证书
- 生成证书吊销列表(CRL)
- 导出CA捆绑包(证书+私钥)
- **证书管理**
- 颁发由您的CA签名的证书
- 管理主题备用名称(SAN)
- 吊销和续订证书
- 支持多种格式导出(PKCS#12, PEM, CRT+KEY)
- **用户管理**
- 带邮箱验证的用户注册
- 基于角色的访问控制(管理员/普通用户)
- 密码策略强制执行
## 系统截图
<div style="display: flex; justify-content: space-between; margin: 20px 0;">
<img src="screenshots/index.png" alt="首页" style="width: 32%; border: 1px solid #ddd; border-radius: 4px;">
<img src="screenshots/ca_detail.png" alt="CA详情页" style="width: 32%; border: 1px solid #ddd; border-radius: 4px;">
<img src="screenshots/cert_detail.png" alt="证书详情页" style="width: 32%; border: 1px solid #ddd; border-radius: 4px;">
</div>
## demo测试账号
https://ssl.liuyan.wang
admin/123456
## 系统要求
- Python 3.7+
- MySQL/MariaDB数据库
- OpenSSL
- 所需Python包(见`requirements.txt`)
## 安装指南
1. 克隆仓库:
```bash
git clone https://github.com/yourusername/certificate-management-system.git
cd certificate-management-system
```
2. 创建并激活虚拟环境:
```bash
python -m venv venv
source venv/bin/activate # Windows系统: venv\Scripts\activate
```
3. 安装依赖:
```bash
pip install -r requirements.txt
```
4. 基于`.env.example`创建`.env`文件并配置:
```ini
DB_HOST=localhost # 数据库服务器IP地址或域名
DB_PORT=3306 # 数据库端口MySQL默认3306
DB_NAME=cert_management # 数据库名称
DB_USER=root # 数据库用户名
DB_PASSWORD=yourpassword # 数据库密码(生产环境建议使用强密码)
SECRET_KEY=your-secret-key-here # Flask应用加密密钥生产环境必须修改建议使用32位随机字符串
MAIL_SERVER=smtp.example.com # SMTP服务器地址QQ邮箱为smtp.qq.com
MAIL_PORT=587 # SMTP端口QQ邮箱SSL端口为465
MAIL_USE_TLS=True # 是否使用SSL加密QQ邮箱必须开启
MAIL_USERNAME=your-email@example.com # 发件邮箱地址
MAIL_PASSWORD=your-email-password # SMTP授权码非邮箱密码
APP_DOMAIN=localhost # 应用对外域名(用于邮件链接生成)
APP_PROTOCOL=https # 应用协议http或https
```
## 运行应用
```bash
python app.py
```
默认情况下,应用将在`http://localhost:5000`可用。
## 使用说明
1. 注册新账户(若注册开放)或直接登录
2. 管理员可:
- 创建和管理CA
- 颁发证书
- 查看系统所有证书
3. 普通用户可:
- 使用自己创建的CA颁发证书
- 管理自己的证书
## API接口
- `/`: 仪表盘
- `/login`, `/logout`: 认证相关
- `/register`: 用户注册
- `/cas`: CA管理
- `/certificates`: 证书管理
- `/download/<filename>`: 文件下载
## 安全注意事项
- 请始终在安全环境中运行本系统
- 妥善保管您的`.env`文件
- 定期备份证书存储和数据库
- 生产环境建议使用HTTPS
## 开源许可
[MIT License](LICENSE)
## 贡献指南
欢迎提交Pull Request。重大改动请先创建Issue讨论。
## 技术支持
如有问题请提交GitHub仓库Issue。

Binary file not shown.

710
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()}
@ -61,9 +77,12 @@ def generate_captcha_image():
# 尝试加载字体,失败则使用默认字体
try:
font = ImageFont.truetype("arial.ttf", 24)
except:
font = ImageFont.load_default()
# 指定字体路径(假设字体文件是 static/arial.ttf
font_path = os.path.join(current_app.static_folder, "arial.ttf")
font = ImageFont.truetype(font_path, 24)
except Exception as e:
print(f"加载字体失败: {e}")
font = ImageFont.load_default() # 回退到默认字体
# 绘制验证码文本(每个字符随机颜色)
for i, char in enumerate(captcha_code):
@ -179,37 +198,24 @@ def get_db_connection():
return None
def generate_captcha():
# 生成6位随机验证码
captcha_code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
conn = get_db_connection()
if conn:
try:
cursor = conn.cursor()
# 清除旧的验证码
cursor.execute("DELETE FROM captcha WHERE created_at < NOW() - INTERVAL 10 MINUTE")
# 插入新验证码
cursor.execute("INSERT INTO captcha (code) VALUES (%s)", (captcha_code,))
conn.commit()
return captcha_code
except Error as e:
print(f"Database error: {e}")
return None
finally:
if conn.is_connected():
cursor.close()
conn.close()
return None
def verify_captcha(user_input):
"""验证用户输入的验证码是否正确只验证最新的4位验证码"""
conn = get_db_connection()
if conn:
try:
cursor = conn.cursor()
cursor.execute("SELECT code FROM captcha ORDER BY created_at DESC LIMIT 1")
# 只查询最新的验证码确保是4位的
cursor.execute("""
SELECT code FROM captcha
WHERE LENGTH(code) = 4 -- 只查询4位验证码
ORDER BY created_at DESC
LIMIT 1
""")
result = cursor.fetchone()
if result and user_input.upper() == result[0]:
# 验证成功后删除已使用的验证码
cursor.execute("DELETE FROM captcha WHERE code = %s", (result[0],))
conn.commit()
return True
return False
except Error as e:
@ -281,16 +287,43 @@ def create_ca(ca_name, common_name, organization, organizational_unit, country,
key_path = os.path.join(ca_dir, f"{common_name}.key")
cert_path = os.path.join(ca_dir, f"{common_name}.crt")
# 创建OpenSSL配置文件
openssl_cnf = f"""
[ req ]
default_bits = {key_size}
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
prompt = no
[ req_distinguished_name ]
C = {country}
ST = {state}
L = {locality}
O = {organization}
OU = {organizational_unit}
CN = {common_name}
[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer:always
basicConstraints = CA:TRUE
keyUsage = digitalSignature, keyCertSign, cRLSign
"""
config_path = os.path.join(ca_dir, 'openssl.cnf')
with open(config_path, 'w') as f:
f.write(openssl_cnf.strip())
# 生成CA私钥
subprocess.run([
'openssl', 'genrsa', '-out', key_path, str(key_size)
], check=True)
# 生成CA自签名证书
# 生成CA自签名证书(使用配置文件)
subprocess.run([
'openssl', 'req', '-new', '-x509', '-days', str(days_valid),
'-key', key_path, '-out', cert_path,
'-subj', f'/CN={common_name}/O={organization}/OU={organizational_unit}/C={country}/ST={state}/L={locality}'
'-config', config_path
], check=True)
# 保存到数据库
@ -319,9 +352,11 @@ def create_ca(ca_name, common_name, organization, organizational_unit, country,
def create_certificate(ca_id, common_name, san_dns, san_ip, organization, organizational_unit,
country, state, locality, key_size, days_valid, created_by):
"""创建证书并返回证书ID"""
# 获取CA信息
ca = get_ca_by_id(ca_id)
if not ca:
print(f"CA ID {ca_id} 不存在")
return None
# 创建证书目录
@ -332,12 +367,21 @@ def create_certificate(ca_id, common_name, san_dns, san_ip, organization, organi
csr_path = os.path.join(cert_dir, f"{common_name}.csr")
cert_path = os.path.join(cert_dir, f"{common_name}.crt")
# 生成私钥
subprocess.run([
'openssl', 'genrsa', '-out', key_path, str(key_size)
], check=True)
# 1. 生成私钥
try:
subprocess.run([
'openssl', 'genrsa', '-out', key_path, str(key_size)
], check=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
except subprocess.CalledProcessError as e:
print(f"生成私钥失败: {e.stderr.decode()}")
return None
# 创建CSR配置文件
# 2. 创建CSR配置文件
has_san = bool(san_dns or san_ip)
dns_entries = [dns.strip() for dns in san_dns.split(',') if dns.strip()] if san_dns else []
ip_entries = [ip.strip() for ip in san_ip.split(',') if ip.strip()] if san_ip else []
# 构建CSR配置
csr_config = f"""[req]
default_bits = {key_size}
prompt = no
@ -345,8 +389,6 @@ default_md = sha256
distinguished_name = dn
"""
# 只有在有SAN时才添加扩展部分
has_san = bool(san_dns or san_ip)
if has_san:
csr_config += "req_extensions = req_ext\n"
@ -363,76 +405,108 @@ L = {locality}
if has_san:
csr_config += """
[req_ext]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
subjectAltName = @alt_names
extendedKeyUsage = serverAuth, clientAuth
[alt_names]"""
# 添加DNS SAN条目
if san_dns:
dns_entries = [dns.strip() for dns in san_dns.split(',') if dns.strip()]
for i, dns in enumerate(dns_entries, 1):
csr_config += f"\nDNS.{i} = {dns}"
for i, dns in enumerate(dns_entries, 1):
csr_config += f"\nDNS.{i} = {dns}"
# 添加IP SAN条目
if san_ip:
ip_entries = [ip.strip() for ip in san_ip.split(',') if ip.strip()]
for i, ip in enumerate(ip_entries, 1):
csr_config += f"\nIP.{i} = {ip}"
for i, ip in enumerate(ip_entries, 1):
csr_config += f"\nIP.{i} = {ip}"
# 确保配置文件不以空行结尾
csr_config = csr_config.strip()
# 写入CSR配置文件
csr_config_path = os.path.join(cert_dir, 'csr_config.cnf')
with open(csr_config_path, 'w') as f:
f.write(csr_config.strip()) # 确保没有多余空行
config_path = os.path.join(cert_dir, 'csr_config.cnf')
with open(config_path, 'w') as f:
f.write(csr_config)
# 生成CSR
# 3. 生成CSR
try:
subprocess.run([
'openssl', 'req', '-new', '-key', key_path, '-out', csr_path,
'-config', config_path
], check=True)
'openssl', 'req', '-new', '-key', key_path,
'-out', csr_path, '-config', csr_config_path
], check=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
except subprocess.CalledProcessError as e:
print(f"OpenSSL错误: {e}")
print(f"生成CSR失败: {e.stderr.decode()}")
print("CSR配置文件内容:")
print(csr_config)
return None
# 使用CA签名证书
# 4. 创建证书扩展配置文件
ext_config = """authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature, keyEncipherment
extendedKeyUsage=serverAuth, clientAuth
"""
if has_san:
ext_config += "subjectAltName=@alt_names\n\n[alt_names]\n"
# 添加DNS SAN条目
for i, dns in enumerate(dns_entries, 1):
ext_config += f"DNS.{i} = {dns}\n"
# 添加IP SAN条目
for i, ip in enumerate(ip_entries, 1):
ext_config += f"IP.{i} = {ip}\n"
ext_config_path = os.path.join(cert_dir, 'ext.cnf')
with open(ext_config_path, 'w') as f:
f.write(ext_config.strip())
# 5. 使用CA签名证书
try:
subprocess.run([
'openssl', 'x509', '-req', '-in', csr_path, '-CA', ca['cert_path'],
'-CAkey', ca['key_path'], '-CAcreateserial', '-out', cert_path,
cmd = [
'openssl', 'x509', '-req', '-in', csr_path,
'-CA', ca['cert_path'], '-CAkey', ca['key_path'],
'-CAcreateserial', '-out', cert_path,
'-days', str(days_valid), '-sha256'
], check=True)
]
# 只有有扩展内容时才添加-extfile参数
if os.path.getsize(ext_config_path) > 0:
cmd.extend(['-extfile', ext_config_path])
subprocess.run(cmd, check=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
except subprocess.CalledProcessError as e:
print(f"签名证书错误: {e}")
print(f"证书签名失败: {e.stderr.decode()}")
print("扩展配置文件内容:")
print(ext_config)
return None
# 计算过期时间
# 6. 保存到数据库
expires_at = datetime.now() + timedelta(days=days_valid)
# 保存到数据库
conn = get_db_connection()
if conn:
try:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO certificates
(common_name, san_dns, san_ip, organization, organizational_unit, country, state, locality,
key_size, days_valid, cert_path, key_path, csr_path, ca_id, created_by, expires_at)
(common_name, san_dns, san_ip, organization, organizational_unit,
country, state, locality, key_size, days_valid, cert_path,
key_path, csr_path, ca_id, created_by, expires_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (common_name, san_dns, san_ip, organization, organizational_unit, country, state, locality,
key_size, days_valid, cert_path, key_path, csr_path, ca_id, created_by, expires_at))
""", (
common_name, san_dns, san_ip, organization, organizational_unit,
country, state, locality, key_size, days_valid, cert_path,
key_path, csr_path, ca_id, created_by, expires_at
))
conn.commit()
return cursor.lastrowid
except Error as e:
print(f"Database error: {e}")
print(f"数据库错误: {e}")
conn.rollback()
return None
finally:
if conn.is_connected():
cursor.close()
conn.close()
return None
@ -643,14 +717,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 +743,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)
# 生成新验证码
captcha_url = url_for('captcha') # 使用图片验证码
return render_template('register.html',
captcha_url=captcha_url, # 前端改为显示图片验证码
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('/')
@ -854,8 +1068,8 @@ def login():
cursor.close()
conn.close()
captcha_code = generate_captcha()
return render_template('login.html', captcha_code=captcha_code)
captcha_url = url_for('captcha')
return render_template('login.html', captcha_url=captcha_url)
@app.route('/logout')
@ -866,19 +1080,50 @@ def logout():
return redirect(url_for('login'))
from math import ceil
# 每页显示的数量
PER_PAGE = 10
@app.route('/cas')
@login_required
def ca_list():
page = request.args.get('page', 1, type=int)
conn = get_db_connection()
if conn:
try:
cursor = conn.cursor(dictionary=True)
# 获取总数
if current_user.is_admin:
cursor.execute("SELECT * FROM certificate_authorities")
cursor.execute("SELECT COUNT(*) as total FROM certificate_authorities")
else:
cursor.execute("SELECT * FROM certificate_authorities WHERE created_by = %s", (current_user.id,))
cursor.execute("SELECT COUNT(*) as total FROM certificate_authorities WHERE created_by = %s",
(current_user.id,))
total = cursor.fetchone()['total']
total_pages = ceil(total / PER_PAGE)
# 获取分页数据
offset = (page - 1) * PER_PAGE
if current_user.is_admin:
cursor.execute("SELECT * FROM certificate_authorities ORDER BY created_at DESC LIMIT %s OFFSET %s",
(PER_PAGE, offset))
else:
cursor.execute("""
SELECT * FROM certificate_authorities
WHERE created_by = %s
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""", (current_user.id, PER_PAGE, offset))
cas = cursor.fetchall()
return render_template('ca_list.html', cas=cas, get_username=get_username)
return render_template('ca_list.html',
cas=cas,
page=page,
total_pages=total_pages,
total=total,
get_username=get_username)
except Error as e:
print(f"Database error: {e}")
flash('获取CA列表失败', 'danger')
@ -890,6 +1135,204 @@ def ca_list():
return redirect(url_for('index'))
@app.route('/cas/batch_delete', methods=['POST'])
@login_required
def batch_delete_cas():
if not request.is_json:
return jsonify({'success': False, 'message': 'Invalid request'}), 400
data = request.get_json()
ca_ids = data.get('ids', [])
if not ca_ids:
return jsonify({'success': False, 'message': 'No CAs selected'}), 400
conn = get_db_connection()
if not conn:
return jsonify({'success': False, 'message': 'Database connection failed'}), 500
try:
cursor = conn.cursor()
# 检查权限并删除
for ca_id in ca_ids:
# 验证CA存在且用户有权限
cursor.execute("SELECT created_by FROM certificate_authorities WHERE id = %s", (ca_id,))
ca = cursor.fetchone()
if not ca:
continue
if not current_user.is_admin and ca['created_by'] != current_user.id:
continue
# 检查是否有关联证书
cursor.execute("SELECT COUNT(*) as count FROM certificates WHERE ca_id = %s", (ca_id,))
result = cursor.fetchone()
if result['count'] > 0:
continue
# 获取CA信息以便删除文件
cursor.execute("SELECT cert_path, key_path FROM certificate_authorities WHERE id = %s", (ca_id,))
ca_info = cursor.fetchone()
if ca_info:
# 删除文件
try:
if os.path.exists(ca_info['cert_path']):
os.remove(ca_info['cert_path'])
if os.path.exists(ca_info['key_path']):
os.remove(ca_info['key_path'])
# 删除CA目录
ca_dir = os.path.dirname(ca_info['cert_path'])
if os.path.exists(ca_dir):
shutil.rmtree(ca_dir) # 递归删除目录
except OSError as e:
print(f"文件删除错误: {e}")
continue
# 删除数据库记录
cursor.execute("DELETE FROM certificate_revocation_list WHERE ca_id = %s", (ca_id,))
cursor.execute("DELETE FROM certificate_authorities WHERE id = %s", (ca_id,))
conn.commit()
return jsonify({'success': True, 'message': '批量删除成功'})
except Error as e:
conn.rollback()
print(f"Database error: {e}")
return jsonify({'success': False, 'message': '数据库操作失败'}), 500
finally:
if conn.is_connected():
cursor.close()
conn.close()
@app.route('/certificates')
@login_required
def certificate_list():
page = request.args.get('page', 1, type=int)
conn = get_db_connection()
if conn:
try:
cursor = conn.cursor(dictionary=True)
# 获取总数
if current_user.is_admin:
cursor.execute("SELECT COUNT(*) as total FROM certificates")
else:
cursor.execute("SELECT COUNT(*) as total FROM certificates WHERE created_by = %s", (current_user.id,))
total = cursor.fetchone()['total']
total_pages = ceil(total / PER_PAGE)
# 获取分页数据
offset = (page - 1) * PER_PAGE
if current_user.is_admin:
cursor.execute("""
SELECT c.*, ca.name as ca_name
FROM certificates c
JOIN certificate_authorities ca ON c.ca_id = ca.id
ORDER BY c.created_at DESC
LIMIT %s OFFSET %s
""", (PER_PAGE, offset))
else:
cursor.execute("""
SELECT c.*, ca.name as ca_name
FROM certificates c
JOIN certificate_authorities ca ON c.ca_id = ca.id
WHERE c.created_by = %s
ORDER BY c.created_at DESC
LIMIT %s OFFSET %s
""", (current_user.id, PER_PAGE, offset))
certificates = cursor.fetchall()
return render_template('certificate_list.html',
certificates=certificates,
page=page,
total_pages=total_pages,
total=total,
get_username=get_username)
except Error as e:
print(f"Database error: {e}")
flash('获取证书列表失败', 'danger')
return redirect(url_for('index'))
finally:
if conn.is_connected():
cursor.close()
conn.close()
return redirect(url_for('index'))
@app.route('/certificates/batch_delete', methods=['POST'])
@login_required
def batch_delete_certificates():
if not request.is_json:
return jsonify({'success': False, 'message': 'Invalid request'}), 400
data = request.get_json()
cert_ids = data.get('ids', [])
if not cert_ids:
return jsonify({'success': False, 'message': 'No certificates selected'}), 400
conn = get_db_connection()
if not conn:
return jsonify({'success': False, 'message': 'Database connection failed'}), 500
try:
cursor = conn.cursor(dictionary=True)
# 检查权限并删除
for cert_id in cert_ids:
# 验证证书存在且用户有权限
cursor.execute("SELECT created_by, cert_path, key_path, csr_path FROM certificates WHERE id = %s",
(cert_id,))
cert = cursor.fetchone()
if not cert:
continue
if not current_user.is_admin and cert['created_by'] != current_user.id:
continue
# 删除文件
try:
files_to_delete = [
cert['cert_path'],
cert['key_path'],
cert['csr_path']
]
# 删除所有指定文件
for file_path in files_to_delete:
if file_path and os.path.exists(file_path):
os.remove(file_path)
# 删除证书目录
cert_dir = os.path.dirname(cert['cert_path'])
if os.path.exists(cert_dir):
shutil.rmtree(cert_dir) # 递归删除目录
except OSError as e:
print(f"文件删除错误: {e}")
continue
# 删除数据库记录
cursor.execute("DELETE FROM certificates WHERE id = %s", (cert_id,))
conn.commit()
return jsonify({'success': True, 'message': '批量删除成功'})
except Error as e:
conn.rollback()
print(f"Database error: {e}")
return jsonify({'success': False, 'message': '数据库操作失败'}), 500
finally:
if conn.is_connected():
cursor.close()
conn.close()
@app.route('/cas/create', methods=['GET', 'POST'])
@login_required
def create_ca_view():
@ -944,9 +1387,11 @@ def create_ca_view():
from datetime import timedelta # 确保顶部已导入
@app.route('/cas/<int:ca_id>')
@login_required
def ca_detail(ca_id):
page = request.args.get('page', 1, type=int)
ca = get_ca_by_id(ca_id)
if not ca:
flash('CA不存在', 'danger')
@ -957,16 +1402,28 @@ def ca_detail(ca_id):
flash('无权访问此CA', 'danger')
return redirect(url_for('ca_list'))
# 获取该CA颁发的证书
# 获取该CA颁发的证书(分页)
conn = get_db_connection()
if conn:
try:
cursor = conn.cursor(dictionary=True)
# 获取证书总数
cursor.execute("""
SELECT COUNT(*) as total FROM certificates
WHERE ca_id = %s
""", (ca_id,))
total = cursor.fetchone()['total']
total_pages = ceil(total / PER_PAGE)
# 获取分页数据
offset = (page - 1) * PER_PAGE
cursor.execute("""
SELECT * FROM certificates
WHERE ca_id = %s
ORDER BY created_at DESC
""", (ca_id,))
LIMIT %s OFFSET %s
""", (ca_id, PER_PAGE, offset))
certificates = cursor.fetchall()
# 获取CRL信息
@ -981,8 +1438,11 @@ def ca_detail(ca_id):
ca=ca,
certificates=certificates,
crl=crl,
timedelta=timedelta, # 传递timedelta到模板
get_username=get_username # 确保这个函数已定义
page=page,
total_pages=total_pages,
total=total,
timedelta=timedelta,
get_username=get_username
)
except Error as e:
print(f"Database error: {e}")
@ -1077,41 +1537,6 @@ def download_crl(ca_id):
return redirect(url_for('ca_detail', ca_id=ca_id))
@app.route('/certificates')
@login_required
def certificate_list():
conn = get_db_connection()
if conn:
try:
cursor = conn.cursor(dictionary=True)
if current_user.is_admin:
cursor.execute("""
SELECT c.*, ca.name as ca_name
FROM certificates c
JOIN certificate_authorities ca ON c.ca_id = ca.id
ORDER BY c.created_at DESC
""")
else:
cursor.execute("""
SELECT c.*, ca.name as ca_name
FROM certificates c
JOIN certificate_authorities ca ON c.ca_id = ca.id
WHERE c.created_by = %s
ORDER BY c.created_at DESC
""", (current_user.id,))
certificates = cursor.fetchall()
return render_template('certificate_list.html', certificates=certificates, get_username=get_username)
except Error as e:
print(f"Database error: {e}")
flash('获取证书列表失败', 'danger')
return redirect(url_for('index'))
finally:
if conn.is_connected():
cursor.close()
conn.close()
return redirect(url_for('index'))
@app.route('/certificates/create', methods=['GET', 'POST'])
@login_required
def create_certificate_view():
@ -1421,17 +1846,36 @@ def delete_ca(ca_id):
if request.method == 'POST':
# 删除文件
try:
# 删除CA证书和私钥文件
if os.path.exists(ca['cert_path']):
os.remove(ca['cert_path'])
if os.path.exists(ca['key_path']):
os.remove(ca['key_path'])
# 删除CA目录
# 删除CA目录及其所有内容
ca_dir = os.path.dirname(ca['cert_path'])
if os.path.exists(ca_dir):
os.rmdir(ca_dir)
# 删除目录中的所有文件和子目录
for filename in os.listdir(ca_dir):
file_path = os.path.join(ca_dir, filename)
try:
if os.path.isfile(file_path) or os.path.islink(file_path):
os.unlink(file_path)
elif os.path.isdir(file_path):
shutil.rmtree(file_path)
except Exception as e:
print(f'删除文件/目录失败 {file_path}. 原因: {e}')
flash(f'删除文件/目录失败: {str(e)}', 'warning')
# 现在删除空目录
try:
os.rmdir(ca_dir)
except OSError as e:
print(f"删除目录失败: {e}")
flash(f'删除目录失败: {str(e)}', 'warning')
except OSError as e:
print(f"文件删除错误: {e}")
flash('删除文件时出错', 'danger')
flash(f'删除文件时出错: {str(e)}', 'danger')
return redirect(url_for('ca_detail', ca_id=ca_id))
# 删除数据库记录
@ -1439,12 +1883,16 @@ def delete_ca(ca_id):
if conn:
try:
cursor = conn.cursor()
# 先删除CRL记录
cursor.execute("DELETE FROM certificate_revocation_list WHERE ca_id = %s", (ca_id,))
# 再删除CA记录
cursor.execute("DELETE FROM certificate_authorities WHERE id = %s", (ca_id,))
conn.commit()
flash('CA删除成功', 'success')
return redirect(url_for('ca_list'))
except Error as e:
print(f"Database error: {e}")
conn.rollback()
flash('删除CA记录失败', 'danger')
return redirect(url_for('ca_detail', ca_id=ca_id))
finally:

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
)
""",

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
version: '3.8'
services:
tiku_bm:
image: certmanager:latest
container_name: certmanager
ports:
- "5002:5000"
environment:
- FLASK_ENV=production
volumes:
- ./data:/app/data
restart: unless-stopped

View File

@ -3,6 +3,6 @@ Flask-Login==0.5.0
mysql-connector-python==8.0.26
python-dotenv==0.19.0
cryptography==2.0
pypinyin
shutil
flask_migrate
pypinyin==0.51.0
flask_migrate==4.0.7
flask_mail==0.10.0

BIN
screenshots/ca_detail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
screenshots/cert_detail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
screenshots/index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
static/arial.ttf Normal file

Binary file not shown.

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

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>证书管理系统 - {% block title %}{% endblock %}</title>
<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">版本 7.1.0</p>
</small>
</div>
</footer>

View File

@ -112,11 +112,12 @@
</div>
</div>
<!-- 证书列表部分 -->
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="fas fa-certificate text-primary me-2"></i> 颁发的证书
<span class="badge bg-primary rounded-pill ms-2">{{ certificates|length }}</span>
<span class="badge bg-primary rounded-pill ms-2">{{ total }}</span>
</h5>
<div>
<a href="{{ url_for('create_certificate_view') }}?ca_id={{ ca.id }}"
@ -137,6 +138,7 @@
<th>通用名</th>
<th>状态</th>
<th>有效期至</th>
<th>创建者</th>
<th>创建时间</th>
<th class="pe-4">操作</th>
</tr>
@ -166,29 +168,56 @@
{% endif %}
</td>
<td>{{ cert.expires_at.strftime('%Y-%m-%d') }}</td>
<td>{{ get_username(cert.created_by) }}</td>
<td>{{ cert.created_at.strftime('%Y-%m-%d') }}</td>
<td class="pe-4">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('certificate_detail', cert_id=cert.id) }}"
class="btn btn-outline-primary"
data-bs-toggle="tooltip"
title="查看证书详情">
<i class="fas fa-eye me-1"></i> 详情
</a>
<a href="{{ url_for('export_certificate_view', cert_id=cert.id) }}"
class="btn btn-outline-success"
data-bs-toggle="tooltip"
title="导出证书">
<i class="fas fa-download me-1"></i> 导出
</a>
</div>
</td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('certificate_detail', cert_id=cert.id) }}"
class="btn btn-outline-primary"
data-bs-toggle="tooltip"
title="查看证书详情">
<i class="fas fa-eye me-1"></i> 详情
</a>
<a href="{{ url_for('export_certificate_view', cert_id=cert.id) }}"
class="btn btn-outline-success"
data-bs-toggle="tooltip"
title="导出证书">
<i class="fas fa-download me-1"></i> 导出
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="card-footer bg-white">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('ca_detail', ca_id=ca.id, page=page-1) }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('ca_detail', ca_id=ca.id, page=p) }}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('ca_detail', ca_id=ca.id, page=page+1) }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-certificate fa-4x text-muted mb-4"></i>

View File

@ -7,12 +7,17 @@
<div>
<h2 class="mb-1">CA机构列表</h2>
<div class="text-muted fs-6">
<i class="fas fa-shield-alt me-1"></i>共 {{ cas|length }} 个CA机构
<i class="fas fa-shield-alt me-1"></i>共 {{ total }} 个CA机构
</div>
</div>
<a href="{{ url_for('create_ca_view') }}" class="btn btn-primary btn-sm text-nowrap">
<i class="fas fa-plus me-1"></i> 创建CA机构
</a>
<div>
<button id="batchDeleteBtn" class="btn btn-danger btn-sm me-2" disabled>
<i class="fas fa-trash-alt me-1"></i> 批量删除
</button>
<a href="{{ url_for('create_ca_view') }}" class="btn btn-primary btn-sm text-nowrap">
<i class="fas fa-plus me-1"></i> 创建CA机构
</a>
</div>
</div>
<div class="card border-0 shadow-sm">
@ -21,7 +26,10 @@
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-4">ID</th>
<th width="40" class="ps-4">
<input type="checkbox" id="selectAll" class="form-check-input">
</th>
<th>ID</th>
<th>名称</th>
<th>通用名</th>
<th>组织</th>
@ -34,7 +42,10 @@
<tbody>
{% for ca in cas %}
<tr>
<td class="ps-4">{{ ca.id }}</td>
<td class="ps-4">
<input type="checkbox" class="form-check-input ca-checkbox" value="{{ ca.id }}">
</td>
<td>{{ ca.id }}</td>
<td>
<a href="{{ url_for('ca_detail', ca_id=ca.id) }}" class="text-decoration-none">
{{ ca.name }}
@ -56,7 +67,7 @@
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center py-4">
<td colspan="9" class="text-center py-4">
<i class="fas fa-shield-alt fa-3x text-muted mb-3"></i>
<p class="text-muted">暂无CA机构记录</p>
<a href="{{ url_for('create_ca_view') }}" class="btn btn-primary btn-sm">
@ -68,6 +79,107 @@
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="card-footer bg-white">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('ca_list', page=page-1) }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('ca_list', page=p) }}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('ca_list', page=page+1) }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// 启用工具提示
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
// 全选/取消全选
const selectAll = document.getElementById('selectAll')
const checkboxes = document.querySelectorAll('.ca-checkbox')
const batchDeleteBtn = document.getElementById('batchDeleteBtn')
selectAll.addEventListener('change', function() {
checkboxes.forEach(checkbox => {
checkbox.checked = selectAll.checked
})
updateBatchDeleteBtn()
})
// 单个复选框变化时更新全选状态
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
selectAll.checked = [...checkboxes].every(cb => cb.checked)
updateBatchDeleteBtn()
})
})
// 更新批量删除按钮状态
function updateBatchDeleteBtn() {
const checkedCount = document.querySelectorAll('.ca-checkbox:checked').length
batchDeleteBtn.disabled = checkedCount === 0
}
// 批量删除
batchDeleteBtn.addEventListener('click', function() {
const checkedBoxes = document.querySelectorAll('.ca-checkbox:checked')
const ids = Array.from(checkedBoxes).map(checkbox => checkbox.value)
if (ids.length === 0) {
return
}
if (!confirm(`确定要删除选中的 ${ids.length} 个CA机构吗`)) {
return
}
fetch("{{ url_for('batch_delete_cas') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message)
window.location.reload()
} else {
alert(data.message)
}
})
.catch(error => {
console.error('Error:', error)
alert('操作失败,请稍后再试')
})
})
})
</script>
{% endblock %}

View File

@ -7,12 +7,17 @@
<div>
<h2 class="mb-1">证书列表</h2>
<div class="text-muted fs-6">
<i class="fas fa-certificate me-1"></i>共 {{ certificates|length }} 个证书
<i class="fas fa-certificate me-1"></i>共 {{ total }} 个证书
</div>
</div>
<a href="{{ url_for('create_certificate_view') }}" class="btn btn-primary btn-sm text-nowrap">
<i class="fas fa-plus me-1"></i> 创建证书
</a>
<div>
<button id="batchDeleteBtn" class="btn btn-danger btn-sm me-2" disabled>
<i class="fas fa-trash-alt me-1"></i> 批量删除
</button>
<a href="{{ url_for('create_certificate_view') }}" class="btn btn-primary btn-sm text-nowrap">
<i class="fas fa-plus me-1"></i> 创建证书
</a>
</div>
</div>
<div class="card border-0 shadow-sm">
@ -21,11 +26,15 @@
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-4">ID</th>
<th width="40" class="ps-4">
<input type="checkbox" id="selectAll" class="form-check-input">
</th>
<th>ID</th>
<th>通用名</th>
<th>CA机构</th>
<th>状态</th>
<th>有效期至</th>
<th>创建者</th>
<th>创建时间</th>
<th class="pe-4">操作</th>
</tr>
@ -33,7 +42,10 @@
<tbody>
{% for cert in certificates %}
<tr>
<td class="ps-4">{{ cert.id }}</td>
<td class="ps-4">
<input type="checkbox" class="form-check-input cert-checkbox" value="{{ cert.id }}">
</td>
<td>{{ cert.id }}</td>
<td>
<a href="{{ url_for('certificate_detail', cert_id=cert.id) }}" class="text-decoration-none">
{{ cert.common_name }}
@ -56,6 +68,7 @@
{% endif %}
</td>
<td>{{ cert.expires_at.strftime('%Y-%m-%d') }}</td>
<td>{{ get_username(cert.created_by) }}</td>
<td>{{ cert.created_at.strftime('%Y-%m-%d') }}</td>
<td class="pe-4">
<div class="btn-group btn-group-sm">
@ -84,7 +97,7 @@
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center py-4">
<td colspan="8" class="text-center py-4">
<i class="fas fa-certificate fa-3x text-muted mb-3"></i>
<p class="text-muted">暂无证书记录</p>
<a href="{{ url_for('create_certificate_view') }}" class="btn btn-primary btn-sm">
@ -96,6 +109,32 @@
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="card-footer bg-white">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('certificate_list', page=page-1) }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% for p in range(1, total_pages + 1) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('certificate_list', page=p) }}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('certificate_list', page=page+1) }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@ -103,12 +142,74 @@
{% block scripts %}
{{ super() }}
<script>
// 启用工具提示
document.addEventListener('DOMContentLoaded', function() {
// 启用工具提示
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
// 全选/取消全选
const selectAll = document.getElementById('selectAll')
const checkboxes = document.querySelectorAll('.cert-checkbox')
const batchDeleteBtn = document.getElementById('batchDeleteBtn')
selectAll.addEventListener('change', function() {
checkboxes.forEach(checkbox => {
checkbox.checked = selectAll.checked
})
updateBatchDeleteBtn()
})
// 单个复选框变化时更新全选状态
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
selectAll.checked = [...checkboxes].every(cb => cb.checked)
updateBatchDeleteBtn()
})
})
// 更新批量删除按钮状态
function updateBatchDeleteBtn() {
const checkedCount = document.querySelectorAll('.cert-checkbox:checked').length
batchDeleteBtn.disabled = checkedCount === 0
}
// 批量删除
batchDeleteBtn.addEventListener('click', function() {
const checkedBoxes = document.querySelectorAll('.cert-checkbox:checked')
const ids = Array.from(checkedBoxes).map(checkbox => checkbox.value)
if (ids.length === 0) {
return
}
if (!confirm(`确定要删除选中的 ${ids.length} 个证书吗?`)) {
return
}
fetch("{{ url_for('batch_delete_certificates') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message)
window.location.reload()
} else {
alert(data.message)
}
})
.catch(error => {
console.error('Error:', error)
alert('操作失败,请稍后再试')
})
})
})
</script>
{% endblock %}

View File

@ -8,7 +8,7 @@
<h4 class="card-title">创建新证书</h4>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('create_certificate_view') }}">
<form method="POST" action="{{ url_for('create_certificate_view') }}" id="certificateForm">
<div class="row mb-3">
<div class="col-md-6">
<label for="common_name" class="form-label">通用名(CN)</label>
@ -57,12 +57,24 @@
<div class="row mb-3">
<div class="col-md-6">
<label for="san_dns" class="form-label">SAN DNS (可选)</label>
<label for="san_dns" class="form-label">
SAN DNS (可选)
<span class="text-primary ms-1" data-bs-toggle="tooltip"
title="Subject Alternative Name - 主题备用名称,用于指定证书可用的多个域名">
<i class="fas fa-question-circle"></i>
</span>
</label>
<input type="text" class="form-control" id="san_dns" name="san_dns">
<div class="form-text">多个DNS用逗号分隔如: example.com,www.example.com</div>
</div>
<div class="col-md-6">
<label for="san_ip" class="form-label">SAN IP (可选)</label>
<label for="san_ip" class="form-label">
SAN IP (可选)
<span class="text-primary ms-1" data-bs-toggle="tooltip"
title="Subject Alternative Name - 主题备用名称用于指定证书可用的多个IP地址">
<i class="fas fa-question-circle"></i>
</span>
</label>
<input type="text" class="form-control" id="san_ip" name="san_ip">
<div class="form-text">多个IP用逗号分隔如: 192.168.1.1,10.0.0.1</div>
</div>
@ -90,4 +102,65 @@
</form>
</div>
</div>
<!-- SAN 警告模态框 -->
<div class="modal fade" id="sanWarningModal" tabindex="-1" aria-labelledby="sanWarningModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white"> <!-- 修改了这一行添加了bg-danger和text-white类 -->
<h5 class="modal-title" id="sanWarningModalLabel">缺少SAN扩展警告</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>您没有配置任何SAN(主题备用名称)扩展,这可能导致以下问题:</p>
<ul>
<li>现代浏览器可能不信任没有SAN扩展的证书</li>
<li>证书只能通过Common Name(CN)字段指定的名称访问</li>
<li>不符合现代安全标准</li>
</ul>
<p>建议至少配置一个DNS SAN或IP SAN。</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">返回修改</button>
<button type="button" class="btn btn-primary" id="continueWithoutSan">继续创建</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
// 初始化工具提示
document.addEventListener('DOMContentLoaded', function() {
// 初始化Bootstrap工具提示
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
// 表单提交验证
document.getElementById('certificateForm').addEventListener('submit', function(e) {
const sanDns = document.getElementById('san_dns').value.trim()
const sanIp = document.getElementById('san_ip').value.trim()
// 检查是否没有配置任何SAN
if (!sanDns && !sanIp) {
e.preventDefault() // 阻止表单提交
// 显示警告模态框
var sanWarningModal = new bootstrap.Modal(document.getElementById('sanWarningModal'))
sanWarningModal.show()
// 继续创建按钮事件
document.getElementById('continueWithoutSan').addEventListener('click', function() {
sanWarningModal.hide()
document.getElementById('certificateForm').submit()
})
}
})
})
</script>
{% endblock %}

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 %}