安全架构
OWASP、PKI 与认证。
安全架构
这一课你将能做到
- 说出 OWASP Top 10 中至少 5 个风险,并指出本站每个风险对应的防护代码写在哪一层
- 为本站设计完整的认证方案:JWT 短期令牌 + Refresh Token 轮换 + Redis 黑名单
- 解释密码存储为什么要用 BCrypt 而不是 MD5,能写出正确的加密存储代码
为什么要学
安全是架构的「隐性需求」——没人会在需求文档里写「系统不能被脱库」,但出了事架构师第一个被问责。而且安全问题几乎不可逆:性能差了可以优化,数据泄露了就是永久事故。
人话:安全就像大楼的消防系统。平时感觉不到它的存在,验收时也不会因为它加分,但火灾发生时,有和没有的区别就是「损失一层楼」和「整栋楼烧光」。
先建立图像
本站的安全纵深防御体系:
攻击者
│
┌───────────────────▼───────────────────┐
│ 第一层:边界安全 │
│ HTTPS(TLS) / WAF / 限流 / 防火墙 │
├────────────────────────────────────────┤
│ 第二层:认证授权 │
│ JWT 校验 / RBAC 权限 / 接口防重放 │
├────────────────────────────────────────┤
│ 第三层:应用安全 │
│ 参数化查询 / XSS 转义 / CSRF Token │
├────────────────────────────────────────┤
│ 第四层:数据安全 │
│ 密码 BCrypt / 敏感字段加密 / 脱敏返回 │
└────────────────────────────────────────┘
原则:任何一层被击穿,还有下一层兜底
跟着做
步骤 1:密码存储——从错误到正确
// ❌ 错误 1:明文存储(脱库即全军覆没)
user.setPassword("123456");
// ❌ 错误 2:MD5(彩虹表秒破,MD5 是摘要算法不是加密算法)
user.setPassword(DigestUtils.md5Hex("123456"));
// ❌ 错误 3:MD5 + 固定盐(盐泄露后依然可批量破解)
user.setPassword(md5("123456" + "static_salt"));
// ✅ 正确:BCrypt(慢哈希 + 每个密码随机盐 + 可调成本因子)
@Service
public class PasswordService {
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
public String hash(String raw) { return encoder.encode(raw); }
public boolean verify(String raw, String hash) {
return encoder.matches(raw, hash);
}
}
BCrypt 的关键设计:故意慢。MD5 算一次几微秒,BCrypt(cost=12) 算一次约 250ms——用户登录无感知,但攻击者暴力破解的成本提高百万倍。
步骤 2:JWT 认证完整方案
// 双令牌策略
public TokenPair login(LoginReq req) {
User user = authenticate(req);
// Access Token:15 分钟,无状态校验
String accessToken = jwtUtil.create(user.getId(), Duration.ofMinutes(15));
// Refresh Token:7 天,存 Redis,可吊销
String refreshToken = UUID.randomUUID().toString();
redis.set("refresh:" + refreshToken, user.getId(), 7, DAYS);
return new TokenPair(accessToken, refreshToken);
}
// 刷新时轮换:旧 Refresh Token 立即作废(防重放)
public TokenPair refresh(String oldRefreshToken) {
Long userId = redis.get("refresh:" + oldRefreshToken);
if (userId == null) throw new UnauthorizedException();
redis.delete("refresh:" + oldRefreshToken); // 关键:一次性
return issueTokens(userId);
}
// 登出/封号:Access Token 加入黑名单直到过期
public void logout(String accessToken) {
long ttl = jwtUtil.getRemainingTtl(accessToken);
redis.set("blacklist:" + accessToken, "1", ttl, SECONDS);
}