Skip to content

六、安全设计 (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_token1 小时日常接口鉴权,携带在每次请求的 Authorization Header 中
refresh_token30 天仅用于换取新的 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_SECRETJWT 签名密钥,建议 32 位以上随机字符串
DB_PASSWORDPostgreSQL 数据库密码
RESEND_API_KEY邮件服务 API Key(用于发送密码重置验证码)
LOG_USER日志查看接口的管理员用户名
LOG_PASS日志查看接口的管理员密码
LOG_SECRET日志 Cookie 的 HMAC 签名密钥

.env 文件列入 .gitignore,仓库中只有 .env.example 占位符模板,供部署时参考格式。

生成随机密钥的推荐方式:

bash
openssl rand -hex 32

6.6 日志接口安全

后端日志接口(GET /logs/)暴露在公网,需要独立的安全保护,不复用普通用户的 JWT 认证。

首次访问时弹出浏览器原生认证框,输入管理员用户名和密码(存储于 .envLOG_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 安全红线:哪些信息绝对不能出现在代码/文档中

类型示例正确做法
服务器 IPxx.xxx.xxx.xxx<your-vps-ip> 占位
JWT 密钥abc123secret....env,文档用 <JWT_SECRET>
数据库密码mydbpass123.env,文档用 <DB_PASSWORD>
API Keyre_xxxxxxxxxxxx.env,文档用 <RESEND_API_KEY>
用户邮箱user@example.com日志记录时脱敏或省略

代码仓库一旦包含真实密钥,即使后续删除,历史 commit 中仍然存在。如果仓库曾经公开过,应立即更换所有相关密钥。