Học viên : Nguyễn Thu Hằng (Java 08)
Email : 14thuhang@gmail.com
Bài viết gốc : https://www.baeldung.com/spring-security-method-security

1. Tổng quan

Nói một cách đơn giản, Spring Security hỗ trợ authorization trên chính method.

Thông thường, chúng tôi có thể bảo mật ứng dụng của mình bằng cách hạn chế những role nào có thể thực thi một phương thức cụ thể nào - và kiểm tra nó bằng cách sử dụng method-level security để hỗ trợ

Trong hướng dẫn này, chúng ta sẽ thảo luận việc sử dụng một số chú thích bảo mật. Sau đó, chúng tôi sẽ tập trung vào việc kiểm tra phương pháp bảo mật của chúng tôi với các cách khác nhau.

2. Enabling Method Security

Đầu tiên, để sử dụng Spring Method Security, chúng ta cần thêm dependency spring-security-config:

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

Chúng tôi có thể tìm thấy version mới nhất của nó trên Maven Central.

Nếu chúng ta muốn sử dụng Spring Boot, chúng ta có thể sử dụng dependency spring-boot-starter-security, đã bao gồm spring-security-config:

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

Theo dõi version mới nhất tại đây

Tiếp theo, chúng ta cần enable global Method Security:

@Configuration
@EnableGlobalMethodSecurity(
  prePostEnabled = true, 
  securedEnabled = true, 
  jsr250Enabled = true)
public class MethodSecurityConfig 
  extends GlobalMethodSecurityConfiguration {
}
  • prePostEnabled annotation kích hoạt chú thích trước / sau của Spring Security
  • securedEnabled annotation xác định xem chú thích @Secured có nên được bật hay không.
  • jsr250Enabled annotation cho phép sử dụng chú thích @RoleAllowed.

Chúng ta sẽ tìm hiểu thêm về những annotation này trong phần tiếp theo.

3. áp dụng Method Security

3.1. Sử dụng @Secured Annotation

Annotation @Secured được sử dụng để chỉ định các role của một phương thức. Vì vậy, người dùng chỉ có thể truy cập phương thức đó nếu có ít nhất một trong các role được chỉ định.

Hãy định nghĩa phương thức getUsername:

@Secured("ROLE_VIEWER")
public String getUsername() {
  SecurityContext securityContext = SecurityContextHolder.getContext();
  return securityContext.getAuthentication().getName();
}

Ở đây, chú thích @Secured (“ROLE_VIEWER”) cho biết chỉ những người dùng có ROLE_VIEWER mới có thể thực thi method getUsername.

Chúng ta có thể định nghĩa nhiều role trong chú thích @Secured:

@Secured({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername(String username) {
    return userRoleRepository.isValidUsername(username);
}

Trong trường hợp này, nếu người dùng có ROLE_VIEWER hoặc ROLE_EDITOR, thì người dùng đó có thể gọi phương thức isValidUsername.

Chú thích @Secured không hỗ trợ Spring Expression Language (SpEL).

3.2. @RolesAllowed Annotation

Annotation @RolesAllowed là annotation tương đương với JSR-250 trong annotation @Secured.

Về cơ bản, chúng ta có thể sử dụng @RolesAllowed tương tự như @Secured.

Chúng ta có thể viết lại phương thức getUsernameisValidUsername như sau :

@RolesAllowed("ROLE_VIEWER")
public String getUsername2() {
    //...
}
    
@RolesAllowed({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername2(String username) {
    //...
}

Tương tự, chỉ người dùng có role ROLE_VIEWER mới có thể thực thi getUsername2.

Và người dùng chỉ có thể gọi isValidUsername2 nếu họ có ít nhất một trong các role ROLE_VIEWER hoặc ROLER_EDITOR.

3.3. @PreAuthorize and @PostAuthorize Annotations

Annotation @PreAuthorize@PostAuthorize đều cung cấp khả năng kiểm soát truy cập dựa trên biểu thức. Vì vậy, có thể được viết bằng SpEL (Spring Expression Language).

Annotation @PreAuthorize kiểm tra biểu thức đã cho trước khi gọi method, trong khi @PostAuthorize xác minh nó sau khi thực hiện method và có thể thay đổi kết quả.

Bây giờ, hãy khai báo phương thức getUsernameInUpperCase như sau:

@PreAuthorize("hasRole('ROLE_VIEWER')")
public String getUsernameInUpperCase() {
    return getUsername().toUpperCase();
}

@PreAuthorize (“hasRole (‘ ROLE_VIEWER ’)”) có tác dụng tương đương @Secured (“ROLE_VIEWER”) mà chúng ta đã sử dụng ở phần trên.

Do đó, annotation @Secured ({“ROLE_VIEWER”, ”ROLE_EDITOR”}) có thể được thay thế bằng @PreAuthorize (“hasRole (‘ ROLE_VIEWER ’) hoặc hasRole (‘ ROLE_EDITOR ’)”):

@PreAuthorize("hasRole('ROLE_VIEWER') or hasRole('ROLE_EDITOR')")
public boolean isValidUsername3(String username) {
    //...
}

Hơn nữa, chúng ta có thể sử dụng đối số như một phần của biểu thức:

@PreAuthorize("#username == authentication.principal.username")
public String getMyRoles(String username) {
    //...
}

Ở đây người dùng chỉ có thể gọi phương thức getMyRoles nếu đối số username giống với username người dùng của hiện tại.

Lưu ý : các biểu thức @PreAuthorize có thể được thay thế bằng các biểu thức @PostAuthorize.

Có thể viết lại getMyRoles như sau :

@PostAuthorize("#username == authentication.principal.username")
public String getMyRoles2(String username) {
    //...
}

Tuy nhiên trong ví dụ trên, authorization sẽ bị trì hoãn sau khi thực hiện phương thức.

Ngoài ra, @PostAuthorize cung cấp khả năng truy cập kết quả của phương thức:

  @PostAuthorize
  ("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

Ở đây, phương thức loadUserDetail sẽ chỉ thực thi thành công nếu tên người dùng của CustomUser được trả về bằng với nickName hiện tại.

3.4. @PreFilter and @PostFilter Annotations

Spring Security cung cấp @PreFilter để lọc đối số trước khi thực thi phương thức:

 @PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
   return usernames.stream().collect(Collectors.joining(";"));
}

Trong ví dụ này đang lọc tất cả các username ngoại trừ username đã được xác thực.

Trong biểu thức của chúng tôi, chúng tôi sử dụng filterObject để đại diện cho đối tượng hiện tại trong method.

Tuy nhiên, nếu phương thức có nhiều đối số, chúng ta cần sử dụng thuộc tính filterTarget để chỉ định đối số nào chúng ta muốn lọc:

 @PreFilter
 (value = "filterObject != authentication.principal.username",
 filterTarget = "usernames")
public String joinUsernamesAndRoles(
 List<String> usernames, List<String> roles) {

   return usernames.stream().collect(Collectors.joining(";")) 
     + ":" + roles.stream().collect(Collectors.joining(";"));
}

Ngoài ra, cũng có thể lọc trả về của một phương thức bằng cách sử dụng @PostFilter:

@PostFilter("filterObject != authentication.principal.username")
public List<String> getAllUsernamesExceptCurrent() {
  return userRoleRepository.getAllUsernames();
}

Trong trường hợp này, tên filterObject tham chiếu đến đối tượng hiện tại được trả về trong method.

Với ví dụ trên Spring Security sẽ lặp qua danh sách trả về và xóa bất kỳ giá trị nào khớp với tên người dùng của chính.

3.5. Method Security Meta-Annotation

Trong 1 số tình huống chúng tôi bảo vệ các method khác nhau bằng cách sử dụng cùng một cấu hình bảo mật.

Trong trường hợp này, chúng ta cần định nghĩa một security meta-annotation:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('VIEWER')")
public @interface IsViewer {
}

Tiếp theo, chúng ta có thể sử dụng trực tiếp annotation @IsViewer để bảo mật method của mình:

@IsViewer
public String getUsername4() {
    //...
}

Security Meta-Annotation là một ý tưởng tuyệt vời vì chúng bổ sung nhiều ngữ nghĩa hơn và tách logic nghiệp vụ của chúng ta khỏi security framework.

3.6. Security Annotation at the Class Level

Nếu sử dụng cùng một chú thích bảo mật cho mọi phương thức trong một lớp, chúng ta có thể cân nhắc đặt chú thích đó ở cấp class :

@Service
@PreAuthorize("hasRole('ROLE_ADMIN')")
public class SystemService {

   public String getSystemYear(){
       //...
   }

   public String getSystemDate(){
       //...
   }
}

Trong ví dụ trên, quy tắc bảo mật hasRole (‘ROLE_ADMIN ') sẽ được áp dụng cho cả hai phương thức getSystemYear và getSystemDate.

3.7. Multiple Security Annotations on a Method

Chúng ta cũng có thể sử dụng nhiều chú thích bảo mật trên một method:

@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
 return userRoleRepository.loadUserByUserName(username);
}

Bằng cách này, Spring sẽ xác minh authorization cả trước và sau khi thực thi phương thức secureLoadUserDetail.

4. Important Considerations

Có hai điểm chúng ta cần nhắc lại liên quan đến method security:

  • Theo mặc định, Spring AOP proxy được sử dụng để áp dụng phương pháp bảo mật. Nếu một phương thức bảo mật A được gọi bởi một phương thức khác trong cùng một lớp, thì bảo mật trong A sẽ bị bỏ qua hoàn toàn. Điều này có nghĩa là phương thức A sẽ thực thi mà không có bất kỳ kiểm tra bảo mật nào. Điều này cũng áp dụng cho các private methods.
  • Spring SecurityContext là thread-bound. Theo mặc định, security context không được truyền tới các thread con.

5. Testing Method Security

5.1. Cấu hình

Để kiểm tra Spring Security với JUnit, chúng ta cần dependency spring-security-test:

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

Theo dõi phiên bản mới nhất tại đây

Tiếp theo, hãy cấu hình Spring Integration test đơn giản bằng cách chạy và cấu hình ApplicationContext:

@RunWith(SpringRunner.class)
@ContextConfiguration
public class MethodSecurityIntegrationTest {
    // ...
}

5.2. Testing Username and Roles

Bây giờ cấu hình của chúng ta đã sẵn sàng, hãy thử kiểm tra phương thức getUsername đã được bảo mật bằng chú thích @Secured (“ROLE_VIEWER”):

@Secured("ROLE_VIEWER")
public String getUsername() {
    SecurityContext securityContext = SecurityContextHolder.getContext();
    return securityContext.getAuthentication().getName();
}

Vì chúng ta sử dụng chú thích @Secured ở đây, nên yêu cầu người dùng phải được xác thực để gọi phương thức. Nếu không sẽ nhận được AuthenticationCredentialsNotFoundException.

Vì vậy, cần cung cấp một user để kiểm tra phương pháp bảo mật.

Để có được điều này, chúng tôi sử dụng test method với @WithMockUser và cung cấp user và các roles cho user đó:

@Test
@WithMockUser(username = "john", roles = { "VIEWER" })
public void givenRoleViewer_whenCallGetUsername_thenReturnUsername() {
    String userName = userRoleService.getUsername();
    
    assertEquals("john", userName);
}

Chúng ta đã cung cấp một người dùng được xác thực có username là john và có role là ROLE_VIEWER. Nếu chúng ta không chỉ định username hoặc role, username mặc định là username và role mặc định là ROLE_USER.

Lưu ý rằng không cần thiết phải thêm tiền tố ROLE_ vào đây vì Spring Security sẽ tự động thêm tiền tố đó.

Ví dụ, hãy khai báo một phương thức getUsernameInLowerCase:

@PreAuthorize("hasAuthority('SYS_ADMIN')")
public String getUsernameLC(){
    return getUsername().toLowerCase();
}

Chúng ta có thể kiểm tra như sau :

public void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() {
    String username = userRoleService.getUsernameInLowerCase();

    assertEquals("john", username);
}

Nếu chúng ta muốn sử dụng cùng một user cho nhiều trường hợp thử nghiệm, chúng ta có thể khai báo @WithMockUser :

@RunWith(SpringRunner.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = { "VIEWER" })
public class MockUserAtClassLevelIntegrationTest {
    //...
}

Nếu muốn chạy thử nghiệm của mình với tư cách là anonymous user, chúng ta có thể sử dụng @WithAnonymousUser:

@Test(expected = AccessDeniedException.class)
@WithAnonymousUser
public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
    userRoleService.getUsername();
}

Trong ví dụ trên, trả về AccessDeniedException vì anonymous user không được cấp role ROLE_VIEWER hoặc authority SYS_ADMIN.

5.3. Testing With a Custom UserDetailsService

Đối với hầu hết các chương trình, thông thường sử dụng một class tùy chỉnh làm cơ sở xác thực. Trong trường hợp này, class tùy chỉnh cần triển khai interface org.springframework.security.core.userdetails.UserDetails.

Chúng ta khai báo một class CustomUser extends UserDetails hiện có, là org.springframework.security.core.userdetails.User.

public class CustomUser extends User {
    private String nickName;
    // getter and setter
}

Hãy xem lại ví dụ với @PostAuthorize trong Phần 3:

@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

Trong trường hợp này, method sẽ chỉ thực thi thành công nếu username của CustomUser được trả về bằng với nickName hiện tại.

Nếu muốn kiểm tra phương pháp đó, chúng ta có thể triển khai UserDetailsService để load CustomUser dựa trên username:

@Test
@WithUserDetails(
  value = "john", 
  userDetailsServiceBeanName = "userDetailService")
public void whenJohn_callLoadUserDetail_thenOK() {
 
    CustomUser user = userService.loadUserDetail("jane");

    assertEquals("jane", user.getNickName());
}

Ở đây, @WithUserDetails nói rằng chúng ta sẽ sử dụng một UserDetailsService để khởi tạo người dùng đã xác thực, tham chiếu bởi thuộc tính userDetailsServiceBeanName.

Ngoài ra, service sẽ sử dụng giá trị của thuộc tính value làm username để tải UserDetails.

Chúng ta cũng có thể dùng @WithUserDetails ở cấp class, tương tự như những gì chúng ta đã làm với @WithMockUser.

5.4. Testing With Meta Annotations

Chúng ta thường thấy mình sử dụng đi sử dụng lại cùng một user/role trong các ví dụ khác nhau nên ta sẽ tạo một meta-annotation.

Xem lại ví dụ trước @WithMockUser (username = ”john”, role = {“VIEWER”}), chúng ta có thể khai báo một meta-annotation:

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value = "john", roles = "VIEWER")
public @interface WithMockJohnViewer { }

Sau đó, chúng ta chỉ cần sử dụng @WithMockJohnViewer trong ví dụ của mình:

@Test
@WithMockJohnViewer
public void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() {
    String userName = userRoleService.getUsername();

    assertEquals("john", userName);
}

Tương tự, chúng ta có thể sử dụng meta-annotation để tạo người dùng cụ thể cho domain-specific bằng cách sử dụng @WithUserDetails.

6. Kết luận

Trong bài viết này, chúng tôi đã khám phá các cách dùng khác nhau để sử dụng Method Security trong Spring Security.

Chúng tôi cũng đã làm một số thử nghiệm để dễ dàng kiểm tra tính bảo mật của method và học cách sử dụng lại những user trong các thử nghiệm khác nhau.