Tích hợp Shiro cho xác thực người dùng trong Spring Boot

Trong Spring Boot, việc tích hợp Shiro để xác thực người dùng chủ yếu bao gồm ba bước chính:

  1. Định nghĩa cấu hình Shiro (ShiroConfig): Tạo một lớp cấu hình Shiro và định nghĩa SecurityManager là một Bean. SecurityManager là trình quản lý bảo mật của Shiro, chịu trách nhiệm quản lý tất cả các đối tượng Subject.

  2. Cấu hình ShiroFilterFactoryBean: Trong lớp cấu hình Shiro, định nghĩa ShiroFilterFactoryBean. Đây là một factory cho các bộ lọc Shiro, phụ thuộc vào SecurityManager.

  3. Tùy chỉnh Realm: Triển khai một lớp Realm tùy chỉnh. Lớp này có hai phương thức quan trọng: doGetAuthorizationInfo() để lấy thông tin ủy quyền (vai trò, quyền hạn) và doGetAuthenticationInfo() để xác thực người dùng. Trong trường hợp này, chúng ta chỉ cần tập trung vào doGetAuthenticationInfo() cho mục đích xác thực.

1. Thêm các phụ thuộc

Để bắt đầu, bạn cần thêm các phụ thuộc cần thiết vào file pom.xml của dự án Spring Boot. Các phụ thuộc này bao gồm Shiro, MyBatis (hoặc một ORM khác), trình điều khiển cơ sở dữ liệu và Thymeleaf cho việc hiển thị giao diện người dùng.


<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis Spring Boot Starter -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.1</version>
</dependency>
<!-- Thymeleaf Starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Shiro Spring Starter -->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.6.0</version>
</dependency>
<!-- MySQL Connector -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<!-- Druid Connection Pool -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.22</version>
</dependency>

2. Cấu hình Shiro (ShiroConfig)

Tạo một lớp cấu hình Shiro với tên ShiroConfig.


@Configuration
public class ShiroConfig {

    /**
     * Cấu hình ShiroFilterFactoryBean để quản lý các yêu cầu HTTP.
     * @param securityManager SecurityManager được định nghĩa làm Bean.
     * @return ShiroFilterFactoryBean đã được cấu hình.
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // Thiết lập SecurityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // URL đăng nhập
        shiroFilterFactoryBean.setLoginUrl("/login");
        // URL sau khi đăng nhập thành công
        shiroFilterFactoryBean.setSuccessUrl("/index");
        // URL khi không có quyền truy cập
        shiroFilterFactoryBean.setUnauthorizedUrl("/403");

        // Định nghĩa chuỗi bộ lọc (filter chain)
        // Sử dụng LinkedHashMap để đảm bảo thứ tự thực thi
        LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();

        // Cho phép truy cập vào các tài nguyên tĩnh mà không cần xác thực
        filterChainDefinitionMap.put("/css/**", "anon");
        filterChainDefinitionMap.put("/js/**", "anon");
        filterChainDefinitionMap.put("/fonts/**", "anon");
        filterChainDefinitionMap.put("/img/**", "anon");

        // Cho phép truy cập vào trang đăng nhập mà không cần xác thực
        filterChainDefinitionMap.put("/login", "anon");

        // Cho phép truy cập vào trang giám sát Druid mà không cần xác thực
        filterChainDefinitionMap.put("/druid/**", "anon");

        // Cấu hình bộ lọc logout, Shiro đã cung cấp sẵn chức năng này
        filterChainDefinitionMap.put("/logout", "logout");

        // Cho phép truy cập vào trang chủ
        filterChainDefinitionMap.put("/", "anon");

        // Tất cả các URL khác đều yêu cầu xác thực
        // Nếu chưa xác thực, sẽ được chuyển hướng đến trang loginUrl
        filterChainDefinitionMap.put("/**", "authc");

        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    /**
     * Định nghĩa SecurityManager là một Bean.
     * @return SecurityManager đã được cấu hình.
     */
    @Bean
    public SecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // Thiết lập Realm tùy chỉnh
        securityManager.setRealm(shiroRealm());
        return securityManager;
    }

    /**
     * Định nghĩa Realm tùy chỉnh là một Bean.
     * @return Realm tùy chỉnh.
     */
    @Bean
    public ShiroRealm shiroRealm() {
        // Khởi tạo và trả về Realm tùy chỉnh của bạn
        return new ShiroRealm();
    }
}

Lưu ý về thứ tự trong Filter Chain: Các quy tắc trong filterChainDefinitionMap được áp dụng theo cơ chế "short-circuiting" (ngắt mạch), nghĩa là quy tắc nào khớp đầu tiên sẽ được ưu tiên thực thi. Ví dụ:


/user/**=anon
/user/aa=authc

Trong trường hợp này, yêu cầu tới /user/aa sẽ luôn được xử lý bởi quy tắc anon đầu tiên, và quy tắc authc thứ hai sẽ không bao giờ được thực thi.

Shiro cung cấp một số bộ lọc dựng sẵn:

Tên Bộ lọc Lớp Mô tả
anon org.apache.shiro.web.filter.authc.AnonymousFilter Cho phép truy cập ẩn danh. Thường dùng cho tài nguyên tĩnh. Ví dụ: /static/**=anon
authc org.apache.shiro.web.filter.authc.FormAuthenticationFilter Bộ lọc xác thực dựa trên form. Nếu người dùng chưa đăng nhập, sẽ được chuyển hướng đến trang đăng nhập. Ví dụ: /**=authc
authcBasic org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter Bộ lọc xác thực HTTP Basic.
logout org.apache.shiro.web.filter.authc.LogoutFilter Bộ lọc đăng xuất. Thuộc tính chính: redirectUrl (URL chuyển hướng sau khi đăng xuất). Ví dụ: /logout=logout
noSessionCreation org.apache.shiro.web.filter.session.NoSessionCreationFilter Bộ lọc không tạo phiên. Gọi subject.getSession(true) sẽ ném ra ngoại lệ DisabledSessionException.
perms org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter Bộ lọc ủy quyền dựa trên quyền hạn. Kiểm tra xem người dùng có tất cả các quyền được chỉ định hay không. Ví dụ: /user/**=perms["user:create"]
port org.apache.shiro.web.filter.authz.PortFilter Bộ lọc cổng. Chỉ cho phép truy cập qua một cổng cụ thể. Ví dụ: /test= port[80]
rest org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter Bộ lọc theo phong cách RESTful. Tự động xây dựng chuỗi quyền hạn dựa trên phương thức HTTP. Ví dụ: /users=rest[user] sẽ tạo ra các quyền như user:read, user:create,...
roles org.apache.shiro.web.filter.authz.RolesAuthorizationFilter Bộ lọc ủy quyền dựa trên vai trò. Kiểm tra xem người dùng có tất cả các vai trò được chỉ định hay không. Ví dụ: /admin/**=roles[admin]
ssl org.apache.shiro.web.filter.authz.SslFilter Bộ lọc SSL. Chỉ cho phép truy cập qua HTTPS.
user org.apache.shiro.web.filter.authc.UserFilter Bộ lọc người dùng. Cho phép truy cập nếu người dùng đã được xác thực hoặc được "ghi nhớ" (remember me). Ví dụ: /**=user

Sau khi cấu hình ShiroConfig, chúng ta cần triển khai Realm và đưa nó vào SecurityManager.

3. Realm tùy chỉnh

Tạo một lớp ShiroRealm bằng cách kế thừa từ AuthorizingRealm và triển khai các phương thức cần thiết. Trong trường hợp này, chúng ta chỉ cần doGetAuthenticationInfo() để xác thực người dùng.


public class ShiroRealm extends AuthorizingRealm {

    @Autowired
    private UserMapper userMapper; // Giả định UserMapper đã được Inject

    /**
     * Phương thức này được gọi để lấy thông tin ủy quyền (vai trò, quyền hạn).
     * Hiện tại chưa được triển khai cho mục đích xác thực.
     * @param principalCollection Tập hợp các đối tượng principal.
     * @return Thông tin ủy quyền.
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        // Chưa triển khai cho mục đích xác thực
        return null;
    }

    /**
     * Phương thức này được gọi để xác thực người dùng.
     * @param token Đối tượng xác thực (UsernamePasswordToken).
     * @return Thông tin xác thực.
     * @throws AuthenticationException Nếu xác thực thất bại.
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        // Lấy tên người dùng từ token
        String username = (String) token.getPrincipal();
        // Lấy mật khẩu từ token
        String password = new String((char[]) token.getCredentials());

        System.out.println("Đang xác thực người dùng: " + username + " - ShiroRealm.doGetAuthenticationInfo");

        // Tìm kiếm người dùng trong cơ sở dữ liệu
        User user = userMapper.findByUserName(username);

        // Nếu không tìm thấy người dùng
        if (user == null) {
            throw new UnknownAccountException("Tên đăng nhập hoặc mật khẩu không đúng!");
        }

        // Nếu mật khẩu không khớp
        if (!password.equals(user.getPassword())) {
            throw new IncorrectCredentialsException("Tên đăng nhập hoặc mật khẩu không đúng!");
        }

        // Nếu tài khoản bị khóa
        if ("0".equals(user.getStatus())) {
            throw new LockedAccountException("Tài khoản đã bị khóa, vui lòng liên hệ quản trị viên!");
        }

        // Tạo đối tượng SimpleAuthenticationInfo
        // Tham số cuối cùng là tên của Realm (quan trọng khi sử dụng nhiều Realm)
        SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user, password, getName());
        return authenticationInfo;
    }
}

Shiro cung cấp một tập hợp các ngoại lệ xác thực sẵn có, giúp bạn xử lý các trường hợp lỗi cụ thể:


try {
    currentUser.login(token);
} catch (UnknownAccountException uae) {
    // Xử lý trường hợp tài khoản không tồn tại
    System.err.println("Tài khoản không tồn tại!");
} catch (IncorrectCredentialsException ice) {
    // Xử lý trường hợp sai mật khẩu
    System.err.println("Sai mật khẩu!");
} catch (LockedAccountException lae) {
    // Xử lý trường hợp tài khoản bị khóa
    System.err.println("Tài khoản bị khóa!");
} catch (ExcessiveAttemptsException eae) {
    // Xử lý trường hợp cố gắng đăng nhập quá nhiều lần
    System.err.println("Đã cố gắng đăng nhập quá nhiều lần!");
} // ... các ngoại lệ khác
catch (AuthenticationException ae) {
    // Xử lý các lỗi xác thực không mong muốn khác
    System.err.println("Lỗi xác thực không mong muốn!");
}

Lưu ý quan trọng: Mặc dù bạn có thể bắt các ngoại lệ cụ thể để đưa ra thông báo chi tiết cho người dùng, nhưng vì lý do bảo mật, tốt nhất là chỉ hiển thị một thông báo chung chung như "Tên đăng nhập hoặc mật khẩu không đúng" khi đăng nhập thất bại. Điều này giúp ngăn chặn việc lộ thông tin về cấu trúc cơ sở dữ liệu cho kẻ tấn công.

Trong ví dụ này, UserMapper là lớp truy cập dữ liệu (DAO). Theo quy trình chuẩn, bạn nên có một lớp dịch vụ (Service) ở giữa Realm và Mapper. Tuy nhiên, để đơn giản hóa, chúng ta bỏ qua lớp dịch vụ trong ví dụ này.

4. Lớp Dữ liệu

4.1. Tạo bảng người dùng

Tạo bảng t_user trong cơ sở dữ liệu để lưu trữ thông tin người dùng.


-- Cấu trúc bảng cho t_user
DROP TABLE IF EXISTS `t_user`;
CREATE TABLE `t_user`  (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'Primary Key',
  `username` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'Username',
  `passwd` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'Password',
  `create_time` datetime(0) DEFAULT CURRENT_TIMESTAMP COMMENT 'Creation Time',
  `status` varchar(1) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'Status: 1 = Active, 0 = Locked',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- Dữ liệu mẫu
INSERT INTO `t_user` VALUES (1, 'wno704', '11e4cee149fc05e970cd779988d866a8', '2020-08-19 17:20:21', '1');
INSERT INTO `t_user` VALUES (2, 'test', 'b7f4b4c54a0beeeeb9eb299d9dbb5157', '2020-08-19 17:20:21', '1');

Cấu hình chi tiết về nguồn dữ liệu (datasource) không được trình bày ở đây, bạn có thể tham khảo các tài liệu về tích hợp Spring Boot với MyBatis.

4.2. Lớp Entity (Mô hình)

Lớp User tương ứng với bảng t_user.


@Getter
@Setter
public class User implements Serializable {
    private static final long serialVersionUID = -5440372534300871944L;

    private Integer id;
    private String userName;
    private String password;
    private Date createTime;
    private String status; // '1' for active, '0' for locked
}

4.3. Định nghĩa Mapper

Tạo interface UserMapper để tương tác với cơ sở dữ liệu.


@Mapper // Hoặc sử dụng @Repository và cấu hình Spring
public interface UserMapper {
    /**
     * Tìm kiếm người dùng theo tên đăng nhập.
     * @param userName Tên đăng nhập.
     * @return Đối tượng User nếu tìm thấy, ngược lại null.
     */
    User findByUserName(String userName);
}

4.4. File XML Mapper

Triển khai các câu lệnh SQL trong file XML tương ứng.


<?xml version="1.0" encoding="UTF-8"?>
<mapper namespace="com.yourpackage.mapper.UserMapper"> <!-- Thay đổi package cho phù hợp -->

    <resultMap id="UserResultMap" type="com.yourpackage.model.User"> <!-- Thay đổi package -->
        <id column="id" property="id" />
        <result column="username" property="userName" />
        <result column="passwd" property="password" />
        <result column="create_time" property="createTime" />
        <result column="status" property="status" />
    </resultMap>

    <select id="findByUserName" resultMap="UserResultMap">
        SELECT id, username, passwd, create_time, status
        FROM t_user
        WHERE username = #{userName}
    </select>

</mapper>

Sau khi hoàn tất lớp dữ liệu, chúng ta cần chuẩn bị các trang giao diện.

5. Chuẩn bị Giao diện

5.1. Trang login.html

Tạo trang login.html để người dùng nhập thông tin đăng nhập.


<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Đăng nhập</title>
    <link rel="stylesheet" th:href="@{/css/login.css}" type="text/css">
    <script th:src="@{/js/jquery-1.11.1.min.js}"></script>
</head>
<body>
    <div class="login-page">
      <div class="form">
          <input type="text" placeholder="Tên đăng nhập" name="username" required="required"/>
          <input type="password" placeholder="Mật khẩu" name="password" required="required"/>
          <button onclick="login()">Đăng nhập</button>
      </div>
    </div>
</body>

<script th:inline="javascript">
    // Lấy context path của ứng dụng
    var ctx = [[@{/}]];

    function login() {
        var username = $("input[name='username']").val();
        var password = $("input[name='password']").val();

        // Gửi yêu cầu POST đến /login để xác thực
        $.ajax({
            type: "post",
            url: ctx + "login", // Sử dụng context path
            data: {"username": username, "password": password},
            dataType: "json",
            success: function (response) {
                if (response.code === 0) { // Giả định mã code 0 là thành công
                    window.location.href = ctx + 'index'; // Chuyển hướng đến trang index
                } else {
                    alert(response.msg); // Hiển thị thông báo lỗi
                }
            },
            error: function() {
                alert("Lỗi kết nối đến máy chủ.");
            }
        });
    }
</script>
</html>

5.2. Trang index.html

Tạo trang chủ index.html hiển thị thông tin người dùng đã đăng nhập và liên kết đăng xuất.


<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Trang chủ</title>
</head>
<body>
    <p>Xin chào! <span th:text="${user.userName}"></span></p>
    <a th:href="@{/logout}">Đăng xuất</a>
</body>
</html>

Sau khi chuẩn bị giao diện, chúng ta sẽ viết Controller.

6. Controller

Tạo lớp LoginController để xử lý các yêu cầu liên quan đến đăng nhập và đăng xuất.


@Controller
public class LoginController {

    /**
     * Hiển thị trang đăng nhập.
     * @return Tên view login.
     */
    @GetMapping("/login")
    public String showLoginPage() {
        return "login";
    }

    /**
     * Xử lý yêu cầu đăng nhập qua AJAX.
     * @param username Tên đăng nhập.
     * @param password Mật khẩu (chưa mã hóa).
     * @return Đối tượng ResponseBo chứa kết quả đăng nhập.
     */
    @PostMapping("/login")
    @ResponseBody
    public ResponseBo handleLogin(String username, String password) {
        // Mã hóa mật khẩu trước khi tạo token
        // Giả định MD5Utils là một lớp tiện ích để mã hóa
        password = MD5Utils.encrypt(username, password);

        // Tạo đối tượng token cho Shiro
        UsernamePasswordToken token = new UsernamePasswordToken(username, password);
        Subject subject = SecurityUtils.getSubject();

        try {
            // Thực hiện đăng nhập
            subject.login(token);
            // Đăng nhập thành công
            return ResponseBo.ok();
        } catch (UnknownAccountException e) {
            // Tài khoản không tồn tại
            return ResponseBo.error(e.getMessage());
        } catch (IncorrectCredentialsException e) {
            // Sai mật khẩu
            return ResponseBo.error(e.getMessage());
        } catch (LockedAccountException e) {
            // Tài khoản bị khóa
            return ResponseBo.error(e.getMessage());
        } catch (AuthenticationException e) {
            // Lỗi xác thực chung
            return ResponseBo.error("Xác thực thất bại!");
        }
    }

    /**
     * Chuyển hướng từ root path đến /index.
     * @return Lời nhắn chuyển hướng.
     */
    @RequestMapping("/")
    public String redirectToIndex() {
        return "redirect:/index";
    }

    /**
     * Hiển thị trang index sau khi đăng nhập thành công.
     * Lấy thông tin người dùng từ Subject và truyền vào Model.
     * @param model Model để thêm thuộc tính.
     * @return Tên view index.
     */
    @RequestMapping("/index")
    public String showIndexPage(Model model) {
        // Lấy đối tượng User từ Subject principal
        User user = (User) SecurityUtils.getSubject().getPrincipal();
        model.addAttribute("user", user);
        return "index";
    }

    // Có thể thêm một controller cho /403 nếu cần
    // @RequestMapping("/403")
    // public String unauthorized() {
    //     return "403"; // Tên file view 403.html
    // }
}

// Giả định lớp ResponseBo có cấu trúc tương tự
class ResponseBo {
    private int code;
    private String msg;

    public static ResponseBo ok() {
        ResponseBo rb = new ResponseBo();
        rb.setCode(0);
        rb.setMsg("Success");
        return rb;
    }

    public static ResponseBo error(String msg) {
        ResponseBo rb = new ResponseBo();
        rb.setCode(1);
        rb.setMsg(msg);
        return rb;
    }

    public int getCode() { return code; }
    public void setCode(int code) { this.code = code; }
    public String getMsg() { return msg; }
    public void setMsg(String msg) { this.msg = msg; }
}

// Giả định lớp MD5Utils tồn tại
class MD5Utils {
    public static String encrypt(String username, String password) {
        // Thực hiện mã hóa MD5 (chỉ là ví dụ)
        return password; // Trả về mật khẩu gốc vì ví dụ này không cần mã hóa thực tế
    }
}

Sau khi đăng nhập thành công, theo cấu hình shiroFilterFactoryBean.setSuccessUrl("/index"), hệ thống sẽ tự động chuyển hướng đến URL /index.

7. Kiểm thử

Sau khi hoàn thành các bước trên, bạn có thể khởi động ứng dụng và kiểm thử các chức năng:

  • Truy cập các URL như http://localhost:8080/web/, http://localhost:8080/web/index, http://localhost:8080/web/some/other/path. Bạn sẽ thấy ứng dụng tự động chuyển hướng đến trang đăng nhập (/login) nếu chưa xác thực.
  • Nhập thông tin đăng nhập sai.
  • Thử đăng nhập với tài khoản bị khóa (ví dụ: tài khoản test có status = '0').
  • Đăng nhập với thông tin đúng.
  • Nhấn vào liên kết "Đăng xuất". Theo cấu hình filterChainDefinitionMap.put("/logout", "logout"), Shiro sẽ tự động xử lý việc đăng xuất và chuyển hướng về trang chủ (/ hoặc /index).

Quá trình tích hợp Shiro để xác thực người dùng trong Spring Boot đến đây là hoàn tất.

Thẻ: Spring Boot shiro Xác thực người dùng Spring Security mybatis

Đăng vào ngày 3 tháng 11 lúc 14:14