大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
安全架构
这一课你将能做到
- 说出 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);
}
步骤 3:SQL 注入与 XSS 防护
// ❌ SQL 注入:字符串拼接
String sql = "SELECT * FROM course WHERE title LIKE '%" + keyword + "%'";
// 攻击者输入: ' OR '1'='1' -- 直接全表泄露
// ✅ 参数化查询(MyBatis #{} 就是预编译)
@Select("SELECT * FROM course WHERE title LIKE CONCAT('%', #{keyword}, '%')")
List<Course> search(String keyword);
// XSS 防护:输出到页面时转义(Thymeleaf 默认转义,Vue 的 v-html 要小心)
// 富文本场景:用 Jsoup 白名单过滤
String safe = Jsoup.clean(userInput, Safelist.relaxed());
讲清楚要点
- 纵深防御(Defense in Depth):不要把安全寄托在单一措施上。WAF 防不住的注入,参数化查询兜住;JWT 被偷了,短期过期 + 黑名单兜住。
- 永远不要自己发明加密算法:用行业验证过的(BCrypt/Argon2 存密码、AES 加密、RSA 签名、TLS 传输)。自研加密几乎必然有漏洞。
- 认证(Authentication)≠ 授权(Authorization):认证是「你是谁」(登录),授权是「你能干什么」(RBAC 权限)。本站用户能看课程(认证后),但只有作者能编辑课程(授权判断)。
- 敏感数据最小化:能不收集就不收集,必须收集的就加密存储、脱敏展示。手机号返回
138****1234,日志里绝不打印密码、身份证、银行卡。 - 安全是流程不是产品:依赖组件定期扫描(OWASP Dependency-Check)、上线前渗透测试、密钥定期轮换、安全日志审计。一次性加固没有意义。
你可能会懵的地方
-
JWT 无状态不是挺好,为什么还要 Redis? 纯 JWT 无法主动吊销——token 签发后有效期内永远有效,用户被封号也拦不住。所以要么忍受短有效期(15 分钟)+ 刷新机制,要么牺牲无状态性加 Redis 黑名单。工程上通常两者结合。
-
HTTPS 都有了,密码还要加密吗? HTTPS 保护的是传输过程。服务器收到的是明文密码,如果日志、内存 dump、数据库里有明文或弱哈希,一样泄露。分层看:TLS 管传输,BCrypt 管存储,两者缺一不可。
-
CORS 是安全机制吗? 不是。CORS 是浏览器的「同源策略豁免」机制,保护的是用户(防止恶意网站盗用你的登录态调 API),不保护服务器。服务端自己的鉴权才是真正的防线,curl 发起请求根本不受 CORS 约束。
-
API 密钥(API Key)怎么设计? 生成:密码学随机数(SecureRandom)至少 32 字节;存储:服务端只存哈希,用户只展示一次;传输:放 Header(不放 URL,避免日志泄露);权限:每个 Key 绑定最小权限范围。
-
什么是零信任架构? 传统安全是「内网可信、外网不可信」;零信任是「永不信任,始终验证」——即使服务间内网调用也要 mTLS 双向认证 + 细粒度授权。本站微服务化后,订单服务调课程服务也要带服务身份凭证。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| MD5/SHA1 存密码 | 彩虹表秒破 | BCrypt/Argon2,成本因子 ≥ 10 |
| JWT 有效期设 30 天 | 泄露后长期可用,无法吊销 | Access 15 分钟 + Refresh 轮换 |
| 前端校验就算安全防护 | curl 绕过一切前端校验 | 前端校验为了体验,后端必须重校验 |
| 错误提示「密码错误」vs「用户不存在」 | 泄露用户是否存在 | 统一提示「用户名或密码错误」 |
| 密钥写死在代码里提交 Git | 历史记录永久泄露 | 环境变量/配置中心 + Git 历史清洗 |
卡点急救
- 怀疑接口被刷:看 Nginx 日志统计 IP 频率 → 网关层按 IP/用户限流(令牌桶)→ 关键接口加图形验证码或滑块 → 恶意 IP 直接封禁。
- 发现密钥已提交到 GitHub:立即吊销该密钥(当作已泄露处理),用 BFG Repo-Cleaner 清洗历史,然后排查泄露期间的所有访问日志。
- JWT 被 XSS 偷走的风险:token 不存 localStorage(JS 可读,XSS 可偷),存 HttpOnly + Secure + SameSite 的 Cookie;同时全站严格 XSS 过滤。
面试常问
-
JWT 的优缺点?如何做吊销? 优点:无状态(服务端不存会话,易水平扩展)、跨域友好、自包含(减少 DB 查询)。缺点:无法主动吊销、payload 只编码不加密(不能放敏感信息)、token 比 sessionId 大。吊销方案:1) 短有效期 + Refresh Token;2) Redis 黑名单存未过期 token;3) 用户版本号(改密码后 version+1,旧 token 全部失效)。本站用 1+2 组合。
-
如何防止 SQL 注入? 核心一条:绝不拼接 SQL,全部用参数化查询(预编译)。MyBatis 用
#{}不用${}(${}是直接拼接,只有动态表名/排序字段才用且必须白名单校验)。辅助:DB 账号最小权限(应用账号不给 DROP 权限)、WAF 兜底、输入长度限制。 -
对称加密和非对称加密的区别和应用场景? 对称(AES):同一把钥匙加解密,快,适合大量数据;问题是怎么把钥匙安全给对方。非对称(RSA):公钥加密私钥解密,慢,适合小数据。实际用法是混合:HTTPS 里用 RSA 交换 AES 密钥,后续通信用 AES。本站场景:用户手机号用 AES 加密存库(密钥在 KMS),对外接口签名用 RSA。
自测三问
- 为什么 BCrypt 比 SHA-256 更适合存密码?核心设计差异是什么?
- 本站的 JWT Access Token 泄露后,在 15 分钟有效期内如何止损?
- 用户在评论区输入
<script>alert(document.cookie)</script>,从输入到渲染要经过哪几道防线?
小结一句话
安全架构的本质是纵深防御:假设每一层都会被突破,用传输加密、强哈希存储、短期令牌、最小权限构建多层兜底,并用流程保证防线持续有效。
延伸
- OWASP Top 10 官方文档
- 《白帽子讲 Web 安全》(吴翰清)
- Spring Security 官方文档:OAuth2 与 JWT 章节
- 下一课:云与设计模式——容器、CI/CD 与云原生设计模式。