Appearance
六、安全设计 (Security)
安全不是一个独立的功能模块,而是贯穿整个系统每一层的设计原则。本章从传输层到应用层,逐层说明 TodoApp 的安全防护措施。其中最核心的**内容端对端加密(E2EE)**见本章 6.9 概述与 第十章 的完整设计。
6.1 传输层:全链路 HTTPS
所有客户端与服务端之间的通信,包括 REST 请求和 SSE 长连接,均强制走 HTTPS(TLS 1.2 / 1.3)。
Nginx 将所有 HTTP 请求 301 重定向到 HTTPS,无法绕过明文访问:
nginx
server {
listen 80;
listen [::]:80;
server_name api.todo.wangpudev.com;
location / {
return 301 https://$host$request_uri;
}
}SSL 证书由 Let's Encrypt 免费签发,每 90 天自动续期,证书到期风险为零。启用了 Strict-Transport-Security 响应头,告知浏览器今后只通过 HTTPS 访问该域名:
nginx
add_header Strict-Transport-Security "max-age=63072000" always;支持的加密套件限定为目前安全的算法,禁用了已知脆弱的 TLS 1.0 / 1.1:
nginx
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...;6.2 密码存储:bcrypt 哈希,永不明文
用户密码在服务端从不以明文形式存储。注册和修改密码时,后端使用 bcrypt 算法处理后再写入数据库:
dart
// 注册时:哈希密码后存储
final hashedPassword = await BCrypt.hashpw(plainPassword, BCrypt.gensalt());
// 登录时:验证密码
final isValid = await BCrypt.checkpw(plainPassword, storedHash);bcrypt 有两个关键特性使其适合密码存储:
不可逆:哈希结果无法被数学反推出原始密码,与 MD5 / SHA256 等通用哈希算法不同,bcrypt 专为密码存储设计。
加盐(Salt):每次哈希都自动引入随机盐值,相同的密码每次生成的哈希都不同,从根本上杜绝彩虹表攻击——攻击者无法预先计算所有可能密码的哈希值建表。
即使数据库被完整泄露,攻击者拿到的也只是 bcrypt 哈希,无法还原用户的真实密码。
6.3 身份认证:JWT 双 Token 机制
Token 设计
登录成功后,服务端签发两个 Token:
| Token | 有效期 | 用途 |
|---|---|---|
| access_token | 1 小时 | 日常接口鉴权,携带在每次请求的 Authorization Header 中 |
| refresh_token | 30 天 | 仅用于换取新的 access_token,权限更敏感 |
access_token 有效期极短,即使在网络传输中泄露,攻击者的利用窗口也不超过 1 小时。
自动刷新
当 access_token 过期时,客户端拦截到 401 响应,自动用 refresh_token 静默换取新的 access_token,整个过程用户无感知:
dart
if (res.statusCode == 401 && retry) {
final refreshed = await _tryRefreshToken();
if (refreshed) {
// 刷新成功,用新 token 重试原请求
return _request(call, parser, retry: false);
}
// 刷新失败(refresh_token 也过期),清除本地会话,引导重新登录
await AuthService.instance.clearSession();
return ApiResult.error('登录已过期,请重新登录', needsReLogin: true);
}Token 签名
JWT 使用 HMAC-SHA256 算法签名,签名密钥(JWT_SECRET)仅存在于服务端环境变量中,客户端无法伪造合法的 Token。
6.4 后端端口隔离
后端 Dart 服务绑定在本机回环地址 127.0.0.1:8080,外部网络无法直接访问:
dart
final server = await HttpServer.bind(
InternetAddress.anyIPv6, // 监听所有接口,但通过防火墙限制
Config.port,
);对外只通过 Nginx 的 443 端口暴露服务,Nginx 内网转发到 8080:
nginx
proxy_pass http://127.0.0.1:8080;VPS 8080 端口对公网不可见。
6.5 敏感配置管理
所有敏感配置通过 .env 文件注入,代码仓库中永远不出现真实密钥:
| 配置项 | 说明 |
|---|---|
JWT_SECRET | JWT 签名密钥,建议 32 位以上随机字符串 |
DB_PASSWORD | PostgreSQL 数据库密码 |
SMTP_USER | QQ 邮箱地址(发件账号,用于发送密码重置验证码) |
SMTP_PASS | QQ 邮箱 SMTP 授权码(不是 QQ 登录密码) |
LOG_USER | 日志查看接口的管理员用户名 |
LOG_PASS | 日志查看接口的管理员密码 |
LOG_SECRET | 日志 Cookie 的 HMAC 签名密钥 |
.env 文件列入 .gitignore,仓库中只有 .env.example 占位符模板,供部署时参考格式。
生成随机密钥的推荐方式:
bash
openssl rand -hex 326.6 日志接口安全
后端日志接口(GET /logs/)暴露在公网,需要独立的安全保护,不复用普通用户的 JWT 认证。
Basic Auth + Cookie
首次访问时弹出浏览器原生认证框,输入管理员用户名和密码(存储于 .env 的 LOG_USER / LOG_PASS)。认证成功后下发一个有效期 7 天的签名 Cookie,期间内无需重复输入密码:
Cookie: log_session=<HMAC签名的过期时间戳>Cookie 使用 LOG_SECRET 进行 HMAC 签名,服务端验证签名和过期时间,防止伪造。Cookie 属性设置为 HttpOnly(防 XSS)、Secure(只在 HTTPS 下传输)、SameSite=Strict(防 CSRF)。
失败限速
同一 IP 连续认证失败 5 次后,锁定 15 分钟,有效防止暴力破解:
dart
if (rl.count >= _maxFailures) {
rl.lockUntil = DateTime.now().add(const Duration(minutes: 15));
rl.count = 0;
}锁定期间返回 429 Too Many Requests,告知剩余等待时间。
日志脱敏
日志内容本身也经过脱敏处理,不记录以下敏感字段:
- 用户密码(任何情况下都不打印)
- JWT Token 原文
- 待办事项的具体内容
- 邮件服务 API Key
日志中出现的用户标识符均为 UUID 格式(如 userId=ca9dc02b-fd4c-...),无法反推出用户真实身份。
6.7 密码重置流程安全
忘记密码功能通过邮件发送验证码,而非直接发送重置链接,有以下安全考虑:
- 验证码为 6 位随机数字,有效期 5 分钟,过期自动失效
- 验证码使用一次后立即作废,防止重放攻击
- 验证码存储在 PostgreSQL 中,与用户账号关联,不同用户的验证码隔离
- 服务端不限制发送频率(当前版本),生产环境建议加入发送频率限制
6.8 安全红线:哪些信息绝对不能出现在代码/文档中
| 类型 | 示例 | 正确做法 |
|---|---|---|
| 服务器 IP | xx.xxx.xxx.xxx | 用 <your-vps-ip> 占位 |
| JWT 密钥 | abc123secret... | 存 .env,文档用 <JWT_SECRET> |
| 数据库密码 | mydbpass123 | 存 .env,文档用 <DB_PASSWORD> |
| 邮箱授权码 | xxxxxxxxxxxxxxxx | 存 .env,文档用 <SMTP_PASS> |
| 用户邮箱 | user@example.com | 日志记录时脱敏或省略 |
代码仓库一旦包含真实密钥,即使后续删除,历史 commit 中仍然存在。如果仓库曾经公开过,应立即更换所有相关密钥。
6.9 端对端加密(E2EE):服务端零知识
前面几节保护的是「传输」与「存储访问」,而 E2EE 更进一步——连服务端自己都读不到用户内容。这是 TodoApp 隐私模型的基石。
- 加密范围:待办的
title与description(核心隐私字段)在客户端用 AES-256-GCM 加密后才上传;due_date、priority、is_completed等无语义隐私的结构化字段保持明文,以便同步与调度逻辑直接使用。 - 密钥派生:密钥由用户「账号密码 + 邮箱」经 PBKDF2-HMAC-SHA256(310000 次迭代)在本地派生,从不上传服务端。相同账号相同密码在任意设备派生出完全相同的密钥,天然实现多端一致,无需密钥交换协议。
- 服务端零知识:服务端被降级为「存储乱码的网盘」,即使数据库被完整拖走,也无法还原任何一条待办内容。
- 密码重置的密钥轮换:通过
key_version机制协调密码重置后的密钥更新——持有旧密钥的其他设备在下一次请求时被强制退出,避免用旧密钥解密新密文而产生乱码。
E2EE 的完整实现(密钥派生细节、密文格式、离线队列配合、key_version 生命周期、密码重置六步流程、多账号隔离、知情同意、Android allowBackup=false)是一个独立且较大的主题,专门在 第十章:端对端加密(E2EE) 展开。