How Systems Block Login After 3 Failed Attempts: Spring Boot + Redis + Lua Implementation

This tutorial demonstrates implementing login attempt limiting using Spring Boot, Redis, and Lua scripts to block IP addresses after three failed password attempts, with automatic lockout expiration.

Architect's Guide
Architect's Guide
Architect's Guide
How Systems Block Login After 3 Failed Attempts: Spring Boot + Redis + Lua Implementation

Background

The article starts from a common user experience: after entering a wrong password three times, the system prevents further attempts. The author also notes that password reset often forbids reusing the old password. The core question: how does the system enforce this temporary lockout?

Problem Analysis

The author identifies three key questions:

Is lockout triggered only by wrong password, or by any wrong credential (username or password)?

Why does the lockout automatically lift after a few minutes?

How complex is the technical stack?

Research and consultation led to a solution using Spring Boot, Redis, and Lua scripts.

Technical Solution Overview

The approach locks the IP address (not the username) after any three failed attempts. Redis stores a counter per IP with a TTL; when the key expires, the lockout lifts automatically. Lua scripts ensure atomic read-increment-expire operations, avoiding race conditions and reducing network round-trips.

Frontend Implementation

A simple HTML login form posts to http://localhost:8080/login with username and password fields. The form uses basic CSS for styling.

<!DOCTYPE html>
<html>
<head>
  <title>登录页面</title>
  <style>
   body {
    background-color: #F5F5F5;
   }
   form {
    width: 300px;
    margin: 0 auto;
    margin-top: 100px;
    padding: 20px;
    background-color: white;
    border-radius: 5px;
    box-shadow: 0 0 10px rgba(0,0,0,0.2);
   }
   label {
    display: block;
    margin-bottom: 10px;
   }
   input[type=text], input[type=password] {
    border: none;
    padding: 10px;
    margin-bottom: 20px;
    border-radius: 5px;
    box-shadow: 0 0 5px rgba(0,0,0,0.1);
    width: 100%;
    box-sizing: border-box;
    font-size: 16px;
   }
   input[type=submit] {
    background-color: #30B0F0;
    color: white;
    border: none;
    padding: 10px;
    border-radius: 5px;
    box-shadow: 0 0 5px rgba(0,0,0,0.1);
    width: 100%;
    font-size: 16px;
    cursor: pointer;
   }
   input[type=submit]:hover {
    background-color: #1C90D6;
   }
  </style>
</head>
<body>
  <form action="http://localhost:8080/login" method="get">
   <label for="username">用户名</label>
   <input type="text" id="username" name="username" placeholder="请输入用户名" required>
   <label for="password">密码</label>
   <input type="password" id="password" name="password" placeholder="请输入密码" required>
   <input type="submit" value="登录">
  </form>
</body>
</html>

Backend Implementation

Project Structure & Dependencies

Maven project with Spring Boot 2.7.11, Java 8. Key dependencies: spring-boot-starter-web, spring-boot-starter-data-redis, jedis, aspectjweaver, commons-lang3, guava, lombok.

<dependencies>
  <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>redis.clients</groupId>
    <artifactId>jedis</artifactId>
  </dependency>
  <dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
  </dependency>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
  </dependency>
  <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>23.0</version>
  </dependency>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
  </dependency>
</dependencies>

Redis configuration in application.properties:

spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=
spring.redis.timeout=1000
spring.redis.jedis.pool.min-idle=0
spring.redis.jedis.pool.max-idle=500
spring.redis.jedis.pool.max-active=2000
spring.redis.jedis.pool.max-wait=10000

Annotation Definition

@LimitCount

targets methods, with attributes: name (description), key (resource key), prefix (key prefix), period (TTL in seconds, default 60), count (max attempts, default 3).

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LimitCount {
  String name() default "";
  String key() default "";
  String prefix() default "";
  int period() default 60;
  int count() default 3;
}

AOP Aspect: LimitCountAspect

Intercepts methods annotated with @LimitCount. Extracts client IP via IPUtil, builds a Redis key prefix_key_ip, executes a Lua script atomically, and proceeds only if the returned count <= limitCount; otherwise returns "接口访问超出频率限制".

@Slf4j
@Aspect
@Component
public class LimitCountAspect {
  private final RedisTemplate<String, Serializable> limitRedisTemplate;
  @Autowired
  public LimitCountAspect(RedisTemplate<String, Serializable> limitRedisTemplate) {
    this.limitRedisTemplate = limitRedisTemplate;
  }
  @Pointcut("@annotation(com.example.loginlimit.annotation.LimitCount)")
  public void pointcut() {}
  @Around("pointcut()")
  public Object around(ProceedingJoinPoint point) throws Throwable {
    HttpServletRequest request = ((ServletRequestAttributes)Objects.requireNonNull(
      RequestContextHolder.getRequestAttributes())).getRequest();
    MethodSignature signature = (MethodSignature)point.getSignature();
    Method method = signature.getMethod();
    LimitCount annotation = method.getAnnotation(LimitCount.class);
    String name = annotation.name();
    String key = annotation.key();
    String ip = IPUtil.getIpAddr(request);
    int limitPeriod = annotation.period();
    int limitCount = annotation.count();
    ImmutableList<String> keys = ImmutableList.of(StringUtils.join(annotation.prefix() + "_", key, ip));
    String luaScript = buildLuaScript();
    RedisScript<Number> redisScript = new DefaultRedisScript<>(luaScript, Number.class);
    Number count = limitRedisTemplate.execute(redisScript, keys, limitCount, limitPeriod);
    log.info("IP:{} 第 {} 次访问key为 {},描述为 [{}] 的接口", ip, count, keys, name);
    if (count != null && count.intValue() <= limitCount) {
      return point.proceed();
    } else {
      return "接口访问超出频率限制";
    }
  }
  private String buildLuaScript() {
    return "local c"
      + "
c = redis.call('get',KEYS[1])"
      + "
if c and tonumber(c) > tonumber(ARGV[1]) then"
      + "
return c;"
      + "
end"
      + "
c = redis.call('incr',KEYS[1])"
      + "
if tonumber(c) == 1 then"
      + "
redis.call('expire',KEYS[1],ARGV[2])"
      + "
end"
      + "
return c;";
  }
}

Lua Script Logic

The script performs the following steps:

Gets current key value.

If value exists and exceeds ARGV[1] (limitCount), returns immediately without incrementing.

Otherwise increments the key.

If the incremented value is 1 (first request), sets expiry to ARGV[2] (limitPeriod).

Returns the new count.

This ensures atomic check-and-increment with automatic TTL on first access.

IP Utility

IPUtil.getIpAddr

handles proxies by checking headers x-forwarded-for, Proxy-Client-IP, WL-Proxy-Client-IP, falling back to request.getRemoteAddr(), and normalizes IPv6 loopback to 127.0.0.1.

public static String getIpAddr(HttpServletRequest request) {
  String ip = request.getHeader("x-forwarded-for");
  if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
    ip = request.getHeader("Proxy-Client-IP");
  }
  if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
    ip = request.getHeader("WL-Proxy-Client-IP");
  }
  if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
    ip = request.getRemoteAddr();
  }
  return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
}

Redis Configuration

RedisConfig

defines beans for JedisPool, JedisConnectionFactory, two RedisTemplate instances (one generic, one for limiting with String key and Serializable value), StringRedisTemplate, CacheManager, and a custom KeyGenerator. Uses Jackson for JSON serialization.

Controller

LoginController

exposes GET /login with

@LimitCount(key="login", name="登录接口", prefix="limit")

. Validates hardcoded credentials (username="张三", password="123456").

@Slf4j
@RestController
public class LoginController {
  @GetMapping("/login")
  @LimitCount(key = "login", name = "登录接口", prefix = "limit")
  public String login(
      @RequestParam(required = true) String username,
      @RequestParam(required = true) String password, HttpServletRequest request) throws Exception {
    if (StringUtils.equals("张三", username) && StringUtils.equals("123456", password)) {
      return "登录成功";
    }
    return "账户名或密码错误";
  }
}

Demo Result

The article shows a screenshot of the lockout message after three failed attempts. The author notes that production systems often replace hard lockouts with CAPTCHA challenges, but the core rate-limiting logic remains applicable.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaAOPBackend DevelopmentRedisSpring Bootrate limitingLua scriptLogin Security
Architect's Guide
Written by

Architect's Guide

Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.