一、前言:为什么越来越多系统采用"短信登录"?
随着用户体验要求提升,"手机号 + 短信验证码"登录方式已成主流:
- ✅ 无需记忆密码,降低用户流失
- ✅ 天然绑定手机号,便于后续触达
- ✅ 符合国内实名制趋势
但实现一个安全、稳定、防刷的短信登录功能,并非简单"发个验证码"即可。
本文将带你用 Spring Boot + Redis 实现一套生产可用的短信登录系统 ,涵盖:
✅ 验证码生成与存储
✅ 双重限流(IP + 手机号)
✅ 登录态管理(Token + Redis)
✅ 安全校验与防重放
✅ 完整代码示例
二、整体业务流程
1. 用户输入手机号,点击【获取验证码】
↓
2. 后端校验图形验证码(防机器刷)
↓
3. 检查 IP 和手机号发送频率(防刷)
↓
4. 生成 6 位随机码,存入 Redis(key = sms:code:{phone})
↓
5. 调用短信平台发送(本文模拟)
↓
6. 用户输入验证码,点击【登录】
↓
7. 校验验证码是否正确 & 未过期 & 未使用
↓
8. 验证通过 → 生成登录 Token,存入 Redis(key = login:token:{uuid})
↓
9. 返回 Token 给前端,后续请求携带 Token 认证
🔑 核心思想 :Redis 既是验证码仓库,也是会话管理中心
三、技术选型
| 组件 | 作用 |
|---|---|
| Spring Boot 3.x | Web 框架 |
| Redis | 存储验证码 + 登录态 |
| StringRedisTemplate | 操作 Redis(字符串友好) |
| EasyCaptcha | 生成图形验证码(防自动化脚本) |
| UUID | 生成唯一登录 Token |
四、核心实现步骤
1. 添加依赖
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
2. Redis Key 命名规范
java
// 验证码
private static final String SMS_CODE_KEY = "sms:code:%s"; // %s = phone
// 验证码发送频率(60秒内限1次)
private static final String SMS_LIMIT_KEY = "sms:limit:%s";
// 登录 Token
private static final String LOGIN_TOKEN_KEY = "login:token:%s"; // %s = token
// 用户信息(可选)
private static final String USER_INFO_KEY = "user:info:%s"; // %s = phone
3. 图形验证码接口(防刷前置)
java
@RestController
public class CaptchaController {
@GetMapping("/captcha")
public void captcha(HttpServletRequest request, HttpServletResponse response) throws Exception {
ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 48);
captcha.setLen(4);
request.getSession().setAttribute("captcha", captcha.text());
response.setContentType("image/png");
captcha.out(response.getOutputStream());
}
}
4. 发送短信验证码接口
java
@RestController
public class SmsLoginController {
@Autowired
private StringRedisTemplate redisTemplate;
private static final long CODE_EXPIRE = 300; // 5分钟
private static final long PHONE_LIMIT = 60; // 60秒内限1次
private static final int IP_DAILY_LIMIT = 20; // IP 日限额
@PostMapping("/send-sms-code")
public ResponseEntity<String> sendSmsCode(
@RequestParam String phone,
@RequestParam String captchaInput,
HttpServletRequest request) {
// 1. 校验图形验证码
String sessionCaptcha = (String) request.getSession().getAttribute("captcha");
if (!captchaInput.equals(sessionCaptcha)) {
return ResponseEntity.badRequest().body("图形验证码错误");
}
// 2. 校验手机号格式
if (!phone.matches("^1[3-9]\\d{9} $ ")) {
return ResponseEntity.badRequest().body("手机号格式错误");
}
String ip = getClientIP(request);
// 3. IP 日限额检查
String ipLimitKey = "sms:ip:" + ip;
Integer ipCount = getRedisInt(ipLimitKey, 24 * 3600);
if (ipCount >= IP_DAILY_LIMIT) {
return ResponseEntity.status(429).body("操作过于频繁");
}
// 4. 手机号频率检查(60秒内只能发1次)
String phoneLimitKey = String.format(SMS_LIMIT_KEY, phone);
Boolean hasSent = redisTemplate.hasKey(phoneLimitKey);
if (Boolean.TRUE.equals(hasSent)) {
return ResponseEntity.status(429).body("请60秒后再试");
}
// 5. 生成验证码
String code = String.valueOf((int) ((Math.random() * 9 + 1) * 100000));
// 6. 存入 Redis
redisTemplate.opsForValue().set(
String.format(SMS_CODE_KEY, phone),
code,
CODE_EXPIRE,
TimeUnit.SECONDS
);
redisTemplate.opsForValue().set(phoneLimitKey, "1", PHONE_LIMIT, TimeUnit.SECONDS);
redisTemplate.opsForValue().increment(ipLimitKey);
redisTemplate.expire(ipLimitKey, 24, TimeUnit.HOURS);
// 7. 【模拟】发送短信
System.out.println("【测试】向 " + phone + " 发送验证码: " + code);
return ResponseEntity.ok("验证码已发送");
}
private Integer getRedisInt(String key, int expireSeconds) {
String val = redisTemplate.opsForValue().get(key);
if (val == null) {
redisTemplate.opsForValue().set(key, "0", expireSeconds, TimeUnit.SECONDS);
return 0;
}
return Integer.parseInt(val);
}
private String getClientIP(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
5. 短信登录接口(核心!)
java
@PostMapping("/sms-login")
public ResponseEntity<Map<String, Object>> smsLogin(@RequestParam String phone,
@RequestParam String code) {
// 1. 校验验证码
String realCode = redisTemplate.opsForValue().get(String.format(SMS_CODE_KEY, phone));
if (realCode == null) {
return ResponseEntity.badRequest().body(Map.of("msg", "验证码已过期"));
}
if (!realCode.equals(code)) {
return ResponseEntity.badRequest().body(Map.of("msg", "验证码错误"));
}
// 2. 【关键】验证成功后立即删除验证码(防重放)
redisTemplate.delete(String.format(SMS_CODE_KEY, phone));
// 3. 生成登录 Token
String token = UUID.randomUUID().toString().replace("-", "");
// 4. 构造用户信息(实际应查数据库)
String userInfo = "{\"phone\":\"" + phone + "\",\"userId\":1001}";
// 5. 存入 Redis(2小时有效)
redisTemplate.opsForValue().set(
String.format(LOGIN_TOKEN_KEY, token),
userInfo,
2 * 3600,
TimeUnit.SECONDS
);
// 6. 返回 Token
Map<String, Object> resp = new HashMap<>();
resp.put("token", token);
resp.put("expire", System.currentTimeMillis() + 2 * 3600 * 1000);
return ResponseEntity.ok(resp);
}
6. 登录拦截器(保护需要认证的接口)
java
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
writeError(response, "缺少认证凭证");
return false;
}
token = token.substring(7); // 去掉 "Bearer "
String userInfo = redisTemplate.opsForValue().get("login:token:" + token);
if (userInfo == null) {
writeError(response, "登录已过期");
return false;
}
// 可选:放入 ThreadLocal
UserContext.setCurrentUser(parseUser(userInfo));
return true;
}
private void writeError(HttpServletResponse response, String msg) throws IOException {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"Unauthorized\",\"msg\":\"" + msg + "\"}");
}
}
五、安全加固要点
| 风险 | 防御措施 |
|---|---|
| 验证码爆破 | 5分钟过期 + 一次性使用 |
| 短信轰炸 | IP + 手机号双维度限流 |
| 机器刷验证码 | 前置图形验证码 |
| Token 泄露 | HTTPS + 不存 localStorage |
| 会话固定 | Token 随机生成(UUID 安全) |
六、与传统 Session 登录对比
| 特性 | 短信登录(Redis Token) | 传统 Session 登录 |
|---|---|---|
| 状态 | 有状态(Redis 存储) | 有状态(内存/Redis) |
| 传输 | Header(Authorization) | Cookie(自动) |
| 跨域 | 天然支持 | 需配置 CORS + withCredentials |
| 适用端 | App / 小程序 / H5 | 传统 Web |
| 安全性 | 可主动失效 | 可主动失效 |
📌 结论 :短信登录本质是"无密码 + Token 认证",更适合现代应用架构
七、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!