登录安全体系:验证码、锁定、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 锁定策略
- 连续失败 5 次,锁定账号 30 分钟
- 锁定期间即使密码正确也无法登录
- 锁定计数在成功登录后清零
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 |
最佳实践
- 验证码一次性使用:验证后立即从 Redis 删除,防止重放攻击
- 锁定时间递增:可设计阶梯式锁定(5次锁30分钟,10次锁2小时,20次锁24小时)
- IP 获取注意代理:生产环境通常有 Nginx 反向代理,需从
X-Forwarded-For 获取真实 IP
- SM4 密钥管理:密钥不要硬编码,使用配置中心或密钥管理服务
- 登录日志审计:记录每次登录的 IP、时间、结果,便于安全审计
相关资源