一、问题背景
1.1 漏洞描述
博客系统的文章解锁功能存在一个严重的安全漏洞:用户可以通过浏览器开发者工具(F12)访问 Application -> Local Storage,手动将 blog_global_unlocked 键值设置为 true,从而绕过关注公众号输入验证码的限制机制,无需验证即可阅读完整文章内容。
1.2 漏洞影响
- 绕过验证机制:用户无需关注公众号即可解锁文章
- 影响内容付费/引流策略:破坏运营策略,降低公众号关注转化率
- 安全隐患:客户端存储敏感状态信息,容易被篡改
1.3 原有实现分析
原有实现的核心逻辑:
// 客户端验证 - 可被轻易绕过
const CONFIG = {
STORAGE_KEY: 'blog_global_unlocked' // 本地存储键名
};
function isGlobalUnlocked() {
return localStorage.getItem(CONFIG.STORAGE_KEY) === 'true';
}
function markGlobalUnlocked() {
localStorage.setItem(CONFIG.STORAGE_KEY, 'true');
}
问题根源:
- 验证逻辑完全依赖客户端存储
- 没有服务器端状态验证
- 缺乏防篡改机制
二、解决方案设计
2.1 设计目标
- 服务器端控制:将关键验证逻辑移至服务器端处理
- 不可伪造:确保客户端无法通过任何方式伪造验证状态
- 用户体验:保持流畅的用户体验,减少额外操作
- 可追踪性:能够识别和追踪用户的验证状态
2.2 技术架构
┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐
│ 客户端 │────▶│ 服务器端API │────▶│ Redis │
│ (post.html) │ │ (Controller) │ │ (状态存储) │
└─────────────────┘ └──────────────────┘ └─────────────┘
│ │
│ 1. 请求验证码验证 │
│───────────────────────▶│
│ │
│ 2. 验证成功,返回令牌 │
│◀───────────────────────│
│ │
│ 3. 页面加载时查询状态 │
│───────────────────────▶│
│ │
│ 4. 返回解锁状态 │
│◀───────────────────────│
2.3 核心组件
| 组件 | 职责 | 技术实现 |
|---|---|---|
| ArticleUnlockService | 管理用户解锁状态 | Redis + 客户端ID |
| VerifyCodeController | 提供验证API | Spring Boot REST API |
| IndexInterceptor | 设置客户端追踪Cookie | HandlerInterceptor |
| 前端脚本 | 与服务器通信验证 | Fetch API + async/await |
三、详细实现
3.1 服务器端状态管理服务
3.1.1 服务接口定义
public interface ArticleUnlockService {
/**
* 获取客户端唯一标识
*/
String getClientId(HttpServletRequest request);
/**
* 标记用户为已解锁状态
*/
void markAsUnlocked(String clientId);
/**
* 检查用户是否已解锁
*/
boolean isUnlocked(String clientId);
/**
* 生成解锁令牌
*/
String generateUnlockToken(String clientId);
/**
* 验证解锁令牌
*/
boolean verifyUnlockToken(String clientId, String token);
}
3.1.2 Redis 存储设计
# 解锁状态存储
Key: article:unlock:status:{clientId}
Value: "true"
TTL: 24小时
# 解锁令牌存储
Key: article:unlock:token:{clientId}
Value: {md5_hash_token}
TTL: 30分钟(滑动过期)
3.1.3 客户端ID生成策略
采用多层识别机制,确保用户追踪的准确性:
public String getClientId(HttpServletRequest request) {
// 1. 优先从拦截器设置的request属性获取
Object attrClientId = request.getAttribute("clientId");
if (attrClientId != null) {
return attrClientId.toString();
}
// 2. 从Cookie获取
Cookie[] cookies = request.getCookies();
// ... 解析 blog_client_id
// 3. 从请求头获取(前端传递)
String headerClientId = request.getHeader("X-Client-Id");
// 4. 基于IP和User-Agent生成指纹
String clientId = generateClientId(request);
return clientId;
}
3.2 验证API设计
3.2.1 验证码验证接口
@PostMapping("/code")
public JsonResult verifyCode(@RequestParam("code") String code,
HttpServletRequest request) {
// 1. 验证验证码
boolean success = verifyCodeService.verifyAndConsumeCode(code, SCENE);
if (success) {
// 2. 获取客户端ID
String clientId = articleUnlockService.getClientId(request);
// 3. 在服务器端标记解锁状态
articleUnlockService.markAsUnlocked(clientId);
// 4. 生成解锁令牌返回给客户端
String token = articleUnlockService.generateUnlockToken(clientId);
return JsonResult.success("验证成功", Map.of(
"unlocked", true,
"token", token
));
}
return JsonResult.fail("验证码错误或已过期");
}
3.2.2 解锁状态查询接口
@GetMapping("/status")
public JsonResult checkUnlockStatus(HttpServletRequest request) {
String clientId = articleUnlockService.getClientId(request);
boolean isUnlocked = articleUnlockService.isUnlocked(clientId);
Map<String, Object> result = new HashMap<>();
result.put("unlocked", isUnlocked);
if (isUnlocked) {
// 重新生成令牌
result.put("token", articleUnlockService.generateUnlockToken(clientId));
}
return JsonResult.success("查询成功", result);
}
3.2.3 令牌验证接口(防篡改)
@PostMapping("/token")
public JsonResult verifyUnlockToken(@RequestParam("token") String token,
HttpServletRequest request) {
String clientId = articleUnlockService.getClientId(request);
boolean isValid = articleUnlockService.verifyUnlockToken(clientId, token);
if (isValid) {
return JsonResult.success("令牌有效");
}
return JsonResult.fail("令牌无效或已过期");
}
3.3 客户端实现
3.3.1 核心验证逻辑
// 配置项
const CONFIG = {
TOKEN_STORAGE_KEY: 'blog_unlock_token',
CLIENT_ID_KEY: 'blog_client_id'
};
// 获取或生成客户端ID
function getClientId() {
let clientId = localStorage.getItem(CONFIG.CLIENT_ID_KEY);
if (!clientId) {
clientId = 'cid_' + Math.random().toString(36).substring(2) +
Date.now().toString(36);
localStorage.setItem(CONFIG.CLIENT_ID_KEY, clientId);
}
return clientId;
}
// 从服务器检查解锁状态(唯一可信来源)
async function checkServerUnlockStatus() {
const clientId = getClientId();
const response = await fetch('/api/verify/status', {
method: 'GET',
headers: {
'X-Client-Id': clientId
},
credentials: 'same-origin'
});
const data = await response.json();
if (data.flag && data.data.unlocked) {
// 保存服务器返回的令牌
currentUnlockToken = data.data.token;
sessionStorage.setItem(CONFIG.TOKEN_STORAGE_KEY, currentUnlockToken);
return { unlocked: true };
}
return { unlocked: false };
}
3.3.2 页面初始化流程
async function initArticleLock() {
// 向服务器查询解锁状态(唯一可信来源)
const status = await checkServerUnlockStatus();
if (status.unlocked) {
// 服务器确认已解锁
unlockArticle();
} else {
// 显示锁定状态
lockArticle();
}
}
3.4 拦截器实现
@Component
public class IndexInterceptor implements HandlerInterceptor {
private static final String CLIENT_ID_COOKIE = "blog_client_id";
private static final int COOKIE_MAX_AGE = 365; // 天
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
ensureClientId(request, response);
return true;
}
private void ensureClientId(HttpServletRequest request,
HttpServletResponse response) {
String clientId = null;
// 1. 从Cookie获取
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (CLIENT_ID_COOKIE.equals(cookie.getName())) {
clientId = cookie.getValue();
break;
}
}
}
// 2. 生成新的客户端ID
if (!StringUtils.hasText(clientId)) {
clientId = generateClientId();
}
// 3. 设置到request属性
request.setAttribute("clientId", clientId);
// 4. 设置Cookie
Cookie clientCookie = new Cookie(CLIENT_ID_COOKIE, clientId);
clientCookie.setMaxAge(COOKIE_MAX_AGE * 24 * 60 * 60);
clientCookie.setPath("/");
clientCookie.setHttpOnly(false); // 允许JS读取
response.addCookie(clientCookie);
}
private String generateClientId() {
return "cid_" + UUID.randomUUID().toString().replace("-", "");
}
}
四、安全机制详解
4.1 多层身份识别
系统采用多层身份识别机制,确保用户追踪的准确性:
- Cookie 追踪:设置长期有效的
blog_client_idCookie - 请求头传递:前端通过
X-Client-Id请求头传递客户端ID - 指纹生成:基于 IP 地址 + User-Agent 生成备用指纹
4.2 令牌机制
令牌生成公式:
token = MD5(clientId + timestamp + UUID)
验证流程:
1. 从Redis获取存储的令牌
2. 对比客户端提交的令牌
3. 验证成功后刷新过期时间(滑动过期)
4.3 防篡改设计
| 攻击方式 | 防御措施 |
|---|---|
| 修改 LocalStorage | 服务器端不读取 LocalStorage 的权限状态 |
| 修改 Cookie | Cookie仅用于识别,权限状态存储在Redis |
| 伪造令牌 | 令牌存储在服务器端Redis,无法伪造 |
| 重放攻击 | 令牌有过期时间,且与clientId绑定 |
| IP更换 | 结合Cookie和指纹多重识别 |
五、测试验证
5.1 正常流程测试
1. 访问文章页面
└─▶ 显示文章前1/3内容,显示解锁提示
2. 点击解锁按钮,输入正确验证码
└─▶ 服务器验证成功,Redis存储解锁状态
└─▶ 返回解锁令牌
└─▶ 客户端解锁文章显示
3. 刷新页面
└─▶ 客户端向服务器查询状态
└─▶ 服务器返回已解锁状态
└─▶ 文章保持解锁状态
5.2 安全测试
测试1:LocalStorage 篡改测试
// 攻击尝试
localStorage.setItem('blog_global_unlocked', 'true');
location.reload();
// 预期结果:文章仍显示锁定状态
// 实际结果:✅ 文章保持锁定,因为服务器端没有对应记录
测试2:Cookie 篡改测试
// 攻击尝试
document.cookie = "blog_client_id=fake_id; path=/";
location.reload();
// 预期结果:系统生成新的客户端ID或识别为未解锁
// 实际结果:✅ 系统识别为新用户,显示锁定状态
测试3:令牌伪造测试
// 攻击尝试
sessionStorage.setItem('blog_unlock_token', 'fake_token');
location.reload();
// 预期结果:令牌验证失败,显示锁定状态
// 实际结果:✅ 服务器拒绝无效令牌
六、性能优化
6.1 Redis 连接优化
- 使用连接池管理 Redis 连接
- 设置合理的超时时间
- 启用 Redis 管道(Pipeline)批量操作
6.2 缓存策略
// 解锁状态缓存 24 小时
redisTemplate.opsForValue().set(
key,
"true",
24,
TimeUnit.HOURS
);
// 令牌缓存 30 分钟,滑动过期
redisTemplate.opsForValue().set(
tokenKey,
token,
30,
TimeUnit.MINUTES
);
6.3 前端优化
- 使用
sessionStorage而非localStorage存储令牌(更安全) - 异步加载验证状态,不阻塞页面渲染
- 添加防抖机制,避免频繁请求
七、部署注意事项
7.1 Redis 配置
确保 Redis 服务可用,并配置合适的内存策略:
# redis.conf
maxmemory 256mb
maxmemory-policy allkeys-lru
7.2 Nginx 配置(如使用)
确保请求头能够正确传递到后端:
location / {
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Client-Id $http_x_client_id;
}
7.3 HTTPS 建议
生产环境建议启用 HTTPS,并设置 Cookie 的 Secure 属性:
clientCookie.setSecure(true); // HTTPS 环境下启用
八、总结
8.1 修复成果
- ✅ 彻底修复安全漏洞:用户无法通过修改客户端存储绕过验证
- ✅ 服务器端控制:所有验证逻辑由服务器端控制,确保安全性
- ✅ 用户体验保持:验证流程对用户透明,无需额外操作
- ✅ 可扩展性:架构支持后续添加更多验证方式
8.2 关键改进点
| 改进项 | 修复前 | 修复后 |
|---|---|---|
| 验证存储位置 | LocalStorage | Redis(服务器端) |
| 验证逻辑 | 客户端判断 | 服务器端判断 |
| 防篡改能力 | 无 | 多层防护 |
| 用户追踪 | 单一Cookie | Cookie + 指纹 |
| 令牌机制 | 无 | MD5 + 滑动过期 |
8.3 后续建议
- 添加限流机制:防止验证码接口被暴力破解
- 日志审计:记录验证成功/失败日志,便于分析
- 异常监控:监控Redis连接状态和API响应时间
- 定期轮换:定期清理过期的解锁状态数据

博主你这样一改我还怎么通过手动设置blog_global_unlocked的值来跳过公众号验证?