Compare commits

...

27 Commits

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
24 changed files with 956 additions and 238 deletions

View File

@ -12,7 +12,7 @@ DB_PORT=3306 # 数据库端口MySQL默认3306
# [应用配置] # [应用配置]
APP_HOST=0.0.0.0 # 应用监听地址0.0.0.0表示允许所有IP访问 APP_HOST=0.0.0.0 # 应用监听地址0.0.0.0表示允许所有IP访问
APP_PORT=9875 # 应用监听端口 APP_PORT=5000 # 应用监听端口
DEBUG=False # 调试模式(生产环境必须关闭) DEBUG=False # 调试模式(生产环境必须关闭)
APP_DOMAIN=localhost # 应用对外域名(用于邮件链接生成) APP_DOMAIN=localhost # 应用对外域名(用于邮件链接生成)
APP_PROTOCOL=http # 应用协议http或https APP_PROTOCOL=http # 应用协议http或https

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.

523
app.py
View File

@ -198,37 +198,24 @@ def get_db_connection():
return None 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): def verify_captcha(user_input):
"""验证用户输入的验证码是否正确只验证最新的4位验证码"""
conn = get_db_connection() conn = get_db_connection()
if conn: if conn:
try: try:
cursor = conn.cursor() 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() result = cursor.fetchone()
if result and user_input.upper() == result[0]: if result and user_input.upper() == result[0]:
# 验证成功后删除已使用的验证码
cursor.execute("DELETE FROM captcha WHERE code = %s", (result[0],))
conn.commit()
return True return True
return False return False
except Error as e: except Error as e:
@ -300,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") key_path = os.path.join(ca_dir, f"{common_name}.key")
cert_path = os.path.join(ca_dir, f"{common_name}.crt") 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私钥 # 生成CA私钥
subprocess.run([ subprocess.run([
'openssl', 'genrsa', '-out', key_path, str(key_size) 'openssl', 'genrsa', '-out', key_path, str(key_size)
], check=True) ], check=True)
# 生成CA自签名证书 # 生成CA自签名证书(使用配置文件)
subprocess.run([ subprocess.run([
'openssl', 'req', '-new', '-x509', '-days', str(days_valid), 'openssl', 'req', '-new', '-x509', '-days', str(days_valid),
'-key', key_path, '-out', cert_path, '-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) ], check=True)
# 保存到数据库 # 保存到数据库
@ -338,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, def create_certificate(ca_id, common_name, san_dns, san_ip, organization, organizational_unit,
country, state, locality, key_size, days_valid, created_by): country, state, locality, key_size, days_valid, created_by):
"""创建证书并返回证书ID"""
# 获取CA信息 # 获取CA信息
ca = get_ca_by_id(ca_id) ca = get_ca_by_id(ca_id)
if not ca: if not ca:
print(f"CA ID {ca_id} 不存在")
return None return None
# 创建证书目录 # 创建证书目录
@ -351,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") csr_path = os.path.join(cert_dir, f"{common_name}.csr")
cert_path = os.path.join(cert_dir, f"{common_name}.crt") cert_path = os.path.join(cert_dir, f"{common_name}.crt")
# 生成私钥 # 1. 生成私钥
subprocess.run([ try:
'openssl', 'genrsa', '-out', key_path, str(key_size) subprocess.run([
], check=True) '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] csr_config = f"""[req]
default_bits = {key_size} default_bits = {key_size}
prompt = no prompt = no
@ -364,8 +389,6 @@ default_md = sha256
distinguished_name = dn distinguished_name = dn
""" """
# 只有在有SAN时才添加扩展部分
has_san = bool(san_dns or san_ip)
if has_san: if has_san:
csr_config += "req_extensions = req_ext\n" csr_config += "req_extensions = req_ext\n"
@ -382,76 +405,108 @@ L = {locality}
if has_san: if has_san:
csr_config += """ csr_config += """
[req_ext] [req_ext]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
subjectAltName = @alt_names subjectAltName = @alt_names
extendedKeyUsage = serverAuth, clientAuth
[alt_names]""" [alt_names]"""
# 添加DNS SAN条目 # 添加DNS SAN条目
if san_dns: for i, dns in enumerate(dns_entries, 1):
dns_entries = [dns.strip() for dns in san_dns.split(',') if dns.strip()] csr_config += f"\nDNS.{i} = {dns}"
for i, dns in enumerate(dns_entries, 1):
csr_config += f"\nDNS.{i} = {dns}"
# 添加IP SAN条目 # 添加IP SAN条目
if san_ip: for i, ip in enumerate(ip_entries, 1):
ip_entries = [ip.strip() for ip in san_ip.split(',') if ip.strip()] csr_config += f"\nIP.{i} = {ip}"
for i, ip in enumerate(ip_entries, 1):
csr_config += f"\nIP.{i} = {ip}"
# 确保配置文件不以空行结尾 # 写入CSR配置文件
csr_config = csr_config.strip() 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') # 3. 生成CSR
with open(config_path, 'w') as f:
f.write(csr_config)
# 生成CSR
try: try:
subprocess.run([ subprocess.run([
'openssl', 'req', '-new', '-key', key_path, '-out', csr_path, 'openssl', 'req', '-new', '-key', key_path,
'-config', config_path '-out', csr_path, '-config', csr_config_path
], check=True) ], check=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
print(f"OpenSSL错误: {e}") print(f"生成CSR失败: {e.stderr.decode()}")
print("CSR配置文件内容:") print("CSR配置文件内容:")
print(csr_config) print(csr_config)
return None 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: try:
subprocess.run([ cmd = [
'openssl', 'x509', '-req', '-in', csr_path, '-CA', ca['cert_path'], 'openssl', 'x509', '-req', '-in', csr_path,
'-CAkey', ca['key_path'], '-CAcreateserial', '-out', cert_path, '-CA', ca['cert_path'], '-CAkey', ca['key_path'],
'-CAcreateserial', '-out', cert_path,
'-days', str(days_valid), '-sha256' '-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: except subprocess.CalledProcessError as e:
print(f"签名证书错误: {e}") print(f"证书签名失败: {e.stderr.decode()}")
print("扩展配置文件内容:")
print(ext_config)
return None return None
# 计算过期时间 # 6. 保存到数据库
expires_at = datetime.now() + timedelta(days=days_valid) expires_at = datetime.now() + timedelta(days=days_valid)
# 保存到数据库
conn = get_db_connection() conn = get_db_connection()
if conn: if conn:
try: try:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
INSERT INTO certificates INSERT INTO certificates
(common_name, san_dns, san_ip, organization, organizational_unit, country, state, locality, (common_name, san_dns, san_ip, organization, organizational_unit,
key_size, days_valid, cert_path, key_path, csr_path, ca_id, created_by, expires_at) 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) 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() conn.commit()
return cursor.lastrowid return cursor.lastrowid
except Error as e: except Error as e:
print(f"Database error: {e}") print(f"数据库错误: {e}")
conn.rollback()
return None return None
finally: finally:
if conn.is_connected(): if conn.is_connected():
cursor.close() cursor.close()
conn.close() conn.close()
return None return None
@ -791,11 +846,11 @@ def register():
conn.close() conn.close()
# 生成新验证码 # 生成新验证码
captcha_code = generate_captcha() captcha_url = url_for('captcha') # 使用图片验证码
return render_template('register.html', return render_template('register.html',
captcha_code=captcha_code, captcha_url=captcha_url, # 前端改为显示图片验证码
registration_open=current_app.config['REGISTRATION_OPEN'], registration_open=current_app.config['REGISTRATION_OPEN'],
email_required=current_app.config['EMAIL_VERIFICATION_REQUIRED']) email_required=current_app.config['EMAIL_VERIFICATION_REQUIRED'])
@app.route('/verify-email/<token>') @app.route('/verify-email/<token>')
@ -1013,8 +1068,8 @@ def login():
cursor.close() cursor.close()
conn.close() conn.close()
captcha_code = generate_captcha() captcha_url = url_for('captcha')
return render_template('login.html', captcha_code=captcha_code) return render_template('login.html', captcha_url=captcha_url)
@app.route('/logout') @app.route('/logout')
@ -1025,19 +1080,50 @@ def logout():
return redirect(url_for('login')) return redirect(url_for('login'))
from math import ceil
# 每页显示的数量
PER_PAGE = 10
@app.route('/cas') @app.route('/cas')
@login_required @login_required
def ca_list(): def ca_list():
page = request.args.get('page', 1, type=int)
conn = get_db_connection() conn = get_db_connection()
if conn: if conn:
try: try:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
# 获取总数
if current_user.is_admin: if current_user.is_admin:
cursor.execute("SELECT * FROM certificate_authorities") cursor.execute("SELECT COUNT(*) as total FROM certificate_authorities")
else: 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() 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: except Error as e:
print(f"Database error: {e}") print(f"Database error: {e}")
flash('获取CA列表失败', 'danger') flash('获取CA列表失败', 'danger')
@ -1049,6 +1135,204 @@ def ca_list():
return redirect(url_for('index')) 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']) @app.route('/cas/create', methods=['GET', 'POST'])
@login_required @login_required
def create_ca_view(): def create_ca_view():
@ -1103,9 +1387,11 @@ def create_ca_view():
from datetime import timedelta # 确保顶部已导入 from datetime import timedelta # 确保顶部已导入
@app.route('/cas/<int:ca_id>') @app.route('/cas/<int:ca_id>')
@login_required @login_required
def ca_detail(ca_id): def ca_detail(ca_id):
page = request.args.get('page', 1, type=int)
ca = get_ca_by_id(ca_id) ca = get_ca_by_id(ca_id)
if not ca: if not ca:
flash('CA不存在', 'danger') flash('CA不存在', 'danger')
@ -1116,16 +1402,28 @@ def ca_detail(ca_id):
flash('无权访问此CA', 'danger') flash('无权访问此CA', 'danger')
return redirect(url_for('ca_list')) return redirect(url_for('ca_list'))
# 获取该CA颁发的证书 # 获取该CA颁发的证书(分页)
conn = get_db_connection() conn = get_db_connection()
if conn: if conn:
try: try:
cursor = conn.cursor(dictionary=True) 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(""" cursor.execute("""
SELECT * FROM certificates SELECT * FROM certificates
WHERE ca_id = %s WHERE ca_id = %s
ORDER BY created_at DESC ORDER BY created_at DESC
""", (ca_id,)) LIMIT %s OFFSET %s
""", (ca_id, PER_PAGE, offset))
certificates = cursor.fetchall() certificates = cursor.fetchall()
# 获取CRL信息 # 获取CRL信息
@ -1140,8 +1438,11 @@ def ca_detail(ca_id):
ca=ca, ca=ca,
certificates=certificates, certificates=certificates,
crl=crl, crl=crl,
timedelta=timedelta, # 传递timedelta到模板 page=page,
get_username=get_username # 确保这个函数已定义 total_pages=total_pages,
total=total,
timedelta=timedelta,
get_username=get_username
) )
except Error as e: except Error as e:
print(f"Database error: {e}") print(f"Database error: {e}")
@ -1236,41 +1537,6 @@ def download_crl(ca_id):
return redirect(url_for('ca_detail', ca_id=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']) @app.route('/certificates/create', methods=['GET', 'POST'])
@login_required @login_required
def create_certificate_view(): def create_certificate_view():
@ -1580,17 +1846,36 @@ def delete_ca(ca_id):
if request.method == 'POST': if request.method == 'POST':
# 删除文件 # 删除文件
try: try:
# 删除CA证书和私钥文件
if os.path.exists(ca['cert_path']): if os.path.exists(ca['cert_path']):
os.remove(ca['cert_path']) os.remove(ca['cert_path'])
if os.path.exists(ca['key_path']): if os.path.exists(ca['key_path']):
os.remove(ca['key_path']) os.remove(ca['key_path'])
# 删除CA目录
# 删除CA目录及其所有内容
ca_dir = os.path.dirname(ca['cert_path']) ca_dir = os.path.dirname(ca['cert_path'])
if os.path.exists(ca_dir): 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: except OSError as e:
print(f"文件删除错误: {e}") print(f"文件删除错误: {e}")
flash('删除文件时出错', 'danger') flash(f'删除文件时出错: {str(e)}', 'danger')
return redirect(url_for('ca_detail', ca_id=ca_id)) return redirect(url_for('ca_detail', ca_id=ca_id))
# 删除数据库记录 # 删除数据库记录
@ -1598,12 +1883,16 @@ def delete_ca(ca_id):
if conn: if conn:
try: try:
cursor = conn.cursor() 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,)) cursor.execute("DELETE FROM certificate_authorities WHERE id = %s", (ca_id,))
conn.commit() conn.commit()
flash('CA删除成功', 'success') flash('CA删除成功', 'success')
return redirect(url_for('ca_list')) return redirect(url_for('ca_list'))
except Error as e: except Error as e:
print(f"Database error: {e}") print(f"Database error: {e}")
conn.rollback()
flash('删除CA记录失败', 'danger') flash('删除CA记录失败', 'danger')
return redirect(url_for('ca_detail', ca_id=ca_id)) return redirect(url_for('ca_detail', ca_id=ca_id))
finally: finally:

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

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

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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 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://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"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" rel="stylesheet">
@ -139,7 +139,7 @@
<div class="container text-center text-muted"> <div class="container text-center text-muted">
<small> <small>
<p class="mb-1">自签证书管理系统 &copy; {{ now.year }} - 基于Flask构建</p> <p class="mb-1">自签证书管理系统 &copy; {{ now.year }} - 基于Flask构建</p>
<p class="mb-0">版本 6.0.0</p> <p class="mb-0">版本 7.1.0</p>
</small> </small>
</div> </div>
</footer> </footer>

View File

@ -112,11 +112,12 @@
</div> </div>
</div> </div>
<!-- 证书列表部分 -->
<div class="card"> <div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center"> <div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
<i class="fas fa-certificate text-primary me-2"></i> 颁发的证书 <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> </h5>
<div> <div>
<a href="{{ url_for('create_certificate_view') }}?ca_id={{ ca.id }}" <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>有效期至</th>
<th>创建者</th>
<th>创建时间</th> <th>创建时间</th>
<th class="pe-4">操作</th> <th class="pe-4">操作</th>
</tr> </tr>
@ -166,29 +168,56 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ cert.expires_at.strftime('%Y-%m-%d') }}</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>{{ cert.created_at.strftime('%Y-%m-%d') }}</td>
<td class="pe-4"> <td class="pe-4">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<a href="{{ url_for('certificate_detail', cert_id=cert.id) }}" <a href="{{ url_for('certificate_detail', cert_id=cert.id) }}"
class="btn btn-outline-primary" class="btn btn-outline-primary"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
title="查看证书详情"> title="查看证书详情">
<i class="fas fa-eye me-1"></i> 详情 <i class="fas fa-eye me-1"></i> 详情
</a> </a>
<a href="{{ url_for('export_certificate_view', cert_id=cert.id) }}" <a href="{{ url_for('export_certificate_view', cert_id=cert.id) }}"
class="btn btn-outline-success" class="btn btn-outline-success"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
title="导出证书"> title="导出证书">
<i class="fas fa-download me-1"></i> 导出 <i class="fas fa-download me-1"></i> 导出
</a> </a>
</div> </div>
</td>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </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 %} {% else %}
<div class="text-center py-5"> <div class="text-center py-5">
<i class="fas fa-certificate fa-4x text-muted mb-4"></i> <i class="fas fa-certificate fa-4x text-muted mb-4"></i>

View File

@ -7,12 +7,17 @@
<div> <div>
<h2 class="mb-1">CA机构列表</h2> <h2 class="mb-1">CA机构列表</h2>
<div class="text-muted fs-6"> <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>
</div> </div>
<a href="{{ url_for('create_ca_view') }}" class="btn btn-primary btn-sm text-nowrap"> <div>
<i class="fas fa-plus me-1"></i> 创建CA机构 <button id="batchDeleteBtn" class="btn btn-danger btn-sm me-2" disabled>
</a> <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>
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
@ -21,7 +26,10 @@
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <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> <th>通用名</th>
<th>组织</th> <th>组织</th>
@ -34,7 +42,10 @@
<tbody> <tbody>
{% for ca in cas %} {% for ca in cas %}
<tr> <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> <td>
<a href="{{ url_for('ca_detail', ca_id=ca.id) }}" class="text-decoration-none"> <a href="{{ url_for('ca_detail', ca_id=ca.id) }}" class="text-decoration-none">
{{ ca.name }} {{ ca.name }}
@ -56,7 +67,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <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> <i class="fas fa-shield-alt fa-3x text-muted mb-3"></i>
<p class="text-muted">暂无CA机构记录</p> <p class="text-muted">暂无CA机构记录</p>
<a href="{{ url_for('create_ca_view') }}" class="btn btn-primary btn-sm"> <a href="{{ url_for('create_ca_view') }}" class="btn btn-primary btn-sm">
@ -68,6 +79,107 @@
</tbody> </tbody>
</table> </table>
</div> </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>
</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 %} {% endblock %}

View File

@ -7,12 +7,17 @@
<div> <div>
<h2 class="mb-1">证书列表</h2> <h2 class="mb-1">证书列表</h2>
<div class="text-muted fs-6"> <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>
</div> </div>
<a href="{{ url_for('create_certificate_view') }}" class="btn btn-primary btn-sm text-nowrap"> <div>
<i class="fas fa-plus me-1"></i> 创建证书 <button id="batchDeleteBtn" class="btn btn-danger btn-sm me-2" disabled>
</a> <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>
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
@ -21,11 +26,15 @@
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <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>CA机构</th> <th>CA机构</th>
<th>状态</th> <th>状态</th>
<th>有效期至</th> <th>有效期至</th>
<th>创建者</th>
<th>创建时间</th> <th>创建时间</th>
<th class="pe-4">操作</th> <th class="pe-4">操作</th>
</tr> </tr>
@ -33,7 +42,10 @@
<tbody> <tbody>
{% for cert in certificates %} {% for cert in certificates %}
<tr> <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> <td>
<a href="{{ url_for('certificate_detail', cert_id=cert.id) }}" class="text-decoration-none"> <a href="{{ url_for('certificate_detail', cert_id=cert.id) }}" class="text-decoration-none">
{{ cert.common_name }} {{ cert.common_name }}
@ -56,6 +68,7 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ cert.expires_at.strftime('%Y-%m-%d') }}</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>{{ cert.created_at.strftime('%Y-%m-%d') }}</td>
<td class="pe-4"> <td class="pe-4">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
@ -84,7 +97,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <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> <i class="fas fa-certificate fa-3x text-muted mb-3"></i>
<p class="text-muted">暂无证书记录</p> <p class="text-muted">暂无证书记录</p>
<a href="{{ url_for('create_certificate_view') }}" class="btn btn-primary btn-sm"> <a href="{{ url_for('create_certificate_view') }}" class="btn btn-primary btn-sm">
@ -96,6 +109,32 @@
</tbody> </tbody>
</table> </table>
</div> </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>
</div> </div>
{% endblock %} {% endblock %}
@ -103,12 +142,74 @@
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script> <script>
// 启用工具提示
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// 启用工具提示
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(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> </script>
{% endblock %} {% endblock %}

View File

@ -8,7 +8,7 @@
<h4 class="card-title">创建新证书</h4> <h4 class="card-title">创建新证书</h4>
</div> </div>
<div class="card-body"> <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="row mb-3">
<div class="col-md-6"> <div class="col-md-6">
<label for="common_name" class="form-label">通用名(CN)</label> <label for="common_name" class="form-label">通用名(CN)</label>
@ -57,12 +57,24 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-6"> <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"> <input type="text" class="form-control" id="san_dns" name="san_dns">
<div class="form-text">多个DNS用逗号分隔如: example.com,www.example.com</div> <div class="form-text">多个DNS用逗号分隔如: example.com,www.example.com</div>
</div> </div>
<div class="col-md-6"> <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"> <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 class="form-text">多个IP用逗号分隔如: 192.168.1.1,10.0.0.1</div>
</div> </div>
@ -90,4 +102,65 @@
</form> </form>
</div> </div>
</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 %} {% endblock %}