Hướng Dẫn Phân Quyền Chi Tiết
Tài liệu step-by-step giải thích toàn bộ hệ thống phân quyền trong dự án webbansach_backend — từ database, entity, service cho đến cấu hình Security Filter Chain.
Dự án sử dụng Spring Security với xác thực HTTP Basic Auth (username + password) và phân quyền theo Role. Không dùng JWT token.
Database Layer
Bảng quyen, nguoi_dung và bảng trung gian nguoidung_quyen lưu trữ quan hệ nhiều-nhiều giữa user và quyền.
Service Layer
UserServiceImpl implement interface UserDetailsService của Spring Security — đây là cầu nối để Spring biết cách nạp thông tin user từ DB.
Security Layer
SecurityConfiguration cấu hình SecurityFilterChain — quy định endpoint nào được truy cập tự do, endpoint nào cần đăng nhập, endpoint nào cần role ADMIN.
Khi client gửi request, Spring Security xử lý theo thứ tự sau:
+ Basic Auth header
có cần auth không
từ UserService
trong DB theo username
đã mã hóa
với endpoint rule
Đây là nền tảng của hệ thống phân quyền. Hai entity này liên kết với nhau qua quan hệ Many-to-Many — một user có thể có nhiều quyền, một quyền có thể thuộc nhiều user.
Entity Quyen.java — Bảng lưu danh sách quyền
Mỗi bản ghi trong bảng quyen là một role, ví dụ: ROLE_ADMIN, ROLE_USER. Quy tắc Spring Security: tên quyền phải bắt đầu bằng ROLE_ khi dùng hasAnyAuthority().
@Entity @Data @Table(name = "quyen") public class Quyen { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "ma_quyen") private int maQuyen; @Column(name = "ten_quyen") private String tenQuyen; // VD: "ROLE_ADMIN", "ROLE_USER" // Quan hệ ngược (inverse side) - Many-to-Many với NguoiDung @ManyToMany(fetch = FetchType.LAZY, cascade = {...}) @JoinTable(name = "nguoidung_quyen", joinColumns = @JoinColumn(name = "ma_quyen"), inverseJoinColumns = @JoinColumn(name = "ma_nguoi_dung")) private List<NguoiDung> danhSachNguoiDung; }
Entity NguoiDung.java — Bảng người dùng
Đây là owning side của quan hệ Many-to-Many. Chú ý fetch = FetchType.EAGER — khi load NguoiDung thì danhSachQuyen sẽ được load ngay lập tức, điều này cần thiết vì Spring Security phải đọc quyền ngay khi xác thực.
// ⭐ Quan trọng: EAGER loading - bắt buộc cho phân quyền @ManyToMany(fetch = FetchType.EAGER, cascade = {...}) @JoinTable(name = "nguoidung_quyen", joinColumns = @JoinColumn(name = "ma_nguoi_dung"), inverseJoinColumns = @JoinColumn(name = "ma_quyen")) private List<Quyen> danhSachQuyen;
Nếu để LAZY, khi Spring Security gọi loadUserByUsername() xong thì Hibernate session đã đóng → không thể load quyền → lỗi LazyInitializationException.
Hai repository quan trọng nhất cho phân quyền:
NguoiDungRepository.java
Method findByTenDangNhap() là trái tim của phân quyền — Spring Security gọi method này để tìm user khi xác thực.
@RepositoryRestResource(path="nguoi-dung") public interface NguoiDungRepository extends JpaRepository<NguoiDung, Integer> { // Kiểm tra trùng lặp khi đăng ký boolean existsByTenDangNhap(String tenDangNhap); boolean existsByEmail(String email); // ⭐ Phân quyền: Spring Security gọi method này NguoiDung findByTenDangNhap(String tenDangNhap); }
QuyenRepository.java
Có thể dùng để tìm quyền theo tên, ví dụ khi gán quyền mặc định cho user mới đăng ký.
@RepositoryRestResource(path="quyen") public interface QuyenRepository extends JpaRepository<Quyen, Integer> { // Dùng để tìm Quyen theo tên, VD: findByTenQuyen("ROLE_USER") Quyen findByTenQuyen(String tenQuyen); }
Đây là bước quan trọng nhất. Spring Security không biết cách lấy user từ DB của bạn — bạn phải "dạy" nó bằng cách implement UserDetailsService.
Interface UserService.java
Kế thừa UserDetailsService — interface chuẩn của Spring Security. Bắt buộc phải override method loadUserByUsername().
public interface UserService extends UserDetailsService { // Thêm method riêng của project NguoiDung finByUsername(String username); }
Implementation UserServiceImpl.java
Đây là nơi logic xác thực thực sự xảy ra. Method loadUserByUsername được Spring Security tự động gọi khi có request cần xác thực.
@Service public class UserServiceImpl implements UserService { // ⭐ Spring Security tự động gọi method này @Override public UserDetails loadUserByUsername(String username) { // 1. Tìm user trong DB NguoiDung nguoiDung = nguoiDungRepository .findByTenDangNhap(username); if (nguoiDung == null) { throw new UsernameNotFoundException("Tài khoản không tồn tại"); } // 2. Trả về UserDetails (gồm username, password, authorities) return new User( nguoiDung.getTenDangNhap(), nguoiDung.getMatKhau(), // Mật khẩu đã BCrypt rolesToAuthorities(nguoiDung.getDanhSachQuyen()) ); } // 3. Chuyển List<Quyen> → Collection<GrantedAuthority> private Collection<? extends GrantedAuthority> rolesToAuthorities( Collection<Quyen> quyens) { return quyens.stream() .map(q -> new SimpleGrantedAuthority(q.getTenQuyen())) .collect(Collectors.toList()); } }
rolesToAuthorities()
Spring Security cần danh sách quyền ở dạng GrantedAuthority. Method này dùng Java Stream để convert: mỗi object Quyen (có field tenQuyen = "ROLE_ADMIN") → tạo new SimpleGrantedAuthority("ROLE_ADMIN").
File cấu hình này quyết định ai được làm gì. Có 3 Bean quan trọng:
Bean BCryptPasswordEncoder
Dùng để mã hóa mật khẩu. BCrypt là thuật toán one-way hash — không thể giải mã ngược. Khi đăng nhập, Spring sẽ mã hóa password nhập vào rồi so sánh với hash trong DB.
@Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // Dùng trong TaiKhoanService để hash password khi đăng ký: // String hash = passwordEncoder.encode(nguoiDung.getMatKhau());
Bean DaoAuthenticationProvider
Đây là "cục ghép nối" giữa UserService và PasswordEncoder. Spring Security dùng Bean này để thực hiện xác thực: lấy user từ UserService, kiểm tra password bằng BCryptPasswordEncoder.
@Bean public DaoAuthenticationProvider authenticationProvider(UserService userService) { DaoAuthenticationProvider dap = new DaoAuthenticationProvider(userService); dap.setPasswordEncoder(passwordEncoder()); return dap; // UserService được inject tự động (Spring tìm Bean có type UserService) }
Bean SecurityFilterChain — Quy tắc phân quyền
Đây là nơi định nghĩa ai được truy cập endpoint nào. Các rule được áp dụng theo thứ tự từ trên xuống dưới — rule đầu tiên match sẽ được áp dụng.
@Bean public SecurityFilterChain SecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(config -> config // 1. Endpoint công khai (GET) .requestMatchers(HttpMethod.GET, Endpoints.PUBLIC_GET_ENDPOINTS) .permitAll() // 2. Endpoint công khai (POST) .requestMatchers(HttpMethod.POST, Endpoints.PUBLIC_POST_ENDPOINTS) .permitAll() // 3. Endpoint chỉ ADMIN mới được GET .requestMatchers(HttpMethod.GET, Endpoints.ADMIN_GET_ENDPOINTS) .hasAnyAuthority("ROLE_ADMIN") // 4. Tất cả request còn lại: phải đăng nhập .anyRequest().authenticated() ); // Dùng HTTP Basic Auth (gửi username:password trong header) http.httpBasic(Customizer.withDefaults()); // Tắt CSRF (vì đây là REST API, không dùng form) http.csrf(csrf -> csrf.disable()); return http.build(); }
CSRF protection dùng cho web app có form HTML. REST API (frontend riêng biệt như React) không cần — việc tắt đi giúp Postman và React gọi API bình thường.
File này tập trung tất cả URL rules vào một chỗ — giúp dễ bảo trì và không phải sửa SecurityConfiguration mỗi khi thêm endpoint.
public class Endpoints { // Frontend URL được phép gọi (CORS) public static final String front_end_host = "http://localhost:3000"; // ✅ Ai cũng gọi được (GET) public static final String[] PUBLIC_GET_ENDPOINTS = { "/sach", "/sach/**", // Xem sách "/hinh-anh", "/hinh-anh/**", // Xem hình ảnh "/nguoi-dung/search/existsByTenDangNhap/**", // Kiểm tra username "/nguoi-dung/search/existsByEmail/**", // Kiểm tra email }; // ✅ Ai cũng gọi được (POST) public static final String[] PUBLIC_POST_ENDPOINTS = { "/tai-khoan/dang-ky", // Đăng ký tài khoản }; // 🔴 Chỉ ROLE_ADMIN được GET public static final String[] ADMIN_GET_ENDPOINTS = { "/nguoi-dung", // Xem danh sách user "/nguoi-dung/**", // Xem chi tiết user }; }
Bảng tóm tắt quyền truy cập:
| Endpoint | Method | Quyền truy cập |
|---|---|---|
/sach, /sach/** | GET | Public |
/hinh-anh, /hinh-anh/** | GET | Public |
/nguoi-dung/search/existsBy... | GET | Public |
/tai-khoan/dang-ky | POST | Public |
/nguoi-dung, /nguoi-dung/** | GET | ROLE_ADMIN only |
| Tất cả endpoints còn lại | Any | Phải đăng nhập |
Service xử lý đăng ký tài khoản, bao gồm validation và mã hóa mật khẩu.
public ResponseEntity<?> dangKyNguoiDung(NguoiDung nguoiDung) { // Bước 1: Kiểm tra username chưa tồn tại if (nguoiDungRepository.existsByTenDangNhap(nguoiDung.getTenDangNhap())) return ResponseEntity.badRequest() .body(new ThongBao("Tên đăng nhập đã tồn tại")); // Bước 2: Kiểm tra email chưa tồn tại if (nguoiDungRepository.existsByEmail(nguoiDung.getEmail())) return ResponseEntity.badRequest() .body(new ThongBao("Email đã tồn tại")); // Bước 3: Mã hóa mật khẩu với BCrypt trước khi lưu String encryptPassword = passwordEncoder.encode(nguoiDung.getMatKhau()); nguoiDung.setMatKhau(encryptPassword); // Bước 4: Lưu vào DB nguoiDungRepository.save(nguoiDung); return ResponseEntity.ok("Đăng ký thành công"); }
Khi user đăng ký, danhSachQuyen sẽ là rỗng → user không có quyền gì → không truy cập được các endpoint yêu cầu xác thực. Xem phần "Mở rộng" để biết cách fix.
3 bảng cần thiết cho hệ thống phân quyền:
-- Bảng quyền (Roles) CREATE TABLE quyen ( ma_quyen INT AUTO_INCREMENT PRIMARY KEY, ten_quyen VARCHAR(50) NOT NULL -- VD: 'ROLE_ADMIN', 'ROLE_USER' ); -- Bảng trung gian (Join Table) - Many-to-Many CREATE TABLE nguoidung_quyen ( ma_nguoi_dung INT, ma_quyen INT, PRIMARY KEY (ma_nguoi_dung, ma_quyen), FOREIGN KEY (ma_nguoi_dung) REFERENCES nguoi_dung(ma_nguoi_dung), FOREIGN KEY (ma_quyen) REFERENCES quyen(ma_quyen) ); -- Dữ liệu mẫu INSERT INTO quyen (ten_quyen) VALUES ('ROLE_USER'); INSERT INTO quyen (ten_quyen) VALUES ('ROLE_ADMIN'); -- Gán ROLE_ADMIN cho user có ma_nguoi_dung = 1 INSERT INTO nguoidung_quyen VALUES (1, 2);
Dùng Postman hoặc curl để test các scenario phân quyền:
Test endpoint Public (không cần đăng nhập)
# Lấy danh sách sách - không cần auth curl -X GET http://localhost:8080/sach # Kết quả: 200 OK + dữ liệu sách
Test endpoint cần đăng nhập (Basic Auth)
# Gọi không có auth → 401 Unauthorized curl -X GET http://localhost:8080/don-hang # Gọi với Basic Auth → 200 OK curl -X GET http://localhost:8080/don-hang \ -u "username:password"
Test endpoint ADMIN_ONLY
# User thường gọi → 403 Forbidden curl -X GET http://localhost:8080/nguoi-dung \ -u "user_thuong:password" # Admin gọi → 200 OK curl -X GET http://localhost:8080/nguoi-dung \ -u "admin_account:password"
Cách gửi Basic Auth trong React (Axios)
// Cách 1: Dùng auth option của axios axios.get('http://localhost:8080/nguoi-dung', { auth: { username: 'admin', password: '123456' } }); // Cách 2: Encode Base64 thủ công const token = btoa('admin:123456'); axios.get('http://localhost:8080/nguoi-dung', { headers: { Authorization: `Basic ${token}` } });
Các bước nâng cấp thông thường:
Gán quyền mặc định khi đăng ký
Sửa TaiKhoanService.java để tự động gán ROLE_USER cho người dùng mới.
// Inject QuyenRepository @Autowired private QuyenRepository quyenRepository; // Trong dangKyNguoiDung(), trước khi save: Quyen quyenUser = quyenRepository.findByTenQuyen("ROLE_USER"); nguoiDung.setDanhSachQuyen(List.of(quyenUser)); nguoiDungRepository.save(nguoiDung);
Thêm endpoint mới vào Endpoints.java
// Thêm endpoint admin cho POST/PUT/DELETE public static final String[] ADMIN_POST_ENDPOINTS = { "/sach", // Thêm sách "/the-loai", // Thêm thể loại }; // Trong SecurityConfiguration, thêm rule: .requestMatchers(HttpMethod.POST, Endpoints.ADMIN_POST_ENDPOINTS) .hasAnyAuthority("ROLE_ADMIN")
Nâng cấp lên JWT (Tương lai)
Basic Auth gửi username+password mỗi request → không an toàn và không stateless. JWT sẽ cấp token sau khi đăng nhập — request sau chỉ cần gửi token.
- Thêm dependency
spring-boot-starter-oauth2-resource-server - Tạo
/tai-khoan/dang-nhapendpoint trả về JWT token - Sửa
SecurityFilterChaindùnghttp.oauth2ResourceServer() - Frontend lưu token vào
localStoragevà gửi trongAuthorization: Bearerheader