Triển khai đăng nhập bằng mã SMS và quản lý phiên với Redis

Hệ thống sử dụng bộ công nghệ bao gồm Redis, Spring Boot, MySQL, Lombok, Mybatis-plus và Hutool để triển khai các chức năng cốt lõi như đăng nhập bằng mã SMS, truy vấn thông tin thương nhân có hỗ trợ bộ nhớ đệm, đấu giá giảm giá theo thời gian thực, tìm kiếm thương nhân lân cận, điểm danh người dùng, theo dõi bạn bè và người dùng đánh giá địa điểm.

1. Đăng nhập bằng mã SMS

Tính năng đăng nhập này sử dụng Redis để quản lý phiên (session) thay vì cơ chế session truyền thống của máy chủ web, nhằm đảm bảo khả năng mở rộng và chia sẻ trạng thái đăng nhập trên nhiều instance của ứng dụng.

1.1. Quy trình gửi mã xác thực

Khi người dùng nhập số điện thoại, hệ thống sẽ kiểm tra tính hợp lệ của định dạng số điện thoại. Nếu hợp lệ, một mã xác thực gồm 6 chữ số sẽ được tạo ngẫu nhiên. Mã này sau đó được lưu trữ trong Redis với một khóa duy nhất liên kết với số điện thoại và đồng thời được gửi đến người dùng qua tin nhắn SMS (trong môi trường phát triển, chức năng gửi SMS được mô phỏng bằng cách in mã ra console).

1.2. Đăng nhập và đăng ký

Người dùng nhập số điện thoại và mã xác thực đã nhận được. Hệ thống sẽ truy vấn mã xác thực từ Redis dựa trên số điện thoại. Nếu mã nhập vào không khớp với mã trong Redis, yêu cầu đăng nhập sẽ bị từ chối. Nếu mã khớp, hệ thống sẽ truy vấn cơ sở dữ liệu MySQL để tìm người dùng tương ứng. Nếu người dùng chưa tồn tại, một tài khoản mới sẽ được tạo. Sau đó, một mã định danh phiên (token) duy nhất sẽ được tạo ra. Thông tin người dùng (đã loại bỏ các trường nhạy cảm) sẽ được lưu trữ trong Redis dưới dạng một hash map, với khóa là token. Thời gian tồn tại của token cũng được thiết lập trong Redis.

Mã nguồn ví dụ gửi mã xác thực:


@Override
public Result sendSmsCode(String phoneNumber) {
    // 1. Kiểm tra định dạng số điện thoại
    if (!PhoneValidator.isValid(phoneNumber)) {
        // 2. Trả về lỗi nếu không hợp lệ
        return Result.failure("Invalid phone number format!");
    }
    // 3. Tạo mã xác thực 6 chữ số
    String verificationCode = CodeGenerator.generateNumericCode(6);

    // 4. Lưu mã xác thực vào Redis với khóa bao gồm số điện thoại
    String redisKey = REDIS_CODE_PREFIX + phoneNumber;
    redisTemplate.opsForValue().set(redisKey, verificationCode, CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES);

    // 5. Gửi mã xác thực (mô phỏng gửi SMS)
    log.info("SMS verification code sent: {}", verificationCode);

    // Trả về kết quả thành công
    return Result.success();
}
    

Mã nguồn ví dụ xử lý đăng nhập:


@Override
public Result userLogin(LoginRequest loginRequest) {
    String phoneNumber = loginRequest.getPhoneNumber();
    String providedCode = loginRequest.getSmsCode();

    // 1. Kiểm tra định dạng số điện thoại
    if (!PhoneValidator.isValid(phoneNumber)) {
        return Result.failure("Invalid phone number format!");
    }

    // 2. Lấy mã xác thực từ Redis
    String storedCode = redisTemplate.opsForValue().get(REDIS_CODE_PREFIX + phoneNumber);

    // 3. Xác thực mã
    if (storedCode == null || !storedCode.equals(providedCode)) {
        return Result.failure("Invalid verification code!");
    }

    // 4. Truy vấn người dùng theo số điện thoại
    User existingUser = userService.getUserByPhoneNumber(phoneNumber);

    // 5. Nếu người dùng chưa tồn tại, tạo mới
    if (existingUser == null) {
        existingUser = userService.createDefaultUser(phoneNumber);
    }

    // 6. Tạo token xác thực duy nhất
    String sessionToken = TokenGenerator.generateUniqueToken();

    // 7. Chuyển đổi thông tin người dùng sang DTO và chuẩn bị lưu vào Redis
    UserDTO userDto = UserMapper.toUserDTO(existingUser);
    Map userDataMap = UserMapper.toMapForRedis(userDto);

    // 8. Lưu thông tin người dùng vào Redis sử dụng token làm khóa
    String redisUserKey = REDIS_USER_SESSION_PREFIX + sessionToken;
    redisTemplate.opsForHash().putAll(redisUserKey, userDataMap);

    // 9. Thiết lập thời gian sống cho token trong Redis
    redisTemplate.expire(redisUserKey, USER_SESSION_TIMEOUT_MINUTES, TimeUnit.MINUTES);

    // 10. Trả về token cho client
    return Result.success(sessionToken);
}
    

1.3. Triển khai Interceptor kiểm tra trạng thái đăng nhập

Một bộ lọc (interceptor) được thêm vào để kiểm tra xem yêu cầu có chứa token hợp lệ hay không. Token này được lấy từ header 'Authorization' của request. Nếu token tồn tại và hợp lệ (thông tin người dùng còn trong Redis), hệ thống sẽ lấy thông tin người dùng từ Redis, làm mới thời gian sống của token và lưu thông tin người dùng vào một đối tượng ThreadLocal để các phần khác của ứng dụng có thể truy cập dễ dàng. Nếu token không hợp lệ hoặc hết hạn, yêu cầu sẽ bị chặn.

Mã nguồn ví dụ Interceptor làm mới token:


public class SessionRefreshInterceptor implements HandlerInterceptor {

    private final RedisTemplate redisTemplate;

    public SessionRefreshInterceptor(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. Lấy token từ header Authorization
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.isBlank(authHeader)) {
            // Không có token, tiếp tục xử lý (có thể bị chặn bởi interceptor khác nếu cần đăng nhập)
            return true;
        }

        // 2. Truy vấn thông tin người dùng từ Redis dựa trên token
        String sessionKey = REDIS_USER_SESSION_PREFIX + authHeader;
        Map userMap = redisTemplate.opsForHash().entries(sessionKey);

        // 3. Kiểm tra xem token có hợp lệ và thông tin người dùng có tồn tại không
        if (userMap.isEmpty()) {
            // Token không hợp lệ hoặc hết hạn, tiếp tục xử lý (có thể bị chặn bởi interceptor khác)
            return true;
        }

        // 4. Chuyển đổi dữ liệu từ Redis sang đối tượng UserDTO
        UserDTO currentUser = UserMapper.fromRedisMap(userMap);

        // 5. Lưu thông tin người dùng vào ThreadLocal
        UserContextHolder.setCurrentUser(currentUser);

        // 6. Làm mới thời gian sống của token trong Redis
        redisTemplate.expire(sessionKey, USER_SESSION_TIMEOUT_MINUTES, TimeUnit.MINUTES);

        // 7. Cho phép yêu cầu tiếp tục xử lý
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // Xóa thông tin người dùng khỏi ThreadLocal sau khi xử lý xong request
        UserContextHolder.clearCurrentUser();
    }
}
    

Cấu hình Interceptor trong Spring:


@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final RedisTemplate redisTemplate;

    public WebMvcConfig(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // Interceptor để làm mới thời gian sống của phiên đăng nhập
        registry.addInterceptor(new SessionRefreshInterceptor(redisTemplate))
                .addPathPatterns("/**") // Áp dụng cho tất cả các đường dẫn
                .order(0); // Ưu tiên thực thi trước

        // Interceptor chính để kiểm tra trạng thái đăng nhập
        registry.addInterceptor(new LoginStatusCheckInterceptor())
                .excludePathPatterns(
                        "/api/public/**", // Các endpoint công khai không yêu cầu đăng nhập
                        "/auth/send-code",
                        "/auth/login"
                )
                .order(1); // Thực thi sau interceptor làm mới token
    }
}
    

Interceptor LoginStatusCheckInterceptor sẽ kiểm tra xem UserContextHolder có chứa thông tin người dùng hay không. Nếu không có, nó sẽ trả về mã lỗi 401 (Unauthorized).

1.4. Vấn đề chia sẻ phiên (Session Sharing) trong môi trường Cluster

Khi triển khai ứng dụng trên nhiều máy chủ (cluster), việc sử dụng session mặc định của máy chủ web (như Tomcat) sẽ gặp vấn đề. Mỗi máy chủ sẽ lưu trữ một bản sao của session, gây tốn bộ nhớ và có thể dẫn đến độ trễ trong việc đồng bộ dữ liệu session giữa các máy chủ.

1.5. Sử dụng Redis thay thế Session

Việc chuyển đổi sang Redis làm nơi lưu trữ phiên giúp giải quyết vấn đề chia sẻ dữ liệu session giữa các máy chủ trong môi trường cluster. Mỗi yêu cầu đến sẽ được xác thực dựa trên token, và thông tin người dùng được truy xuất tập trung từ Redis.

1.6. Giải quyết vấn đề làm mới trạng thái đăng nhập

Interceptor SessionRefreshInterceptor được thiết kế để xử lý việc làm mới thời gian sống của token đăng nhập trong Redis. Điều này đảm bảo rằng người dùng không bị đăng xuất đột ngột nếu họ đang hoạt động trên hệ thống, ngay cả khi thời gian chờ ban đầu đã hết. Sau khi interceptor này xử lý, interceptor LoginStatusCheckInterceptor chỉ cần kiểm tra sự tồn tại của người dùng trong UserContextHolder để quyết định cho phép hay chặn yêu cầu.

2. Caching truy vấn thông tin thương nhân

Để cải thiện hiệu suất và trải nghiệm người dùng, hệ thống sử dụng Redis làm lớp cache cho dữ liệu thương nhân. Thay vì truy vấn trực tiếp từ cơ sở dữ liệu MySQL vốn có thể chậm, dữ liệu thương nhân thường xuyên được truy cập sẽ được lưu trữ trong bộ nhớ của Redis. Điều này giúp giảm đáng kể thời gian phản hồi cho các yêu cầu đọc dữ liệu.

Quy trình triển khai caching cho thông tin thương nhân sẽ bao gồm việc kiểm tra Redis trước khi truy vấn MySQL. Nếu dữ liệu có sẵn trong Redis và còn hiệu lực, nó sẽ được trả về ngay lập tức. Nếu không, hệ thống sẽ truy vấn MySQL, lưu kết quả vào Redis với thời gian sống nhất định, sau đó mới trả về cho người dùng.

Thẻ: Java Spring Boot Redis mysql MyBatis-Plus

Đăng vào ngày 29 tháng 8 lúc 20:02