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:
-
Định nghĩa cấu hình Shiro (ShiroConfig): Tạo một lớp cấu hình Shiro và định nghĩa
SecurityManagerlà một Bean.SecurityManagerlà 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ượngSubject. -
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àoSecurityManager. -
Tùy chỉnh Realm: Triển khai một lớp
Realmtù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àodoGetAuthenticationInfo()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
testcó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.