# 验证码安全机制说明 ## 安全问题 ### 旧方案的漏洞 ``` Redis存储:sms:code:手机号 => 验证码 ``` **问题**:任何人只要知道手机号和验证码,就可以注册该手机号的账号。 **攻击场景**: 1. 攻击者获取目标用户手机号 2. 攻击者触发验证码发送 3. 如果攻击者通过其他方式获取到验证码(如社会工程学、短信拦截等) 4. 攻击者可以用这个手机号+验证码注册账号 ## 新方案:SessionID绑定 ### 核心思想 验证码不直接绑定手机号/邮箱,而是绑定一个临时会话ID(SessionID),确保只有发起验证码请求的用户才能使用。 ### 实现机制 #### 1. 发送验证码流程 ``` 用户请求发送验证码(手机号/邮箱) ↓ 后端生成 SessionID(UUID) ↓ 发送验证码到用户手机/邮箱 ↓ Redis存储: key: sms:code:SessionID value: 手机号:验证码 expire: 5分钟 ↓ 返回 SessionID 给前端 ``` **关键点**: - SessionID 是随机生成的UUID,无法预测 - 验证码存储时绑定SessionID,不直接绑定手机号 - 前端必须保存并传递SessionID #### 2. 验证验证码流程 ``` 用户提交注册表单 - 手机号/邮箱 - 验证码 - SessionID(从发送验证码时获取) ↓ 后端验证流程: 1. 通过SessionID从Redis获取:手机号:验证码 2. 验证提交的手机号是否匹配存储的手机号 3. 验证提交的验证码是否匹配存储的验证码 4. 两者都匹配才允许注册 ↓ 注册成功后删除验证码 ``` ### 数据结构 #### Redis存储格式 **短信验证码**: ``` Key: sms:code:550e8400-e29b-41d4-a716-446655440000 Value: 13800138000:123456 TTL: 300秒(5分钟) ``` **邮箱验证码**: ``` Key: email:code:650e8400-e29b-41d4-a716-446655440001 Value: user@example.com:654321 TTL: 300秒(5分钟) ``` **频率限制**: ``` Key: sms:code:ratelimit:13800138000 Value: 1 TTL: 300秒(5分钟) ``` ### API接口变化 #### 发送验证码接口 **旧版本**: ```json POST /auth/send-sms-code Request: { "phone": "13800138000" } Response: { "code": 200, "message": "验证码已发送", "data": true } ``` **新版本**: ```json POST /auth/send-sms-code Request: { "phone": "13800138000" } Response: { "code": 200, "message": "验证码已发送", "data": { "sessionId": "550e8400-e29b-41d4-a716-446655440000", "message": "验证码已发送" } } ``` #### 注册接口 **旧版本**: ```json POST /auth/register { "registerType": "phone", "phone": "13800138000", "smsCode": "123456", "password": "123456", "confirmPassword": "123456" } ``` **新版本**: ```json POST /auth/register { "registerType": "phone", "phone": "13800138000", "smsCode": "123456", "smsSessionId": "550e8400-e29b-41d4-a716-446655440000", // 新增 "password": "123456", "confirmPassword": "123456" } ``` ### 安全优势 #### 1. 防止验证码盗用 - **旧方案**:知道手机号+验证码 = 可以注册 - **新方案**:需要手机号+验证码+SessionID = 更安全 攻击者即使获取到验证码,没有SessionID也无法注册。 #### 2. 会话绑定 SessionID由发起验证码请求的用户持有,其他人无法获取。 #### 3. 防止批量注册 每次发送验证码都会生成新的SessionID,无法重复使用。 #### 4. 时效性 SessionID和验证码都有5分钟有效期,过期自动失效。 ### 攻击场景分析 #### 场景1:短信拦截攻击 **攻击流程**: 1. 攻击者获取目标手机号 2. 攻击者拦截短信获取验证码 **旧方案**:✗ 攻击者可以注册 **新方案**:✓ 攻击者没有SessionID,无法注册 #### 场景2:社会工程学 **攻击流程**: 1. 攻击者诱骗用户说出验证码 **旧方案**:✗ 攻击者可以注册 **新方案**:✓ 攻击者没有SessionID,无法注册 #### 场景3:中间人攻击 **攻击流程**: 1. 攻击者拦截网络请求获取验证码 **旧方案**:✗ 攻击者可以注册 **新方案**:✗ 攻击者可以拦截SessionID(需要HTTPS) **防御**:必须使用HTTPS加密传输 ### 前端实现 #### 1. 保存SessionID ```typescript // 发送验证码 const sendSmsCode = async () => { const result = await authApi.sendSmsCode(phone); if (result.code === 200 && result.data) { // 保存sessionId到表单 registerForm.smsSessionId = result.data.sessionId; } }; ``` #### 2. 提交注册 ```typescript // 注册 const register = async () => { const result = await authApi.register({ registerType: 'phone', phone: '13800138000', smsCode: '123456', smsSessionId: registerForm.smsSessionId, // 传递sessionId password: '123456', confirmPassword: '123456' }); }; ``` ### 后端实现 #### 1. 生成SessionID ```java // 生成会话ID String sessionId = IDUtils.generateID(); // 存储验证码(绑定sessionId) String codeKey = "sms:code:" + sessionId; String codeValue = phone + ":" + code; redisService.set(codeKey, codeValue, 5, TimeUnit.MINUTES); // 返回sessionId Map data = Map.of( "sessionId", sessionId, "message", "验证码已发送" ); ``` #### 2. 验证SessionID ```java // 获取存储的值 String codeKey = "sms:code:" + smsSessionId; String storedValue = redisService.get(codeKey); // 解析:手机号:验证码 String[] parts = storedValue.split(":"); String storedPhone = parts[0]; String storedCode = parts[1]; // 验证 if (!storedPhone.equals(phone)) { return "手机号与验证码不匹配"; } if (!storedCode.equals(smsCode)) { return "验证码错误"; } // 验证通过后删除 redisService.delete(codeKey); ``` ### 验证日志 ``` [INFO] 短信验证码已发送,手机号: 138****8000, sessionId: 550e8400-e29b-41d4-a716-446655440000 [INFO] 手机号注册: 13800138000, sessionId: 550e8400-e29b-41d4-a716-446655440000 [WARN] 手机号注册验证失败,提交手机号: 13800138001, 验证码绑定手机号: 13800138000 ``` ### 注意事项 1. **HTTPS必须** - SessionID通过HTTP传输,必须使用HTTPS加密 - 防止中间人攻击窃取SessionID 2. **SessionID保密** - 不要在日志中完整输出SessionID - 不要在URL参数中传递SessionID 3. **有效期设置** - 验证码有效期:5分钟 - 频率限制:5分钟内只能发送一次 - SessionID随验证码一起失效 4. **错误提示** - 不要暴露具体的验证失败原因 - 统一返回"验证码错误"或"验证码已过期" 5. **前端存储** - SessionID存储在组件状态中,不持久化 - 页面刷新后SessionID丢失,需要重新获取验证码 ## 对比总结 | 方面 | 旧方案 | 新方案 | 优势 | |------|--------|--------|------| | 存储键 | 手机号 | SessionID | 无法通过手机号直接访问 | | 验证要素 | 手机号+验证码 | 手机号+验证码+SessionID | 多一层安全保障 | | 防止盗用 | ✗ | ✓ | SessionID由请求方持有 | | 批量攻击 | 易受攻击 | 难以攻击 | 每次请求生成新SessionID | | 实现复杂度 | 简单 | 中等 | 增加SessionID管理 | | 前端改动 | 无 | 需要保存SessionID | 增加一个字段 | ## 推荐实践 1. ✅ **使用HTTPS**:强制要求HTTPS访问 2. ✅ **SessionID加密**:可以考虑对SessionID进行签名 3. ✅ **监控异常**:记录验证失败的日志,监控异常行为 4. ✅ **IP限流**:限制单个IP的发送频率 5. ✅ **图形验证码**:发送验证码前增加图形验证码 6. ✅ **行为分析**:分析用户行为,识别机器人 7. ✅ **黑名单机制**:记录恶意手机号/IP,加入黑名单 ## 总结 通过引入SessionID机制,我们将验证码从"谁都可以用"变成了"只有请求者可以用",大大提高了系统的安全性。这是一个简单但有效的安全增强方案,推荐在所有需要验证码的场景中使用。