登录安全体系:验证码、锁定、IP 控制组合拳

作者:忆笙智云官方 | 发布时间:2026-05-01 15:00 | 更新时间:2026-07-01 15:00

登录安全体系:验证码、锁定、IP 控制组合拳

引言

登录是系统安全的第一道防线。一个设计不当的登录接口,轻则被暴力破解用户密码,重则被 DDoS 攻击拖垮整个系统。在企业级应用中,登录安全绝不仅仅是"用户名 + 密码"那么简单。

本文将分享一套完整的登录安全方案,包含图形验证码、登录失败锁定、IP 黑白名单和 SM4 密码加密四个维度的组合防护策略。

一、登录安全威胁分析

1.1 常见攻击手段

mindmap
  root((登录安全威胁))
    暴力破解
      字典攻击
      彩虹表攻击
      分布式暴力破解
    撞库攻击
      利用泄露数据
      批量尝试
    DDoS攻击
      耗尽服务器资源
      占用数据库连接
    中间人攻击
      密码明文传输
      网络嗅探
    钓鱼攻击
      伪造登录页面
      社会工程学

1.2 防护策略矩阵

攻击手段 验证码 失败锁定 IP控制 密码加密
暴力破解 -
撞库攻击 - -
DDoS攻击 - - -
中间人攻击 - - -
钓鱼攻击 - - - -

二、图形验证码

2.1 验证码生成

使用 Hutool 的验证码工具生成图形验证码,验证码文本存储到 Redis 并设置过期时间:

/**
 * 验证码服务
 */
@Service
public class CaptchaService {

    private final RedisTemplate<String, Object> redisTemplate;

    /** 验证码Redis Key前缀 */
    private static final String CAPTCHA_KEY = "captcha:";

    /** 验证码过期时间(分钟) */
    private static final long CAPTCHA_EXPIRE_MINUTES = 5;

    /**
     * 生成图形验证码
     * @param uuid 唯一标识(前端生成)
     * @return Base64编码的验证码图片
     */
    public String generateCaptcha(String uuid) {
        // 生成验证码(宽120,高40,4个字符,干扰线数量)
        LineCaptcha captcha = CaptchaUtil.createLineCaptcha(120, 40, 4, 6);

        // 获取验证码文本(忽略大小写)
        String code = captcha.getCode();

        // 存储到Redis
        String key = CAPTCHA_KEY + uuid;
        redisTemplate.opsForValue().set(key, code.toLowerCase(),
            CAPTCHA_EXPIRE_MINUTES, TimeUnit.MINUTES);

        // 返回Base64图片
        return captcha.getImageBase64Data();
    }

    /**
     * 校验验证码
     * @param uuid 唯一标识
     * @param code 用户输入的验证码
     * @return 是否正确
     */
    public boolean validate(String uuid, String code) {
        String key = CAPTCHA_KEY + uuid;
        Object cached = redisTemplate.opsForValue().get(key);

        // 验证后立即删除,防止重复使用
        redisTemplate.delete(key);

        if (cached == null) {
            return false; // 验证码已过期
        }

        return cached.toString().equalsIgnoreCase(code);
    }
}

2.2 验证码接口

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final CaptchaService captchaService;

    /**
     * 获取验证码
     */
    @GetMapping("/captcha")
    public R<Map<String, String>> getCaptcha() {
        String uuid = UUID.randomUUID().toString().replace("-", "");
        String image = captchaService.generateCaptcha(uuid);

        Map<String, String> result = new HashMap<>();
        result.put("uuid", uuid);
        result.put("image", image);
        return R.ok(result);
    }
}

2.3 验证码流程

sequenceDiagram
    participant F as 前端
    participant S as 后端
    participant R as Redis

    F->>S: GET /api/auth/captcha
    S->>S: 生成验证码图片和文本
    S->>R: SET captcha:{uuid} = code, TTL=5min
    S-->>F: {uuid, base64Image}
    F->>F: 显示验证码图片
    F->>S: POST /api/auth/login {username, password, uuid, code}
    S->>R: GET captcha:{uuid}
    R-->>S: 返回验证码文本
    S->>S: 比对验证码(忽略大小写)
    S->>R: DEL captcha:{uuid}(一次性使用)
    alt 验证码正确
        S-->>F: 继续登录流程
    else 验证码错误
        S-->>F: 返回"验证码错误"
    end

三、登录失败锁定

3.1 锁定策略

3.2 Redis 存储设计

Key格式: login:fail:{username}
Value:  失败次数
TTL:    30分钟(与锁定时间一致)

Key格式: login:lock:{username}
Value:  锁定时间戳
TTL:    30分钟

3.3 锁定服务实现

/**
 * 登录锁定服务
 */
@Service
public class LoginLockService {

    private final RedisTemplate<String, Object> redisTemplate;

    /** 最大失败次数 */
    private static final int MAX_FAIL_COUNT = 5;

    /** 锁定时间(分钟) */
    private static final long LOCK_DURATION_MINUTES = 30;

    /** 失败计数Key前缀 */
    private static final String FAIL_KEY = "login:fail:";

    /** 锁定Key前缀 */
    private static final String LOCK_KEY = "login:lock:";

    /**
     * 检查账号是否被锁定
     * @param username 用户名
     * @return 锁定信息(null表示未锁定)
     */
    public String checkLocked(String username) {
        String key = LOCK_KEY + username;
        Object locked = redisTemplate.opsForValue().get(key);
        if (locked != null) {
            Long ttl = redisTemplate.getExpire(key, TimeUnit.MINUTES);
            return "账号已锁定,请" + ttl + "分钟后重试";
        }
        return null;
    }

    /**
     * 记录登录失败
     * @param username 用户名
     * @return 剩余尝试次数
     */
    public int recordFail(String username) {
        String key = FAIL_KEY + username;

        // 递增失败次数
        Long count = redisTemplate.opsForValue().increment(key);

        // 首次失败设置过期时间
        if (count != null && count == 1) {
            redisTemplate.expire(key, LOCK_DURATION_MINUTES, TimeUnit.MINUTES);
        }

        // 达到最大失败次数,锁定账号
        if (count != null && count >= MAX_FAIL_COUNT) {
            String lockKey = LOCK_KEY + username;
            redisTemplate.opsForValue().set(lockKey, System.currentTimeMillis(),
                LOCK_DURATION_MINUTES, TimeUnit.MINUTES);
            // 清除失败计数
            redisTemplate.delete(key);
            return 0;
        }

        return MAX_FAIL_COUNT - (count != null ? count.intValue() : 0);
    }

    /**
     * 登录成功,清除失败计数
     * @param username 用户名
     */
    public void clearFailCount(String username) {
        redisTemplate.delete(FAIL_KEY + username);
    }
}

3.4 登录流程整合

/**
 * 登录服务
 */
@Service
public class LoginService {

    private final LoginLockService loginLockService;
    private final CaptchaService captchaService;
    private final UserService userService;
    private final PasswordEncoder passwordEncoder;

    /**
     * 登录
     */
    public LoginResult login(LoginDTO dto) {
        // 1. 校验验证码
        if (!captchaService.validate(dto.getUuid(), dto.getCode())) {
            return LoginResult.fail("验证码错误或已过期");
        }

        // 2. 检查账号是否被锁定
        String lockMsg = loginLockService.checkLocked(dto.getUsername());
        if (lockMsg != null) {
            return LoginResult.fail(lockMsg);
        }

        // 3. 校验用户名密码
        User user = userService.getByUsername(dto.getUsername());
        if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
            int remaining = loginLockService.recordFail(dto.getUsername());
            if (remaining == 0) {
                return LoginResult.fail("密码错误次数过多,账号已锁定30分钟");
            }
            return LoginResult.fail("用户名或密码错误,还剩" + remaining + "次尝试机会");
        }

        // 4. 登录成功,清除失败计数
        loginLockService.clearFailCount(dto.getUsername());

        // 5. 生成Token
        String token = generateToken(user);

        return LoginResult.success(token);
    }
}

3.5 登录安全流程

flowchart TD
    A[用户提交登录] --> B[校验验证码]
    B -->|失败| C[返回: 验证码错误]
    B -->|成功| D[检查账号锁定]
    D -->|已锁定| E[返回: 账号已锁定]
    D -->|未锁定| F[校验用户名密码]
    F -->|正确| G[清除失败计数]
    G --> H[生成Token]
    H --> I[返回: 登录成功]
    F -->|错误| J[记录失败次数]
    J --> K{达到5次?}
    K -->|是| L[锁定账号30分钟]
    L --> M[返回: 账号已锁定]
    K -->|否| N[返回: 剩余N次机会]

    style C fill:#ffcdd2,stroke:#c62828
    style E fill:#ffcdd2,stroke:#c62828
    style I fill:#c8e6c9,stroke:#2e7d32
    style M fill:#ffcdd2,stroke:#c62828

四、IP 黑白名单

4.1 IP 控制策略

类型 说明 优先级
黑名单 禁止访问的IP 最高
白名单 允许访问的IP(为空则不限制) 次高
默认 不在名单中的IP 最低

4.2 IP 过滤器

/**
 * IP黑白名单过滤器
 */
@Component
public class IpFilter extends OncePerRequestFilter {

    private final RedisTemplate<String, Object> redisTemplate;

    /** IP黑名单Key */
    private static final String BLACK_KEY = "ip:blacklist";

    /** IP白名单Key */
    private static final String WHITE_KEY = "ip:whitelist";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain)
            throws ServletException, IOException {

        String clientIp = getClientIp(request);

        // 检查黑名单
        if (isBlacklisted(clientIp)) {
            response.setStatus(403);
            response.getWriter().write("IP已被禁止访问");
            return;
        }

        // 检查白名单(白名单不为空时才校验)
        if (!isWhitelisted(clientIp)) {
            response.setStatus(403);
            response.getWriter().write("IP不在允许访问列表中");
            return;
        }

        filterChain.doFilter(request, response);
    }

    /**
     * 判断IP是否在黑名单中
     */
    private boolean isBlacklisted(String ip) {
        return Boolean.TRUE.equals(
            redisTemplate.opsForSet().isMember(BLACK_KEY, ip));
    }

    /**
     * 判断IP是否在白名单中
     * 白名单为空时,所有IP都允许
     */
    private boolean isWhitelisted(String ip) {
        Long whiteSize = redisTemplate.opsForSet().size(WHITE_KEY);
        if (whiteSize == null || whiteSize == 0) {
            return true; // 白名单为空,不限制
        }
        return Boolean.TRUE.equals(
            redisTemplate.opsForSet().isMember(WHITE_KEY, ip));
    }

    /**
     * 获取客户端真实IP
     * 支持代理环境
     */
    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("X-Real-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        // 多级代理取第一个
        if (ip != null && ip.contains(",")) {
            ip = ip.split(",")[0].trim();
        }
        return ip;
    }
}

4.3 IP 管理接口

/**
 * IP黑白名单管理
 */
@RestController
@RequestMapping("/api/security/ip")
public class IpManageController {

    private final RedisTemplate<String, Object> redisTemplate;

    /**
     * 添加黑名单IP
     */
    @PostMapping("/blacklist/{ip}")
    public R<Void> addBlacklist(@PathVariable String ip) {
        redisTemplate.opsForSet().add("ip:blacklist", ip);
        return R.ok(null);
    }

    /**
     * 移除黑名单IP
     */
    @DeleteMapping("/blacklist/{ip}")
    public R<Void> removeBlacklist(@PathVariable String ip) {
        redisTemplate.opsForSet().remove("ip:blacklist", ip);
        return R.ok(null);
    }

    /**
     * 获取黑名单列表
     */
    @GetMapping("/blacklist")
    public R<Set<Object>> listBlacklist() {
        return R.ok(redisTemplate.opsForSet().members("ip:blacklist"));
    }
}

五、SM4 密码加密

5.1 为什么选择 SM4

对比项 AES SM4
密钥长度 128/192/256 bit 128 bit
安全性 高(国密标准)
合规性 国际标准 中国国家标准
适用场景 国际化项目 国内政企项目

5.2 SM4 工具类

/**
 * SM4加密解密工具
 * 基于BouncyCastle库实现
 */
public class SM4Util {

    private static final String ALGORITHM = "SM4";
    private static final String TRANSFORMATION = "SM4/CBC/PKCS5Padding";

    /**
     * SM4加密
     * @param data 明文
     * @param key 密钥(16字节)
     * @return Base64编码的密文
     */
    public static String encrypt(String data, String key) {
        try {
            // BouncyCastle提供SM4实现
            Security.addProvider(new BouncyCastleProvider());

            Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC");
            byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);

            // 使用密钥前16字节作为IV(生产环境建议使用随机IV)
            IvParameterSpec iv = new IvParameterSpec(
                Arrays.copyOf(keyBytes, 16));
            SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);

            cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);
            byte[] encrypted = cipher.doFinal(
                data.getBytes(StandardCharsets.UTF_8));

            return Base64.getEncoder().encodeToString(encrypted);
        } catch (Exception e) {
            throw new RuntimeException("SM4加密失败", e);
        }
    }

    /**
     * SM4解密
     * @param data Base64编码的密文
     * @param key 密钥(16字节)
     * @return 明文
     */
    public static String decrypt(String data, String key) {
        try {
            Security.addProvider(new BouncyCastleProvider());

            Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC");
            byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);

            IvParameterSpec iv = new IvParameterSpec(
                Arrays.copyOf(keyBytes, 16));
            SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);

            cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);
            byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(data));

            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("SM4解密失败", e);
        }
    }
}

5.3 前后端加密传输流程

sequenceDiagram
    participant F as 前端
    participant S as 后端
    participant DB as 数据库

    Note over F,S: 注册/修改密码流程
    F->>F: 用户输入明文密码
    F->>F: SM4加密(password, publicKey)
    F->>S: 传输加密后的密码
    S->>S: SM4解密得到明文
    S->>S: BCrypt哈希(明文密码)
    S->>DB: 存储BCrypt哈希值

    Note over F,S: 登录流程
    F->>F: 用户输入明文密码
    F->>F: SM4加密(password, publicKey)
    F->>S: 传输加密后的密码
    S->>S: SM4解密得到明文
    S->>DB: 取出BCrypt哈希值
    S->>S: BCrypt.matches(明文, 哈希)
    S-->>F: 登录结果

六、组合防护流程

flowchart TD
    A[登录请求] --> B[IP黑白名单检查]
    B -->|黑名单| C[403 拒绝访问]
    B -->|通过| D[验证码校验]
    D -->|失败| E[返回验证码错误]
    D -->|成功| F[账号锁定检查]
    F -->|已锁定| G[返回锁定提示]
    F -->|未锁定| H[SM4解密密码]
    H --> I[BCrypt密码校验]
    I -->|失败| J[记录失败次数]
    J --> K{达到5次?}
    K -->|是| L[锁定账号30分钟]
    K -->|否| M[返回剩余次数]
    I -->|成功| N[清除失败计数]
    N --> O[生成Token]
    O --> P[登录成功]

    style C fill:#ffcdd2,stroke:#c62828
    style E fill:#ffcdd2,stroke:#c62828
    style G fill:#ffcdd2,stroke:#c62828
    style L fill:#ffcdd2,stroke:#c62828
    style P fill:#c8e6c9,stroke:#2e7d32

结论与建议

四层防护总结

防护层 防御目标 实现方式
IP 黑白名单 DDoS、恶意 IP Redis Set + Spring Filter
图形验证码 自动化脚本、机器人 Hutool Captcha + Redis
失败锁定 暴力破解、撞库 Redis 计数 + TTL
SM4 加密 中间人窃听 BouncyCastle SM4 + BCrypt

最佳实践

相关资源