Spring Security · JWT-less · Role-Based

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.

📋
Kiến Trúc Tổng Quan

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.

🔄
Luồng Xác Thực & Phân Quyền

Khi client gửi request, Spring Security xử lý theo thứ tự sau:

ClientGửi request
+ Basic Auth header
SecurityFilterChainKiểm tra endpoint
có cần auth không
DaoAuthenticationProviderLấy UserDetails
từ UserService
UserServiceImplTìm NguoiDung
trong DB theo username
BCrypt verifyXác minh mật khẩu
đã mã hóa
Kiểm tra RoleSo sánh Authority
với endpoint rule
✓ ResponseTrả về dữ liệu
1️⃣
Entity: Quyen & NguoiDung

Đâ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.

A

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().

Quyen.java
@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;
}
B

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.

NguoiDung.java (trích)
// ⭐ 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;
⚠️ Tại sao phải EAGER?

Nếu để LAZY, khi Spring Security gọi loadUserByUsername() xong thì Hibernate session đã đóng → không thể load quyền → lỗi LazyInitializationException.

2️⃣
Repository Layer

Hai repository quan trọng nhất cho phân quyền:

A

NguoiDungRepository.java

Method findByTenDangNhap()trái tim của phân quyền — Spring Security gọi method này để tìm user khi xác thực.

NguoiDungRepository.java
@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);
}
B

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ý.

QuyenRepository.java
@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);
}
3️⃣
UserService — Cầu Nối Với Spring Security

Đâ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.

A

Interface UserService.java

Kế thừa UserDetailsService — interface chuẩn của Spring Security. Bắt buộc phải override method loadUserByUsername().

UserService.java
public interface UserService extends UserDetailsService {
    // Thêm method riêng của project
    NguoiDung finByUsername(String username);
}
B

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.

UserServiceImpl.java
@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());
    }
}
💡 Hiểu method 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").

4️⃣
SecurityConfiguration — Bộ Não Phân Quyền

File cấu hình này quyết định ai được làm gì. Có 3 Bean quan trọng:

A

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.

SecurityConfiguration.java
@Bean
public BCryptPasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
// Dùng trong TaiKhoanService để hash password khi đăng ký:
// String hash = passwordEncoder.encode(nguoiDung.getMatKhau());
B

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.

SecurityConfiguration.java
@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)
}
C

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.

SecurityConfiguration.java
@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();
}
💡 Tại sao tắt CSRF?

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.

5️⃣
Endpoints.java — Danh Sách URL Phân Quyền

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.

Endpoints.java
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:

EndpointMethodQuyền truy cập
/sach, /sach/**GETPublic
/hinh-anh, /hinh-anh/**GETPublic
/nguoi-dung/search/existsBy...GETPublic
/tai-khoan/dang-kyPOSTPublic
/nguoi-dung, /nguoi-dung/**GETROLE_ADMIN only
Tất cả endpoints còn lạiAnyPhải đăng nhập
6️⃣
TaiKhoanService — Đăng Ký Người Dùng

Service xử lý đăng ký tài khoản, bao gồm validation và mã hóa mật khẩu.

TaiKhoanService.java
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");
}
⚠️ Vấn đề hiện tại: Chưa gán quyền khi đăng ký!

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.

🗄️
Cơ Sở Dữ Liệu — Cấu Trúc Bảng

3 bảng cần thiết cho hệ thống phân quyền:

SQL Schema
-- 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);
🧪
Test API — Kiểm Tra Phân Quyền

Dùng Postman hoặc curl để test các scenario phân quyền:

1

Test endpoint Public (không cần đăng nhập)

curl
# 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
2

Test endpoint cần đăng nhập (Basic Auth)

curl
# 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"
3

Test endpoint ADMIN_ONLY

curl
# 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"
4

Cách gửi Basic Auth trong React (Axios)

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}` }
});
🚀
Mở Rộng Hệ Thống Phân Quyền

Các bước nâng cấp thông thường:

A

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.

TaiKhoanService.java — thêm vào dangKyNguoiDung()
// 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);
B

Thêm endpoint mới vào Endpoints.java

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")
C

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-nhap endpoint trả về JWT token
  • Sửa SecurityFilterChain dùng http.oauth2ResourceServer()
  • Frontend lưu token vào localStorage và gửi trong Authorization: Bearer header
WebBanSach Backend · Spring Security · Role-Based Authorization Guide