Spring Security: Authorization

18 tháng 06, 2021 - 1773 lượt xem

Spring Security cung cấp nhiều lựa chọn để phân quyền. Trong bài này, tôi chỉ tập trung chia sẻ kỹ thuật phần quyền mới nhất và linh hoạt nhất sử dụng các annotation @PreAuthorize, @PostAuthorize, @PostFilter. Mã nguồn minh hoạ trực quan ở đây

0. Chạy thử ứng dụng

Để các bạn không phải đợi lâu, tôi trình bày các dùng thử ứng dụng mẫu luôn.

  1. Ứng dụng này sử dụng CSDL SQL in memory là H2 nhúng sẵn bên trong ứng dụng nên không cần cài đặt DB bên ngoài
  2. Chỉ cần biên dịch rồi truy cập địa chỉ http://localhost:8080. Nếu JDK trên máy của bạn không phải là phiên bản 16 thì vào file pom.xml sửa <java.version>16</java.version>
  3. Ứng dụng có 4 role: ADMIN, USER, EDITOR và AUTHOR. Một người có thể được gán nhiều role.
  4. Ứng dụng có 4 địa chỉ /admin, /user , /editor, /author. Người nào có role tương ứng sẽ xem được

Giao diện chính

Lỗi khi không có quyền truy cập

Access Denied

Đường http://localhost:8080/api/post lọc bài viết theo tác giả đăng nhập

1. Tổng quan về phân quyền trong Spring Security

Phân quyền (Authorization) là bước tiếp theo của xác thực (Authentication). Sau khi biết người đăng nhập là ai, thì hệ thống sẽ biết được vai trò (role) của người đó trong ứng dụng và cấp quyền hạn tương ứng.

Authorization độc lập với kỹ thuật Authentication. Chức năng phần phân quyền có thể làm việc tốt với bất kỳ cơ chế Authentication nào http basic, form login, digest, session less token (JWT Token) để xác thực.

Spring Security có 2 khái niệm Role và Authority thường là đồng nhất trong phần lớn trường hợp, nên rất dễ nhầm lẫn. Đây không rõ là lỗi thiết kế hay là ý đồ của tác giả Spring Security. Spring Security cũng không thiết kế hoàn toàn giống theo mô hình RBAC (Role Based Access Controll). Mô hình RBAC có 3 đối tượng: Subject(chủ thể), Role(vai trò), Permission(quyền hạn). Còn trong Spring Security, có User ~ chủ thể, Role ~ vai trò. Ứng với từng method cụ thể trong Controller, REST Controller, Service, lập trình viên sẽ dùng annotation để cấp quyền thực hiện method cho vài trò nào hoặc ai đó cụ thể sử dụng Spring Expression Language.

Entity RBAC Spring Security
User Lưu trong CSDL Lưu trong CSDL
Role Lưu trong CSDL Tuỳ có thể lưu hoặc không lưu
Permission Lưu trong CSDL Dùng annotaton để gán trực tiếp lên method

RBAC chuẩn hoá việc lưu thông tin User, Role, Permission ... theo dạng bảng quan hệ trong CSDL

Spring Security chỉ cung cấp Interface và một số annotation cần thiết, lập trình viên tự thực hiện code. Đánh giá của tôi Spring Security thực dụng và đủ dùng linh hoạt trong hoàn cảnh cụ thể của Spring Boot. Danh sách Role trong Spring Security buộc phải xác định trước khi biên dịch. Còn RBAC là chuẩn mực phân quyền cho phép Role có thể tạo động khi ứng dụng đang chạy. Tính năng này trong RBAC cũng không thực sự quá cần thiết, và đối khi làm ứng dụng trở nên rối rắm, khó kiểm soát.

2. Cấu hình ứng dụng Spring Boot để phân quyền

Spring Security yêu cầu thư viện spring-boot-starter-security

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Các class quan trọng:

  • Cấu hình SecurityConfig kế thừa class WebSecurityConfigurerAdapter
  • User mô tả đối tượng, hoàn toàn tuỳ biến miễn là tuân thủ interface UserDetails
  • Với Role bạn có thể sử dụng kiểu String hoặc Enum. Một User nhận 1 hoặc nhiều Role, lưu dưới dạng List<String> hay Set<String>. Tuy nhiên để trả lời liệt kê danh sách chính xác User có Role X, Role Y thì tốt nhất lưu Role trong CSDL có quan hệ nhiều - nhiều với User.

Phần còn lại là các thư viện thường đi kèm

  • JPA: quản lý User, Role qua ORM Hibernate
  • H2, MySQL, Postgresql, Oracle, MongoDB... là CSDL lưu thông tin User, Role...

3. User - Role - Post

Để minh hoạ chức năng phần quyền, tôi tạo ra 3 Entity:

  1. User sẽ có một hoặc nhiều Role. Ngược lại một Role có thể phân cho nhiều User. Quan hệ nhiều - nhiều
  2. Role gồm có ADMIN, USER, AUTHOR, EDITOR
  3. Post do một User viết. Một User có thể viết nhiều Post.

Quy tắc phân quyền của một trang Blog công nghệ như sau:

  • Author là người được phép viết , sửa, xoá Post của chính mình
  • Editor được phép sửa, xoá Post của người khác
  • User được xem bài viết của mọi người, được bình luận vào post. Nhưng không được quyền tạo bài mới cho đến khi anh ta nhận đủ một số lượng điểm thưởng tích luỹ
  • Admin được phép thêm bớt Role cho một user, hoặc vô hiệu hoá tài khoản User.

Nhờ vào việc Spring Security cho phép một user nhận nhiều Role. Do đó ứng với một Role chúng ta chỉ phần quyền đủ cho Role đó. Tổ hợp nhiều Role lại sẽ cho người dùng nhiều quyền. Không cần thiết phải tạo ra Role có rất nhiều quyền, sau đó để tinh chỉnh quyền, ta lại phải tạo rất nhiều Role gây khó khăn trong việc kiểm soát.

4. SecurityConfig

Để tích hợp Spring Security vào dự án Spring Boot nhất thiết bạn cần ít nhất một file cấu hình. Trong dự án này là SecurityConfig.java kế thừa class WebSecurityConfigurerAdapter

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  @Bean
    //Cấu hình Password encoder. Cần phải có !
  public PasswordEncoder encoder() {
    return new BCryptPasswordEncoder();
  }

  @Override
    //Cấu hình authentication theo đường dẫn, crsf, cors, session cookie hay stateless.
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
      .antMatchers("/h2-console/**").permitAll().and().csrf().ignoringAntMatchers("/h2-console/**")
      .and().headers().frameOptions().sameOrigin().and().formLogin();
  }
}

Giải thích các annotation:

  1. @Configuration đánh dấu đây là class có các phương thức tạo ra Bean component.
  2. @EnableWebSecurity đây là class cấu hình cho Spring Security, hãy sử dụng cấu hình mặc định nếu người dùng không yêu cầu thay đổi gì cụ thể.
  3. @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = false, jsr250Enabled = false) kích hoạt cả 3 cách phân quyền phương thức. Thực tế trong bài viết này, tôi chỉ ví dụ cách mới nhất là prePostEnabled = true sử dụng Spring Expression Language. Xem thêm so sánh giữa 3 phương pháp ở đây: @RolesAllowed vs. @PreAuthorize vs. @Secured

Dòng cấu hình dưới chỉ áp dụng cho môi trường thử nghiệm, cho phép lập trình viên truy cập h2-console để xem dữ liệu trong H2 mà không cần login.

.antMatchers("/h2-console/**").permitAll().and().csrf().ignoringAntMatchers("/h2-console/**")
      .and().headers().frameOptions().sameOrigin().and().formLogin();

5. Sinh dữ liệu mẫu để thử nghiệm

Hãy xem kỹ file SecurityService.java, có một phương thức tạo dữ liệu mẫu Role, User, cùng các bài viết.

@Override
@Transactional
public void generateUsersRoles() {
    Role roleAdmin = new Role("ADMIN");
    Role roleUser = new Role("USER");
    Role roleAuthor = new Role("AUTHOR");
    Role roleEditor = new Role("EDITOR");

    roleRepository.save(roleAdmin);
    roleRepository.save(roleUser);
    roleRepository.save(roleAuthor);
    roleRepository.save(roleEditor);
    roleRepository.flush();

    User admin = createUser("admin", "123", roleAdmin);
    userRepository.save(admin);

    User bob =  createUser("bob", "123", roleUser);
    userRepository.save(bob);

    User alice =  createUser("alice", "123", roleEditor);
    userRepository.save(alice);

    User tom =  createUser("tom", "123", roleUser, roleEditor);
    userRepository.save(tom);

    User jane =  createUser("jane", "123", roleAuthor);
    userRepository.save(jane);

    bob.addPost(new Post("Bob love Spring Boot"));
    bob.addPost(new Post("Bob love Việt Nam"));

    alice.addPost(new Post("Alice likes dancing"));
    tom.addPost(new Post("Tom loves drawing"));
    admin.addPost(new Post("Admin never tweets"));
    jane.addPost(new Post("Jane loves Bob and Tom"));

    userRepository.flush();
}

6. UserDetailService interface

UserDetailService là một interface có duy nhất một mẫu phương thức UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

Trong ứng dụng Spring Boot, chỉ cần một Component hay Bean nào đó implements interface UserDetailService thì trong quá trình xác thực, phương thức UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; của Component hay Bean đó sẽ được gọi ra khi cần tìm kiếm user theo user name. Bản chất của cơ chế này là Auto Injection, khi quét (component scan) và nạp các Component hay Bean vào Application Context, Spring Boot đã biết được rõ, component nào dùng để tìm kiếm user theo user name thông qua kiểu Interface bạn không cần phải cấu hình gì thêm. Nếu có nhiều hơn một Component implements interface UserDetailService thì sao? Khi đó bạn có thể dùng annotation @Primary hay @Order để ưu tiên component bạn muốn sử dụng.

7. Phân quyền tập trung hay phân quyền bằng annotation?

Spring Security cho lập trình viên 2 lựa chọn:

  1. Tập trung cấu hình phần quyền trong phương thức protected void configure(HttpSecurity http) throws Exception sử dụng antMatchers hay mvcMatchers cho từng đường dẫn
http.authorizeRequests()
.antMatchers("/admin").hasAuthority("ADMIN")
.antMatchers("/free").hasAnyAuthority("ADMIN", "USER", "AUTHOR", "EDITOR")
.antMatchers("/author").hasAnyAuthority("AUTHOR")
.antMatchers("/user").hasAnyAuthority("USER")
.antMatchers("/editor").hasAnyAuthority("EDITOR")
  1. Dùng annotation để phân quyền tại từng phương thức hoặc controller
@GetMapping("/admin")
@PreAuthorize("hasAuthority('ADMIN')")
public String showAdmin() {
    return "admin";
}

@PreAuthorize("hasAuthority('USER')")
@GetMapping("/user")
public String showUserPage() {
    return "user";
}
Phân quyền tập trung Phần quyền dùng annotation
Nhìn thấy toàn cảnh logic phân quyền Phân quyền tản mát tại từng phương thức, controller
Khi số lượng phương thức rất nhiều. Nhìn sẽ khá phức tạp. Đôi khi khó debug Rất dễ đọc và gỡ rối
Phù hợp cấu hình chung cho cả web site và các đường dẫn chính Phù hợp cấu hình chi tiết đến từng HTTP Verb và đường dẫn
Không sử dụng Spring Language Expression Sử dụng được Spring Language Expression và tuỳ biến trước , sau request

Thật tuyệt là chúng ta có thể kết hợp cả 2 phương pháp này. Những logic cấu hình authentication và những yêu cầu bảo mật chung như cần yêu cầu đăng nhập với request đến đường dẫn /api tôi luôn để trong hàm protected void configure(HttpSecurity http) throws Exception. Tôi chỉ dùng annotation phân quyền khi quy định đó dành riêng cho phương thức đó mà không áp dụng được cho phương thức khác.

8. Expression-Based Access Control

Spring Security có tính năng Expression-Based Access Control (kiểm soát truy cập bằng biểu thức). Để dùng được tính năng này, cần bật prePostEnabled = true trong annotation @EnableGlobalMethodSecurity. Hãy xem thêm tài liệu chuẩn của Spring Boot về Express-Based Access Control

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = false, jsr250Enabled = false)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

Expression-Based Access Control có một số hàm có sẵn rất hữu ích:

Biểu thức Giải thích
hasRole(String role) Cần có role mới được thực thi
hasAnyRole(String… roles) Cần có một trong những roles mới được thực thi
hasAuthority(String authority) Cần có authority mới được thực thi
hasAnyAuthority(String… authorities) Cần có một trong những authories mới được thực thi
principal truy cập đến đối tượng User đang đăng nhập
authentication đối tượng Authentication trong Security Context
permitAll cho phép tất cả truy cập
denyAll cấm tất cả truy cập
isAnonymous() true nếu người dùng chưa đăng nhập
isAuthenticated() true nếu người dùng đã đăng nhập

9. Phân quyền ở cấp độ Controller

Ví dụ ở một REST Controller: APIControllerV2.java, chúng ta yêu cầu tối thiểu mọi người dùng đều phải đăng nhập. Sau đó với từng phương thức chi tiết sẽ có kiểm tra quyền riêng. Việc đặt @PreAuthorize("isAuthenticated()") ở cấp độ Controller sẽ rất hợp lý.

@RestController
@RequestMapping("/v2/api")
@PreAuthorize("isAuthenticated()")
public class APIControllerV2 {
 ...
}

10. Phần quyền ở cấp độ phương thức

10.1 @PreAuthorize kiểm tra trước khi chạy phương thức

Ví dụ kiểm tra quyền phải là ADMIN mới được truy cập

@GetMapping("/admin")
@PreAuthorize("hasAuthority('ADMIN')")
public String showAdmin() {
    return "admin";
}

Viết một logic kiểm tra quyền gồm 2 điều kiện:

  • Yêu cầu đăng nhập isAuthenticated()
  • Giá trị biến đường dẫn {username} bằng với tên đăng nhập hiện thời #username == authentication.principal.username. Chú ý authentication.principal là một biến mặc định mà Spring Security cung cấp để lấy thông tin về người dùng đang đăng nhập
@GetMapping("/user/{username}")
@PreAuthorize("isAuthenticated() and #username == authentication.principal.username")
public String getMyRoles(@PathVariable("username") String username) {
        SecurityContext securityContext = SecurityContextHolder.getContext();
        return securityContext.getAuthentication().getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
}

10.2 Tại sao không phải là @PreAuthorize("hasRole('ADMIN')") mà lại là @PreAuthorize("hasAuthority('ADMIN')")?

Trong dự án này tôi chuyển đổi 1:1 từ Role sang Authority. File User.java tuân thủ interface UserDetails viết đè một phương thức. Đọc kỹ logic bạn sẽ thấy ứng với mỗi role, tôi sẽ tạo ra một đối tượng SimpleGrantedAuthority. Sau này khi chuyển sang cơ chế RBAC, bạn có thể viết lại, mỗi một Role, trả về một tập các Authority thuộc Role đó.

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    Set<GrantedAuthority> authorities = new HashSet<>();
    for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
    }
    return authorities;
}

Nếu bạn muốn dùng hasRole thì chuỗi mô tả tên role sẽ phải có thêm tiền tố ROLE_

Tạo ROLE_ADMIN

Role roleAdmin = new Role("ROLE_ADMIN");

Kiểm tra ROLE_ADMIN

@PreAuthorize("hasRole('ROLE_ADMIN')")

10.3 @PostAuthorize

Nếu như @PreAuthorize kiểm tra quyền trước khi method thực thi thì có những tình huống quyết định cho phép truy cập tiếp hay không chỉ sau khi phương thức được thực thi. Ví dụ GET /post/{id} sẽ trả về bài viết. Tôi muốn rằng chỉ tác giả của chính bài viết đó mới có thể được xem để sửa. Spring Boot đã có user.id đang đăng nhập, nhưng để biết tác giả của bài viết thì cần phải lấy bài viết đó từ CSDL ra đã. @PostAuthorize

@PreAuthorize("isAuthenticated()") //Phải đảm bảo là user login để có principal.id
@PostAuthorize("returnObject.user.id == authentication.principal.id")  //chỉ trả bài viết nếu user.id == id của login user
@GetMapping("/post/{id}")
public Post showEditPostForm(@PathVariable("id") long id) {
    Optional<Post> oPost = postRepo.findById(id);
    if (oPost.isPresent()) {
        return oPost.get();
    } else {
        throw new RuntimeException("Cannot find post with id " + id);
    }
}

10.4 @PostFilter

Để lọc dữ liệu trước khi trả về, có 2 cách: để lập trình viên tự viết code trong phương thức hoặc cấu hình bằng annotation @PostFilter.

Trong ví dụ này, phương thức getPostsOfAnUser() sẽ trả về một danh sách tất cả bài viết. Tôi muốn lọc ra chỉ trả về những bài viết do chính user đang login authentication.principal viết mà thôi.

@PreAuthorize("isAuthenticated()")
@PostFilter("filterObject.user.id == authentication.principal.id")
@GetMapping("/post")
public List<Post> getPostsOfAnUser() {
    return postRepo.findAll();
}

10.5 @PreFilter

@Prefilter lại cho phép lọc dữ liệu truyền vào phương thức trước khi thực thi. Tôi chưa có ví dụ thực tế nào để minh hoạ cho annotation. Nhưng về cơ bản nó giống với @PostFilter

11. Kết luận

Bạn đã đọc một bài viết rất dài và hại não. Tôi cũng phải mất 3 ngày full time để code ví dụ này, và thêm 1.5 ngày nữa để viết ra những gì đã trải nghiệm sau khi đã đọc rất nhiều tài liệu liên quan. Một vài điểm bạn cần nhớ:

  • Authentication và Authorization luôn đi cùng nhau. Nhưng Authorization có thể độc lập, không phụ thuộc vào phương pháp Authentication
  • Interface UserDetailService dùng để tìm kiếm người dùng theo user name. Cần phải có khi bạn muốn quản lý người dùng đăng nhập
  • Phải tạo một class kế thừa class WebSecurityConfigurerAdapter để cấu hình Authentication và Authorization
  • Có 2 cách để cấu hình phân quyền: tập trung trong class kế thừa WebSecurityConfigurerAdapter sử dụng amtMatchers hoặc sử dụng annotation trực tiếp tại Controller và method. Mỗi cách có một ưu điểm riêng, do đó hãy kết hợp cả hai.
  • Expression-Based Access Control sử dụng biểu thức để phân quyền. Chú ý các annotation @PreAuthorize, @PostAuthorize, @PostFilter
  • Bạn có thể quên bài viết này vì nó quá dài. Nhưng hãy nhớ khi nào lập trình Spring Boot hay Spring Security cứ tìm ở trang techmaster.vn sẽ có rất nhiều bài viết bổ ích, vì chúng tôi hàng ngày lập trình dự án Spring Boot, tư vấn, đào tạo cho rất nhiều đội phát triển Java. Chúng tôi chia sẻ lại tất cả kinh nghiệm mà chúng tôi có được.

Bình luận

avatar
Nguyễn Thế Anh 2021-06-23 08:09:08.901655+00

Bài viết quá hay và chất lượng anh ơi. Có repo trên git để tham khảo không ạ?

Trịnh Minh Cường
Trịnh Minh Cường 2021-06-23T09:15:31.657438+00:00

Link mã  nguồn đây nhé https://github.com/TechMaster/SpringBoot28Days/tree/main/25-SpringSecurityAuthorization/authorization

Avatar
* Vui lòng trước khi bình luận.
Ảnh đại diện
 +4 Thích
+4