Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f64e7b18b0 | |||
| 9c92be13d9 | |||
| 89cb0af79a | |||
| 9cbb6b022c | |||
| 375816df87 | |||
| 2b36de569d | |||
| 21142a6104 | |||
| f3a2f05b1c | |||
| 722d88a017 | |||
| 179400f9ed | |||
| cc2b18ba80 | |||
| fc3801f0f2 | |||
| c2ca546955 | |||
| f711ca8280 | |||
| 6993679971 | |||
| 2ae605e60b | |||
| 117a7885d7 | |||
| 5aeaab5747 | |||
| 35eea7a803 | |||
| 0001c8f0d3 | |||
| 5c2381c3a8 | |||
| 25add10b35 | |||
| 6f578be53e | |||
| c5e8fd6e3f | |||
| 47f0c572b3 | |||
| afa61f7a04 | |||
| 35165bd58b |
@ -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
BIN
.gitignore
vendored
Normal file
Binary file not shown.
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@ -1,8 +0,0 @@
|
|||||||
# 默认忽略的文件
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# 基于编辑器的 HTTP 客户端请求
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
8
.idea/certmgr.iml
generated
8
.idea/certmgr.iml
generated
@ -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
28
.idea/deployment.xml
generated
@ -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>
|
|
||||||
16
.idea/inspectionProfiles/Project_Default.xml
generated
16
.idea/inspectionProfiles/Project_Default.xml
generated
@ -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>
|
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
6
.idea/inspectionProfiles/profiles_settings.xml
generated
@ -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
4
.idea/misc.xml
generated
@ -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
8
.idea/modules.xml
generated
@ -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
6
.idea/vcs.xml
generated
@ -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
33
Dockerfile
Normal 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
21
LICENSE
Normal 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
131
README.md
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# 自签名证书管理系统
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
<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
523
app.py
@ -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
13
docker-compose.yml
Normal 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
BIN
screenshots/ca_detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
screenshots/cert_detail.png
Normal file
BIN
screenshots/cert_detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
screenshots/index.png
Normal file
BIN
screenshots/index.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@ -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">自签证书管理系统 © {{ now.year }} - 基于Flask构建</p>
|
<p class="mb-1">自签证书管理系统 © {{ 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>
|
||||||
|
|||||||
@ -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">«</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">»</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>
|
||||||
|
|||||||
@ -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">«</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">»</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 %}
|
||||||
@ -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">«</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">»</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 %}
|
||||||
@ -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 %}
|
||||||
Loading…
x
Reference in New Issue
Block a user