Implementing SMS Authentication and Shop Caching with Redis

SMS-Based Authentication

Sending Verification Codes via Session

When a user submits a phone number, the system validatse its format. If invalid, an error is returned. If valid, a verification code is generated, stored in the session, and sent via SMS.

Login and Registration Flow

The user inputs the code and phone number. The backend retrieves the stored code from the session and compares it with the user input. If they do not match, access is denied. If they match, the system queries the database for the user. If the user does not exist, a new account is created. The user object is then stored in the session for subsequent requests.

Validating Login Status

For incoming requests, the JsessionId from the cookie is used to retrieve the session. If no session is found, the request is blocked. If found, user details are stored in a ThreadLocal variable and the request proceeds.

Code: Sending the Code

@Override
public Result dispatchCode(String telephone, HttpSession session) {
    if (RegexUtils.isPhoneInvalid(telephone)) {
        return Result.fail("Invalid phone number format!");
    }
    String generatedCode = RandomUtil.randomNumbers(6);
    session.setAttribute("auth_code", generatedCode);
    log.debug("SMS dispatched. Code: {}", generatedCode);
    return Result.ok();
}

Code: Login Implementation

@Override
public Result authenticate(LoginFormDTO form, HttpSession session) {
    String telephone = form.getPhone();
    if (RegexUtils.isPhoneInvalid(telephone)) {
        return Result.fail("Invalid phone number format!");
    }
    Object storedCode = session.getAttribute("auth_code");
    String inputCode = form.getCode();
    if (storedCode == null || !storedCode.toString().equals(inputCode)) {
        return Result.fail("Invalid verification code");
    }
    User entity = query().eq("phone", telephone).one();
    if (entity == null) {
        entity = initializeUser(telephone);
    }
    session.setAttribute("loggedUser", entity);
    return Result.ok();
}

Login Interception

Tomcat handles requests using a thread pool. Each request is processed by a separate thread. ThreadLocal is used to isolate user data across these threads.

Interceptor Code:

public class AuthInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        HttpSession session = request.getSession();
        Object principal = session.getAttribute("loggedUser");
        if (principal == null) {
            response.setStatus(401);
            return false;
        }
        UserHolder.setPrincipal((UserDTO) principal);
        return true;
    }
}

Configuration:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuthInterceptor())
                .excludePathPatterns(
                    "/shop/**",
                    "/voucher/**",
                    "/upload/**",
                    "/user/code",
                    "/user/login"
                ).order(1);
    }
}

Hiding Sensitive Data

Instead of returning the full User object, use a UserDTO to exclude sensitive fields like passwords before storing in the session or returning in API responses.

Session Sharing Issues

In a multi-instance deployment, sessions are not shared between Tomcat instances. Storing sessions in Redis solves this by providing a centralized, shared storage.

Redis-Based Authentication Flow

  1. Generate a unique token (e.g., UUID) upon successful login.
  2. Store user data in Redis using the token as the key.
  3. Set a TTL on the token key.
  4. The client passes the token in subsequent requests (e.g., in the Authorization header).
  5. An interceptor validates the token and loads user data into ThreadLocal.

Code: Redis Login Implementation

@Override
public Result login(LoginFormDTO form, HttpSession session) {
    String telephone = form.getPhone();
    if (RegexUtils.isPhoneInvalid(telephone)) {
        return Result.fail("Invalid phone number format!");
    }
    String cachedCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_PREFIX + telephone);
    if (cachedCode == null || !cachedCode.equals(form.getCode())) {
        return Result.fail("Invalid verification code");
    }
    User entity = query().eq("phone", telephone).one();
    if (entity == null) {
        entity = initializeUser(telephone);
    }
    String token = UUID.randomUUID().toString(true);
    UserDTO dto = BeanUtil.copyProperties(entity, UserDTO.class);
    Map<String, Object> dataMap = BeanUtil.beanToMap(dto, new HashMap<>(),
        CopyOptions.create()
            .setIgnoreNullValue(true)
            .setFieldValueEditor((name, val) -> val.toString())
    );
    String redisKey = LOGIN_TOKEN_PREFIX + token;
    stringRedisTemplate.opsForHash().putAll(redisKey, dataMap);
    stringRedisTemplate.expire(redisKey, LOGIN_TIMEOUT, TimeUnit.MINUTES);
    return Result.ok(token);
}

Token Refresh Interceptor

A separate interceptor runs for all requests to refresh the token's TTL if the user is authenticated.

public class TokenRefreshInterceptor implements HandlerInterceptor {
    private final StringRedisTemplate redisTemplate;
    public TokenRefreshInterceptor(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) return true;
        String key = LOGIN_TOKEN_PREFIX + token;
        Map<Object, Object> map = redisTemplate.opsForHash().entries(key);
        if (map.isEmpty()) return true;
        UserDTO dto = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
        UserHolder.setPrincipal(dto);
        redisTemplate.expire(key, LOGIN_TIMEOUT, TimeUnit.MINUTES);
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        UserHolder.clear();
    }
}

Shop Data Caching

What is Caching?

Caching stores frequently accessed data in fast storage (like Redis) to reduce database load and improve response times.

Adding Cache for Shop Queries

Standard pattern: check Redis first. If data exists, return it. If not, query the database, store the result in Redis with a TTL, and return it.

Cache Update Strategies

Strategy Description Consistency Maintenance
Memory Eviction Redis auto-removes data when memory is full Low None
TTL Expiry Keys expire after a set time Medium Low
Active Update Update/delete cache when DB changes High High

Recommended Approach:

  • Read: Cache -> DB -> Update Cache.
  • Write: Update DB -> Delete Cache.

Solving Cache Penetration

Occurs when requests hit data that doesn't exist in cache or DB.

Solutions:

  1. Cache null values with a short TTL.
  2. Use a Bloom filter to check existence before querying.

Solving Cache Avalanche

Happens when many keys expire simultaneously or Redis goes down.

Solutions:

  • Add random jitter to TTLs.
  • Use Redis Cluster.
  • Implement circuit breakers and rate limiting.

Solving Hot Key (Cache Breakdown)

A highly accessed key expires, causing many requests to hit the DB at once.

Solution 1: Mutex Lock Only one thread rebuilds the cache while others wait or retry.

private boolean acquireLock(String key) {
    Boolean success = stringRedisTemplate.opsForValue()
        .setIfAbsent(key, "locked", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(success);
}
private void releaseLock(String key) {
    stringRedisTemplate.delete(key);
}

Solution 2: Logical Expiry Store an expiry time inside the cached data. If expired, return stale data immediately and rebuild the cache asynchronously.

@Data
public class CachedPayload {
    private LocalDateTime expiry;
    private Object content;
}

Query with Logical Expiry:

public Shop fetchWithLogicExpire(Long id) {
    String key = SHOP_CACHE_KEY + id;
    String json = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isBlank(json)) return null;
    CachedPayload payload = JSONUtil.toBean(json, CachedPayload.class);
    Shop shop = JSONUtil.toBean((JSONObject) payload.getContent(), Shop.class);
    if (payload.getExpiry().isAfter(LocalDateTime.now())) {
        return shop;
    }
    String lockKey = SHOP_LOCK_KEY + id;
    if (acquireLock(lockKey)) {
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                // Rebuild cache logic here
                saveShopToCache(id, 20L);
            } finally {
                releaseLock(lockKey);
            }
        });
    }
    return shop;
}

Redis Utility Class

Encapsulate common cache operations:

@Slf4j
@Component
public class RedisCacheUtil {
    private final StringRedisTemplate template;
    private static final ExecutorService executor = Executors.newFixedThreadPool(10);
    public RedisCacheUtil(StringRedisTemplate template) {
        this.template = template;
    }
    public void cacheWithTTL(String key, Object value, Long duration, TimeUnit unit) {
        template.opsForValue().set(key, JSONUtil.toJsonStr(value), duration, unit);
    }
    public <T, ID> T loadThroughCache(String prefix, ID id, Class<T> type, Function<ID, T> dbQuery, Long ttl, TimeUnit unit) {
        String key = prefix + id;
        String json = template.opsForValue().get(key);
        if (StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }
        if (json != null) return null; // Null cached
        T data = dbQuery.apply(id);
        if (data == null) {
            template.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
            return null;
        }
        this.cacheWithTTL(key, data, ttl, unit);
        return data;
    }
}

Tags: Redis Session Management SMS Authentication Caching Strategies Cache Penetration

Posted on Fri, 15 May 2026 17:03:20 +0000 by timolein