Перейти к основному содержимому

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

· 10 мин. чтения

1. Обзор

Проще говоря, Spring Security поддерживает семантику авторизации на уровне метода.

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

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

2. Включение безопасности метода

Во-первых, чтобы использовать Spring Method Security, нам нужно добавить зависимость spring-security-config :

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

Его последнюю версию мы можем найти на Maven Central .

Если мы хотим использовать Spring Boot, мы можем использовать зависимость spring-boot-starter-security , которая включает spring-security-config :

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

Опять же, последнюю версию можно найти на Maven Central .

Далее нам нужно включить глобальную безопасность методов :

@Configuration
@EnableGlobalMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true)
public class MethodSecurityConfig
extends GlobalMethodSecurityConfiguration {
}
  • Свойство prePostEnabled включает аннотации Spring Security до/после.
  • Свойство secureEnabled определяет, следует ли включить аннотацию @Secured .
  • Свойство jsr250Enabled позволяет нам использовать аннотацию @RoleAllowed .

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

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

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

Аннотация @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 не поддерживает язык выражений Spring (SpEL).

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

Аннотация @RolesAllowed является эквивалентной аннотацией JSR-250 аннотации @Secured .

По сути, мы можем использовать аннотацию @RolesAllowed аналогично @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

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

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

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

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

Spring Security предоставляет аннотацию @PreFilter для фильтрации аргумента коллекции перед выполнением метода :

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

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

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

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

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

Кроме того, мы также можем отфильтровать возвращаемую коллекцию метода с помощью аннотации @PostFilter :

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

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

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

В нашей статье Spring Security — @PreFilter и @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 проверит авторизацию как до, так и после выполнения метода secureLoadUserDetail .

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

Есть два момента, которые мы хотели бы напомнить в отношении безопасности методов:

  • По умолчанию проксирование Spring AOP используется для применения безопасности метода. Если защищенный метод A вызывается другим методом в том же классе, безопасность в A полностью игнорируется. Это означает, что метод A будет выполняться без какой-либо проверки безопасности. То же самое относится и к частным методам.
  • Spring SecurityContext привязан к потоку. По умолчанию контекст безопасности не распространяется на дочерние потоки. Для получения дополнительной информации обратитесь к нашей статье Распространение контекста безопасности Spring .

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

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

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

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

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

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

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

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

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

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

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

Итак, нам нужно предоставить пользователя для тестирования нашего защищенного метода.

Для этого мы украшаем тестовый метод @WithMockUser и предоставляем пользователя и роли :

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

assertEquals("john", userName);
}

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

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

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

Например, давайте объявим метод 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);
}

Удобно, если мы хотим использовать одного и того же пользователя для многих тестовых случаев, мы можем объявить аннотацию @WithMockUser в тестовом классе :

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

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

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

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

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

Для большинства приложений в качестве принципала проверки подлинности обычно используется пользовательский класс. В этом случае пользовательский класс должен реализовать org.springframework.security.core.userdetails. Интерфейс сведений о пользователе .

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

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

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

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

@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. Тестирование с помощью мета-аннотаций

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

Для таких ситуаций удобно создать мета-аннотацию .

Снова взглянув на предыдущий пример @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.

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

Все примеры для этой статьи можно найти на GitHub .