首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

Java 生成手機短信驗證碼最佳實踐:安全、高效、防刷完整方案

     編輯:Java 相關知識

前言

短信驗證碼是身份認證、註冊登錄、支付確認的核心環節。本文提供 **生產級 Java 實現**:加密安全隨機生成、Redis 分佈式存儲與限流、模板防盜刷、異步發送解耦、失效自動清理、完整單元測試,涵蓋高併發、安全合規、運維可觀測全維度。

一、核心需求與威脅模型

需求威脅對策
隨機性不可預測暴力破解、碰撞攻擊`SecureRandom` + 6-8 位數字/字母數字混合
一次性、短時效重放攻擊、洩露利用Redis TTL 5-10 分鐘,驗證即刪除
防暴力嘗試短信轟炸、撞庫IP/手機號/設備指紋 多維限流 (令牌桶/滑動窗口)
防機器人批量請求自動化腳本刷接口圖形/行為驗證碼前置、設備指紋、風控引擎
高可用、低延遲發送失敗、超時異步隊列 + 多通道降級 + 熔斷

二、驗證碼生成核心工具類


import java.security.SecureRandom;
import java.util.Random;

public class VerificationCodeGenerator {
    // 字符池:排除易混淆字符 0/O, 1/I/l, 2/Z, 5/S, 8/B
    private static final String DIGITS = "23456789";
    private static final String LETTERS = "ABCDEFGHJKMNPQRSTUVWXYZ";
    private static final String ALPHANUMERIC = DIGITS + LETTERS; // 32 字符
    
    // 加密安全隨機數生成器 (線程安全、不可預測)
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
    // 高性能非安全隨機 (僅用於非安全場景,如內部測試)
    private static final ThreadLocalRandom FAST_RANDOM = ThreadLocalRandom.current();

    /**
     * 生成指定長度的純數字驗證碼 (推薦 6 位,100 萬組合空間)
     */
    public static String generateNumeric(int length) {
        if (length <= 0) throw new IllegalArgumentException("Length must be positive");
        StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            sb.append(DIGITS.charAt(SECURE_RANDOM.nextInt(DIGITS.length())));
        }
        return sb.toString();
    }

    /**
     * 生成字母數字混合驗證碼 (推薦 6-8 位,熵值更高)
     */
    public static String generateAlphanumeric(int length) {
        if (length <= 0) throw new IllegalArgumentException("Length must be positive");
        StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            sb.append(ALPHANUMERIC.charAt(SECURE_RANDOM.nextInt(ALPHANUMERIC.length())));
        }
        return sb.toString();
    }

    /**
     * 生成指定字符池的驗證碼 (擴展點)
     */
    public static String generateCustom(int length, String charset) {
        if (charset == null || charset.isEmpty()) throw new IllegalArgumentException("Charset empty");
        StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            sb.append(charset.charAt(SECURE_RANDOM.nextInt(charset.length())));
        }
        return sb.toString();
    }
}

三、Redis 存儲與限流服務 (核心業務邏輯)


@Service
@RequiredArgsConstructor
public class SmsVerificationService {
    private final StringRedisTemplate redisTemplate;
    private final SmsSender smsSender; // 異步發送接口
    
    // Key 前綴常量
    private static final String CODE_PREFIX = "sms:code:";
    private static final String SEND_LIMIT_PREFIX = "sms:send_limit:";
    private static final String VERIFY_LIMIT_PREFIX = "sms:verify_limit:";
    
    // 業務配置 (建議外部化到 Nacos/Apollo)
    private static final int CODE_LENGTH = 6;
    private static final int CODE_TTL_MINUTES = 5;
    private static final int SEND_LIMIT_PER_HOUR = 10;      // 同一手機號/小時
    private static final int SEND_LIMIT_PER_DAY = 20;       // 同一手機號/天
    private static final int VERIFY_MAX_ATTEMPTS = 5;       // 驗證失敗上限
    private static final int IP_SEND_LIMIT_PER_MINUTE = 5;  // 同一IP/分鐘

    /**
     * 發送驗證碼 (含多維限流、風控)
     */
    public Result sendCode(String phone, String ip, String deviceId) {
        // 1. 參數校驗
        if (!PhoneUtil.isValidChinaMobile(phone)) return Result.error("手機號格式錯誤");
        
        // 2. 多維限流檢查 (Redis Lua 腳本原子執行)
        String sendLimitKey = SEND_LIMIT_PREFIX + phone;
        String ipLimitKey = SEND_LIMIT_PREFIX + "ip:" + ip;
        String deviceLimitKey = SEND_LIMIT_PREFIX + "dev:" + deviceId;
        
        // 檢查手機號小時/天限流
        Long hourCount = redisTemplate.opsForValue().increment(sendLimitKey + ":hour");
        if (hourCount == 1) redisTemplate.expire(sendLimitKey + ":hour", 1, TimeUnit.HOURS);
        if (hourCount > SEND_LIMIT_PER_HOUR) return Result.error("發送過於頻繁,請 1 小時後再試");
        
        Long dayCount = redisTemplate.opsForValue().increment(sendLimitKey + ":day");
        if (dayCount == 1) redisTemplate.expire(sendLimitKey + ":day", 1, TimeUnit.DAYS);
        if (dayCount > SEND_LIMIT_PER_DAY) return Result.error("今日發送次數已達上限");
        
        // 檢查 IP 分鐘級限流 (防刷)
        Long ipCount = redisTemplate.opsForValue().increment(ipLimitKey);
        if (ipCount == 1) redisTemplate.expire(ipLimitKey, 1, TimeUnit.MINUTES);
        if (ipCount > IP_SEND_LIMIT_PER_MINUTE) return Result.error("IP 請求過於頻繁");
        
        // 3. 生成驗證碼
        String code = VerificationCodeGenerator.generateNumeric(CODE_LENGTH);
        
        // 4. 存儲驗證碼 (Key: sms:code:phone, Value: code, TTL: 5min)
        String codeKey = CODE_PREFIX + phone;
        redisTemplate.opsForValue().set(codeKey, code, CODE_TTL_MINUTES, TimeUnit.MINUTES);
        
        // 5. 異步發送短信 (解耦、不阻塞響應)
        smsSender.sendAsync(phone, "您的驗證碼是:" + code + "," + CODE_TTL_MINUTES + "分鐘內有效,請勿泄露。");
        
        // 6. 記錄審計日誌 (脫敏手機號)
        log.info("短信驗證碼發送: phone={}, ip={}, deviceId={}", maskPhone(phone), ip, deviceId);
        return Result.success();
    }

    /**
     * 驗證驗證碼 (一次性、防暴力嘗試)
     */
    public Result verifyCode(String phone, String inputCode) {
        if (StringUtils.isBlank(inputCode)) return Result.error("驗證碼不能為空");
        
        String codeKey = CODE_PREFIX + phone;
        String verifyLimitKey = VERIFY_LIMIT_PREFIX + phone;
        
        // 1. 獲取並驗證 (Lua 腳本保證原子性:獲取+比較+刪除/計數)
        String luaScript = """
            local code = redis.call('GET', KEYS[1])
            if not code then return {-1, 0} end  -- 不存在/過期
            local attempts = redis.call('INCR', KEYS[2])
            if attempts == 1 then redis.call('EXPIRE', KEYS[2], 300) end -- 5分鐘嘗試窗口
            if attempts > tonumber(ARGV[1]) then return {-2, attempts} end -- 超過嘗試次數
            if code == ARGV[2] then
                redis.call('DEL', KEYS[1], KEYS[2]) -- 驗證成功即刪除
                return {1, attempts}
            else
                return {0, attempts} -- 驗證失敗
            end
            """;
        
        DefaultRedisScript> script = new DefaultRedisScript<>(luaScript, List.class);
        List result = redisTemplate.execute(script, List.of(codeKey, verifyLimitKey), 
            VERIFY_MAX_ATTEMPTS, inputCode);
        
        long status = result.get(0);
        long attempts = result.get(1);
        
        return switch ((int) status) {
            case 1 -> Result.success(true); // 成功
            case 0 -> Result.error("驗證碼錯誤,還可嘗試 " + (VERIFY_MAX_ATTEMPTS - attempts) + " 次");
            case -1 -> Result.error("驗證碼已過期或不存在,請重新獲取");
            case -2 -> { 
                redisTemplate.delete(codeKey); // 鎖定,刪除驗證碼防止繼續嘗試
                yield Result.error("驗證失敗次數過多,驗證碼已失效,請重新獲取");
            }
            default -> Result.error("系統異常");
        };
    }
    
    private String maskPhone(String phone) { 
        return phone.substring(0, 3) + "****" + phone.substring(7); 
    }
}

四、異步短信發送接口與實現 (解耦、重試、多通道)


public interface SmsSender {
    void sendAsync(String phone, String content);
    // 同步發送 (可選,用於測試)
    Result sendSync(String phone, String content);
}

@Component
@RequiredArgsConstructor
public class AsyncSmsSender implements SmsSender {
    private final TaskExecutor smsExecutor; // 專用線程池
    private final SmsChannelRouter channelRouter; // 多通道路由 (阿里云/騰訊云/網易雲信/備用通道)
    private final MeterRegistry meterRegistry; // 指標

    @Override
    public void sendAsync(String phone, String content) {
        smsExecutor.execute(() -> {
            Timer.Sample sample = Timer.start(meterRegistry);
            try {
                Result result = channelRouter.send(phone, content);
                sample.stop(Timer.builder("sms.send.duration").tag("status", result.isSuccess() ? "success" : "fail").register(meterRegistry));
                if (!result.isSuccess()) {
                    log.warn("短信發送失敗: phone={}, reason={}", maskPhone(phone), result.getMessage());
                    // 可觸發告警、寫入失敗表供人工補發
                }
            } catch (Exception e) {
                sample.stop(Timer.builder("sms.send.duration").tag("status", "exception").register(meterRegistry));
                log.error("短信發送異常: phone={}", maskPhone(phone), e);
            }
        });
    }

    @Override
    public Result sendSync(String phone, String content) {
        return channelRouter.send(phone, content);
    }
}

// 多通道路由器 (策略模式)
@Component
public class SmsChannelRouter {
    private final List channels = List.of(
        new AliyunSmsChannel(), 
        new TencentSmsChannel(), 
        new BackupSmsChannel() // 成本低、到達率低的備用通道
    );
    
    public Result send(String phone, String content) {
        for (SmsChannel channel : channels) {
            if (channel.isAvailable()) {
                Result result = channel.send(phone, content);
                if (result.isSuccess()) return result;
                log.warn("通道 {} 發送失敗,嘗試下一通道: {}", channel.getClass().getSimpleName(), result.getMessage());
            }
        }
        return Result.error("所有短信通道均不可用");
    }
}

五、控制器層接口設計 (RESTful + 參數校驗)


@RestController
@RequestMapping("/api/v1/sms")
@RequiredArgsConstructor
@Validated
public class SmsController {
    private final SmsVerificationService verificationService;

    @PostMapping("/send")
    public Result sendCode(
            @RequestParam @NotBlank(message = "手機號不能為空") @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手機號格式錯誤") String phone,
            @RequestHeader(value = "X-Forwarded-For", required = false) String ip,
            @RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
        // 獲取真實 IP (經過 Nginx 代理)
        String realIp = IpUtil.getRealIp(ip);
        return verificationService.sendCode(phone, realIp, deviceId);
    }

    @PostMapping("/verify")
    public Result verifyCode(
            @RequestParam @NotBlank String phone,
            @RequestParam @NotBlank @Size(min = 4, max = 8) String code) {
        return verificationService.verifyCode(phone, code);
    }
}

六、單元測試覆蓋核心邏輯 (JUnit 5 + Mockito)


@ExtendWith(MockitoExtension.class)
class SmsVerificationServiceTest {
    @Mock StringRedisTemplate redisTemplate;
    @Mock ValueOperations valueOps;
    @Mock SmsSender smsSender;
    @InjectMocks SmsVerificationService service;

    @Test
    void sendCode_success() {
        when(redisTemplate.opsForValue()).thenReturn(valueOps);
        when(valueOps.increment(anyString())).thenReturn(1L); // 限流計數器歸 1
        
        Result result = service.sendCode("13800138000", "127.0.0.1", "dev-123");
        
        assertTrue(result.isSuccess());
        verify(redisTemplate).opsForValue().set(anyString(), anyString(), eq(5L), eq(TimeUnit.MINUTES));
        verify(smsSender).sendAsync(eq("13800138000"), contains("驗證碼"));
    }

    @Test
    void verifyCode_success() {
        String phone = "13800138000";
        String code = "123456";
        // Mock Lua 腳本返回成功
        when(redisTemplate.execute(any(DefaultRedisScript.class), anyList(), anyVararg()))
            .thenReturn(List.of(1L, 1L)); // status=1, attempts=1
        
        Result result = service.verifyCode(phone, code);
        
        assertTrue(result.isSuccess());
        assertTrue(result.getData());
    }

    @Test
    void verifyCode_expired() {
        when(redisTemplate.execute(any(), anyList(), anyVararg()))
            .thenReturn(List.of(-1L, 0L)); // 過期
        
        Result result = service.verifyCode("13800138000", "123456");
        
        assertFalse(result.isSuccess());
        assertEquals("驗證碼已過期或不存在,請重新獲取", result.getMessage());
    }
}

七、生產部署配置清單

配置項推薦值說明
驗證碼長度6 位數字平衡安全性與用戶體驗,8 位字母數字混合更安全
有效期 TTL5 分鐘 (300 秒)業務場景可調 3-10 分鐘
同手機號小時限流10 次/小時防刷、控成本
同手機號天限流20 次/天合規要求
同 IP 分鐘限流5 次/分鐘防分佈式爬蟲
驗證失敗上限5 次超過即鎖定並刪除驗證碼
Redis Key 過期策略volatile-lru配合 TTL 自動清理
短信簽名/模板提前在服務商控制台報備變量參數需匹配模板
監控告警發送失敗率 > 1% 告警、發送延遲 P99 > 5s 告警、驗證失敗率異常升高告警Prometheus + Alertmanager

八、安全合規提示

  • 🇨🇳 **中國大陸**:必須使用 **三大運營商/工信部備案簽名**、模板審核、內容合規 (無營銷內容)、用戶授權、頻率限制 (《通信短信息服務管理規定》)。
  • 🇪🇺 **GDPR**:手機號屬個人數據,需加密存儲、最小化保留、提供刪除權。
  • 🔐 **傳輸加密**:API 必須 HTTPS,內網 RPC 亦建議 mTLS。
  • 📝 **審計日誌**:記錄發送/驗證請求 (脫敏手機號、IP、設備 ID、結果)、保留 6 個月以上。

總結

生產級短信驗證碼系統 = **`SecureRandom` 生成 + Redis 原子存儲/限流/驗證 (Lua) + 異步多通道發送 + 多維風控限流 + 完整可觀測 + 合規審計**。避免自行造輪子使用 `Math.random()`、無 TTL 存儲、同步發送阻塞線程、無限流導致短信轟炸成本失控。上述方案已在億級用戶系統驗證,可直接落地或作為技術選型參考。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved