Въведение в Spring Method Security

1. Въведение

Най-просто казано, Spring Security поддържа семантиката на оторизация на ниво метод.

Обикновено бихме могли да осигурим нашия сервизен слой, като например ограничим кои роли могат да изпълняват определен метод - и да го тестваме, като използваме специална поддръжка за тестване на защитата на ниво метод.

В тази статия първо ще разгледаме използването на някои анотации за сигурност. След това ще се съсредоточим върху тестването на сигурността на нашия метод с различни стратегии.

2. Активиране на защитата на метода

На първо място, за да използваме Spring Method Security, трябва да добавим зависимостта spring-security-config :

 org.springframework.security spring-security-config 

Можем да намерим последната му версия в Maven Central.

Ако искаме да използваме Spring Boot, можем да използваме зависимостта spring-boot-starter-security, която включва spring-security-config :

 org.springframework.boot spring-boot-starter-security 

Отново, най-новата версия може да бъде намерена в Maven Central.

След това трябва да активираме глобалната защита на методите:

@Configuration @EnableGlobalMethodSecurity( prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration { }
  • В prePostEnabled имота позволява предварително Пролет сигурност / публикувайте анотации
  • В securedEnabled имота определя дали @Secured трябва да е активиран анотация
  • В jsr250Enabled имота ни позволява да използваме @RoleAllowed анотация

Ще разгледаме повече за тези пояснения в следващия раздел.

3. Прилагане на метод за сигурност

3.1. Използване на @Secured Annotation

В @Secured анотацията се използва за определяне на списък на ролите на метод. Следователно потребителят има достъп до този метод само ако има поне една от посочените роли.

Нека дефинираме метод getUsername :

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

Тук анотацията @Secured (“ROLE_VIEWER”) дефинира, че само потребители, които имат ролята ROLE_VIEWER , могат да изпълняват метода getUsername .

Освен това можем да дефинираме списък с роли в @Secured анотация:

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

В този случай конфигурацията гласи, че ако потребителят има ROLE_VIEWER или ROLE_EDITOR , този потребител може да извика метода isValidUsername .

В @Secured анотация не поддържа Пролет Expression Language (Spel).

3.2. Използване на @RoleAllowed Annotation

В @RoleAllowed анотацията е JSR-250 еквивалент анотация на @Secured анотацията .

По принцип можем да използваме анотацията @RoleAllowed по подобен начин като @Secured . По този начин бихме могли да дефинираме методите getUsername и isValidUsername :

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

По същия начин само потребителят, който има роля ROLE_VIEWER, може да изпълни getUsername2 .

Отново потребителят може да извика isValidUsername2 само ако има поне една от ролите ROLE_VIEWER или ROLER_EDITOR .

3.3. Използване на @PreAuthorize и @PostAuthorize Annotations

Както @PreAuthorize, така и @PostAuthorize анотациите предоставят контрол на достъпа въз основа на изрази. Следователно предикатите могат да бъдат написани с помощта на SpEL (Spring Expression Language).

На @PreAuthorize анотация проверки на даден израз, преди да влезе на метода , докато най- @PostAuthorize анотация • Проверява след изпълнението на метода, и биха могли да променят резултата .

Сега, нека декларираме метод getUsernameInUpperCase , както е показано по-долу:

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

В @PreAuthorize ( "hasRole (" ROLE_VIEWER ') ") има същото значение, както @Secured (" ROLE_VIEWER ") , който ние използвахме в предишния раздел. Чувствайте се свободни да откриете повече подробности за изрази за сигурност в предишни статии.

Следователно, пояснението @Secured ({“ROLE_VIEWER”, ”ROLE_EDITOR”}) може да бъде заменено с @PreAuthorize (“hasRole ('ROLE_VIEWER') или hasRole ('ROLE_EDITOR')“):

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

Освен това всъщност можем да използваме аргумента на метода като част от израза :

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

Тук потребителят може да извика метода getMyRoles само ако стойността на потребителското име на аргумента е същата като потребителското име на текущия принципал.

Той е на стойност да се отбележи, че @PreAuthorize изрази могат да бъдат заменени с @PostAuthorize такива .

Нека пренапишем getMyRoles :

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

В предишния пример обаче оторизацията ще се забави след изпълнението на целевия метод.

Освен това анотацията @PostAuthorize предоставя възможността за достъп до резултата от метода :

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

In this example, the loadUserDetail method would only execute successfully if the username of the returned CustomUser is equal to the current authentication principal's nickname.

In this section, we mostly use simple Spring expressions. For more complex scenarios, we could create custom security expressions.

3.4. Using @PreFilter and @PostFilter Annotations

Spring Security provides the @PreFilter annotation to filter a collection argument before executing the method:

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

In this example, we're joining all usernames except for the one who is authenticated.

Here, in our expression, we use the name filterObject to represent the current object in the collection.

However, if the method has more than one argument which is a collection type, we need to use the filterTarget property to specify which argument we want to filter:

@PreFilter (value = "filterObject != authentication.principal.username", filterTarget = "usernames") public String joinUsernamesAndRoles( List usernames, List roles) { return usernames.stream().collect(Collectors.joining(";")) + ":" + roles.stream().collect(Collectors.joining(";")); }

Additionally, we can also filter the returned collection of a method by using @PostFilter annotation:

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

In this case, the name filterObject refers to the current object in the returned collection.

With that configuration, Spring Security will iterate through the returned list and remove any value matching the principal's username.

Spring Security – @PreFilter and @PostFilter article describes both annotations in greater detail.

3.5. Method Security Meta-Annotation

We typically find ourselves in a situation where we protect different methods using the same security configuration.

In this case, we can define a security meta-annotation:

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

Next, we can directly use the @IsViewer annotation to secure our method:

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

Security meta-annotations are a great idea because they add more semantics and decouple our business logic from the security framework.

3.6. Security Annotation at the Class Level

If we find ourselves using the same security annotation for every method within one class, we can consider putting that annotation at class level:

@Service @PreAuthorize("hasRole('ROLE_ADMIN')") public class SystemService { public String getSystemYear(){ //... } public String getSystemDate(){ //... } }

In above example, the security rule hasRole(‘ROLE_ADMIN') will be applied to both getSystemYear and getSystemDate methods.

3.7. Multiple Security Annotations on a Method

We can also use multiple security annotations on one method:

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

Hence, Spring will verify authorization both before and after the execution of the securedLoadUserDetail method.

4. Important Considerations

There are two points we'd like to remind regarding method security:

  • By default, Spring AOP proxying is used to apply method security – if a secured method A is called by another method within the same class, security in A is ignored altogether. This means method A will execute without any security checking. The same applies to private methods
  • Spring SecurityContext is thread-bound – by default, the security context isn't propagated to child-threads. For more information, we can refer to Spring Security Context Propagation article

5. Testing Method Security

5.1. Configuration

To test Spring Security with JUnit, we need the spring-security-test dependency:

 org.springframework.security spring-security-test 

We don't need to specify the dependency version because we're using the Spring Boot plugin. We can find the latest version of this dependency on Maven Central.

Next, let's configure a simple Spring Integration test by specifying the runner and the ApplicationContext configuration:

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

5.2. Testing Username and Roles

Now that our configuration is ready, let's try to test our getUsername method which we secured with the @Secured(“ROLE_VIEWER”) annotation:

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

Since we use the @Secured annotation here, it requires a user to be authenticated to invoke the method. Otherwise, we'll get an AuthenticationCredentialsNotFoundException.

Hence, we need to provide a user to test our secured method. To achieve this, we decorate the test method with @WithMockUser and provide a user and roles:

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

We've provided an authenticated user whose username is john and whose role is ROLE_VIEWER. If we don't specify the username or role, the default username is user and default role is ROLE_USER.

Note that it isn't necessary to add the ROLE_ prefix here, Spring Security will add that prefix automatically.

If we don't want to have that prefix, we can consider using authority instead of role.

For example, let's declare a getUsernameInLowerCase method:

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

We could test that using authorities:

@Test @WithMockUser(username = "JOHN", authorities = { "SYS_ADMIN" }) public void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() { String username = userRoleService.getUsernameInLowerCase(); assertEquals("john", username); }

Conveniently, if we want to use the same user for many test cases, we can declare the @WithMockUser annotation at test class:

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

If we wanted to run our test as an anonymous user, we could use the @WithAnonymousUser annotation:

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

In the example above, we expect an AccessDeniedException because the anonymous user isn't granted the role ROLE_VIEWER or the authority SYS_ADMIN.

5.3. Testing With a Custom UserDetailsService

For most applications, it's common to use a custom class as authentication principal. In this case, the custom class needs to implement the org.springframework.security.core.userdetails.UserDetails interface.

In this article, we declare a CustomUser class which extends the existing implementation of UserDetails, which is org.springframework.security.core.userdetails.User:

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

Let's take back the example with the @PostAuthorize annotation in section 3:

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

In this case, the method would only execute successfully if the username of the returned CustomUser is equal to the current authentication principal's nickname.

If we wanted to test that method, we could provide an implementation of UserDetailsService which could load our CustomUser based on the username:

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

Here, the @WithUserDetails annotation states that we'll use a UserDetailsService to initialize our authenticated user. The service is referred by the userDetailsServiceBeanName property. This UserDetailsService might be a real implementation or a fake for testing purposes.

Additionally, the service will use the value of the property value as the username to load UserDetails.

Conveniently, we can also decorate with a @WithUserDetails annotation at the class level, similarly to what we did with the @WithMockUser annotation.

5.4. Testing With Meta Annotations

We often find ourselves reusing the same user/roles over and over again in various tests.

For these situations, it's convenient to create a meta-annotation.

Taking back the previous example @WithMockUser(username=”john”, roles={“VIEWER”}), we can declare a meta-annotation as:

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

Then we can simply use @WithMockJohnViewer in our test:

@Test @WithMockJohnViewer public void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() { String userName = userRoleService.getUsername(); assertEquals("john", userName); }

Likewise, we can use meta-annotations to create domain-specific users using @WithUserDetails.

6. Conclusion

In this tutorial, we've explored various options for using Method Security in Spring Security.

We also have gone through a few techniques to easily test method security and learned how to reuse mocked users in different tests.

All examples of this tutorial can be found over on Github.