Введение в безопасность методов Spring

Введение в безопасность методов Spring

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 включает аннотации до и после публикации Spring Security.

  • СвойствоsecuredEnabled определяет, должна ли быть включена аннотация@Secured

  • Свойствоjsr250Enabled позволяет нам использовать аннотацию@RoleAllowed

Подробнее об этих аннотациях мы поговорим в следующем разделе.

3. Применение безопасности метода

3.1. Использование аннотации@Secured

The @Secured annotation is used to specify a list of roles on a method. Следовательно, пользователь может получить доступ к этому методу, только если у него есть хотя бы одна из указанных ролей.

Определим метод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 не поддерживает Spring Expression Language (SpEL).

3.2. Использование аннотации@RoleAllowed

Аннотация@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

Both @PreAuthorize and @PostAuthorize annotations provide expression-based access control. Следовательно, предикаты могут быть записаны с использованиемSpEL (Spring Expression Language).

The @PreAuthorize annotation checks the given expression before entering the method, тогда какthe @PostAuthorize annotation verifies it after the execution of the method and could alter the result.

Теперь давайте объявим методgetUsernameInUpperCase, как показано ниже:

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

@PreAuthorize(“hasRole(‘ROLE_VIEWER')”) имеет то же значение, что и@Secured(“ROLE_VIEWER”), которое мы использовали в предыдущем разделе. Не стесняйтесь открывать для себя большеsecurity expressions details in previous articles.

Следовательно, аннотацию@Secured(\{“ROLE_VIEWER”,”ROLE_EDITOR”}) можно заменить на@PreAuthorize(“hasRole(‘ROLE_VIEWER') or hasRole(‘ROLE_EDITOR')”):

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

Кроме того,we can actually use the method argument as part of the expression:

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

Здесь пользователь может вызвать методgetMyRoles, только если значение аргументаusername совпадает с именем пользователя текущего принципала.

It’s worth to note that @PreAuthorize expressions can be replaced by @PostAuthorize ones.

ПерепишемgetMyRoles:

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

В предыдущем примере, однако, авторизация будет задерживаться после выполнения целевого метода.

Дополнительноthe @PostAuthorize annotation provides the ability to access the method result:

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

В этом примере методloadUserDetail будет успешно выполняться, только еслиusername возвращаемогоCustomUser равноnickname. текущего участника аутентификации.

В этом разделе мы в основном используем простые выражения Spring. Для более сложных сценариев мы могли бы создатьcustom security expressions.

3.4. Использование аннотаций@PreFilter и@PostFilter

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(";"));
}

В этом примере мы объединяем все имена пользователей, кроме того, который прошел аутентификацию.

Здесьour expression uses the name filterObject to represent the current object in the collection.

Однако, если метод имеет более одного аргумента, который является типом коллекции, нам нужно использовать свойствоfilterTarget, чтобы указать, какой аргумент мы хотим фильтровать:

@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(";"));
}

Дополнительно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();
}

В этом случае имяfilterObject относится к текущему объекту в возвращаемой коллекции.

С этой конфигурацией Spring Security будет перебирать возвращенный список и удалять любое значение, которое соответствует имени пользователя участника.

Более подробную информацию о@PreFilter и@PostFilter можно найти в статьеSpring Security – @PreFilter and @PostFilter.

3.5. Мета-аннотация безопасности метода

Обычно мы находимся в ситуации, когда мы защищаем разные методы, используя одну и ту же конфигурацию безопасности.

В этом случае мы можем определить метааннотацию безопасности:

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

Далее мы можем напрямую использовать аннотацию @IsViewer для защиты нашего метода:

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

Мета-аннотации безопасности - отличная идея, потому что они добавляют больше семантики и отделяют нашу бизнес-логику от инфраструктуры безопасности.

3.6. Аннотация безопасности на уровне класса

Если мы обнаружим, что используем одну и ту же аннотацию безопасности для каждого метода в пределах одного класса, мы можем рассмотреть возможность размещения этой аннотации на уровне класса:

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

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

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

В приведенном выше примере правило безопасностиhasRole(‘ROLE_ADMIN') будет применяться к методамgetSystemYear иgetSystemDate.

3.7. Множественные аннотации безопасности к методу

Мы также можем использовать несколько аннотаций безопасности для одного метода:

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

Следовательно, Spring будет проверять авторизацию как до, так и после выполнения методаsecuredLoadUserDetail.

4. Важные соображения

Мы хотели бы напомнить о двух моментах, касающихся безопасности метода:

  • By default, Spring AOP proxying is used to apply method security –, если защищенный метод A вызывается другим методом в том же классе, безопасность в A полностью игнорируется. Это означает, что метод A будет выполняться без какой-либо проверки безопасности. То же самое относится и к частным методам

  • Spring SecurityContext is thread-bound – по умолчанию, контекст безопасности не распространяется на дочерние потоки. Для получения дополнительной информации мы можем обратиться к статьеSpring Security Context Propagation

5. Безопасность метода тестирования

5.1. конфигурация

Чтобы протестировать Spring Security с помощью JUnit, нам нужна зависимостьspring-security-test:


    org.springframework.security
    spring-security-test

Нам не нужно указывать версию зависимости, потому что мы используем плагин Spring Boot. Последние версии этой зависимости можно найти наMaven Central.

Затем давайте настроим простой тест Spring Integration, указав бегуна и конфигурациюApplicationContext:

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

5.2. Тестирование имени пользователя и ролей

Теперь, когда наша конфигурация готова, давайте попробуем протестировать наш методgetUsername, который защищен аннотацией@Secured(“ROLE_VIEWER”):

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

Поскольку здесь мы используем аннотацию@Secured, для вызова метода требуется, чтобы пользователь прошел аутентификацию. В противном случае мы получимAuthenticationCredentialsNotFoundException.

Следовательно,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);
}

Мы предоставили аутентифицированного пользователя с именемjohn и рольюROLE_VIEWER. Если мы не укажемusername илиrole, по умолчаниюusername будетuser, аrole -ROLE_USER.

Обратите внимание, что здесь нет необходимости добавлять префиксROLE_, Spring Security добавит этот префикс автоматически.

Если мы не хотим иметь этот префикс, мы можем рассмотреть возможность использованияauthority вместоrole..

Например, давайте объявим методgetUsernameInLowerCase:

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

Мы могли бы проверить это, используя полномочия:

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

    assertEquals("john", username);
}

Удобно,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 TestWithMockUserAtClassLevel {
    //...
}

Если бы мы хотели запустить наш тест как анонимный пользователь, мы могли бы использовать аннотацию@WithAnonymousUser:

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

В приведенном выше примере мы ожидаемAccessDeniedException, потому что анонимному пользователю не предоставлена ​​рольROLE_VIEWER или полномочияSYS_ADMIN.

5.3. Тестирование с пользовательскимUserDetailsService

For most applications, it’s common to use a custom class as authentication principal. В этом случае настраиваемый класс должен реализовать интерфейсorg.springframework.security.core.userdetails.UserDetails.

В этой статье мы объявляем классCustomUser, который расширяет существующую реализациюUserDetails, то естьorg.springframework.security.core.userdetails.User:

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

Вернемся к примеру с аннотацией@PostAuthorize из раздела 3:

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

В этом случае метод будет успешно выполняться только в том случае, еслиusername возвращенногоCustomUser равноnickname текущего участника аутентификации.

Если бы мы хотели протестировать этот метод, 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());
}

В аннотации@WithUserDetails указано, что мы будем использоватьUserDetailsService для инициализации аутентифицированного пользователя. На сервис ссылается свойствоuserDetailsServiceBeanName.. ЭтотUserDetailsService может быть реальной реализацией или подделкой для целей тестирования.

Кроме того, служба будет использовать значение свойстваvalue в качестве имени пользователя для загрузкиUserDetails.

Для удобства мы также можем украсить аннотацией@WithUserDetails на уровне класса, аналогично тому, что мы сделали с аннотацией@WithMockUser.

5.4. Тестирование с метааннотациями

Мы часто обнаруживаем, что снова и снова используем одного и того же пользователя / роли в различных тестах.

Для этих ситуаций удобно создатьmeta-annotation.

Возвращаясь к предыдущему примеру@WithMockUser(username=”john”, roles=\{“VIEWER”}), мы можем объявить метааннотацию как:

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

Тогда мы можем просто использовать@WithMockJohnViewer в нашем тесте:

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

    assertEquals("john", userName);
}

Точно так же мы можем использовать метааннотации для создания пользователей, зависящих от домена, используя@WithUserDetails.

6. Заключение

В этом руководстве мы изучили различные варианты использования Method Security в Spring Security.

Мы также прошли несколько методик, чтобы легко протестировать безопасность методов и узнали, как повторно использовать поддельных пользователей в различных тестах.

Все примеры этого руководства можно найтиover on Github.