Appearance
六、安全设计 (Security)
安全不是一个独立的功能模块,而是贯穿整个系统每一层的设计原则。本章从传输层到应用层,逐层说明 TodoApp 的安全防护措施。
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 数据库密码 |
RESEND_API_KEY | 邮件服务 API Key(用于发送密码重置验证码) |
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 位随机数字,有效期 10 分钟,过期自动失效
- 验证码使用一次后立即作废,防止重放攻击
- 验证码存储在 PostgreSQL 中,与用户账号关联,不同用户的验证码隔离
- 服务端不限制发送频率(当前版本),生产环境建议加入发送频率限制
6.8 安全红线:哪些信息绝对不能出现在代码/文档中
| 类型 | 示例 | 正确做法 |
|---|---|---|
| 服务器 IP | xx.xxx.xxx.xxx | 用 <your-vps-ip> 占位 |
| JWT 密钥 | abc123secret... | 存 .env,文档用 <JWT_SECRET> |
| 数据库密码 | mydbpass123 | 存 .env,文档用 <DB_PASSWORD> |
| API Key | re_xxxxxxxxxxxx | 存 .env,文档用 <RESEND_API_KEY> |
| 用户邮箱 | user@example.com | 日志记录时脱敏或省略 |
代码仓库一旦包含真实密钥,即使后续删除,历史 commit 中仍然存在。如果仓库曾经公开过,应立即更换所有相关密钥。