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 .