基于Redis实现短信登录

一、前言:为什么越来越多系统采用"短信登录"?

随着用户体验要求提升,"手机号 + 短信验证码"登录方式已成主流:

  • ✅ 无需记忆密码,降低用户流失
  • ✅ 天然绑定手机号,便于后续触达
  • ✅ 符合国内实名制趋势

但实现一个安全、稳定、防刷的短信登录功能,并非简单"发个验证码"即可。

本文将带你用 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 认证",更适合现代应用架构


七、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

相关推荐
OnYoung2 小时前
实战:用OpenCV和Python进行人脸识别
jvm·数据库·python
明天…ling2 小时前
sql注入笔记总结
java·数据库·sql
qq_417129252 小时前
Python深度学习入门:TensorFlow 2.0/Keras实战
jvm·数据库·python
YIN_尹2 小时前
【MySQL】增删查改的艺术——数据库CRUD完全指南(上)
数据库·mysql
zhengfei6112 小时前
sqligo - 轻松检测和利用 SQL 注入漏洞
数据库·sql
没有bug.的程序员2 小时前
Spring Cloud Gateway:API网关限流与熔断实战
java·开发语言·数据库·spring boot·gateway·api·springcloud
OnYoung2 小时前
用Python实现自动化的Web测试(Selenium)
jvm·数据库·python
(;_;)(╥ω╥`)2 小时前
深入剖析Kafka(二)
数据库·kafka·linq