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
- Generate a unique token (e.g., UUID) upon successful login.
- Store user data in Redis using the token as the key.
- Set a TTL on the token key.
- The client passes the token in subsequent requests (e.g., in the
Authorizationheader). - 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:
- Cache null values with a short TTL.
- 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;
}
}