Модульное тестирование с помощью Spring Security

145

Моя компания изучает Spring MVC, чтобы определить, следует ли использовать его в одном из наших следующих проектов. Пока мне нравится то, что я видел, и прямо сейчас я смотрю на модуль Spring Security, чтобы определить, можем ли мы / должны ли это использовать.

Наши требования безопасности довольно простые; пользователю просто нужно иметь возможность указать имя пользователя и пароль, чтобы иметь доступ к определенным частям сайта (например, для получения информации о своей учетной записи); и на сайте есть несколько страниц (часто задаваемые вопросы, поддержка и т. д.), к которым должен быть предоставлен доступ анонимному пользователю.

В прототипе, который я создавал, я хранил объект «LoginCredentials» (который просто содержит имя пользователя и пароль) в Session для аутентифицированного пользователя; некоторые контроллеры проверяют, находится ли этот объект в сеансе, чтобы, например, получить ссылку на имя пользователя, вошедшего в систему. Я хочу заменить эту самодельную логику на Spring Security, которая имела бы приятное преимущество в виде удаления любого вида «как мы отслеживаем пользователей, вошедших в систему?» и "как мы аутентифицируем пользователей?" из моего контроллера / бизнес-кода.

Похоже, что Spring Security предоставляет объект «контекста» (для каждого потока), чтобы иметь возможность получить доступ к информации об имени пользователя / принципале из любого места в вашем приложении ...

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

... что очень не похоже на Spring, поскольку этот объект в некотором роде (глобальный) синглтон.

Мой вопрос таков: если это стандартный способ доступа к информации об аутентифицированном пользователе в Spring Security, то каков приемлемый способ ввести объект аутентификации в SecurityContext, чтобы он был доступен для моих модульных тестов, когда модульные тесты требуют аутентифицированный пользователь?

Нужно ли мне подключать это к методу инициализации каждого тестового примера?

protected void setUp() throws Exception {
    ...
    SecurityContextHolder.getContext().setAuthentication(
        new UsernamePasswordAuthenticationToken(testUser.getLogin(), testUser.getPassword()));
    ...
}

Это кажется излишне многословным. Есть способ попроще?

Сам SecurityContextHolderобъект кажется очень не похожим на Spring ...

Мэтт Би
источник

Ответы:

48

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

Перед тем, как мы начали использовать Spring Security, мы должны были создать bean-компонент с областью сеанса в контейнере для хранения принципала, внедрить его в «AuthenticationService» (синглтон), а затем внедрить этот bean-компонент в другие службы, которым требовалось знание текущего принципала.

Если вы реализуете свою собственную службу аутентификации, вы могли бы в основном сделать то же самое: создать bean-компонент в области сеанса со свойством "Principal", внедрить его в свою службу аутентификации, установить для службы аутентификации свойство при успешной аутентификации, а затем сделайте службу аутентификации доступной для других bean-компонентов по мере необходимости.

Я бы не расстроился, если бы использовал SecurityContextHolder. хотя. Я знаю, что это статический / синглтон и что Spring не рекомендует использовать такие вещи, но их реализация заботится о том, чтобы вести себя соответствующим образом в зависимости от среды: с областью сеанса в контейнере сервлетов, с областью действия потоков в тесте JUnit и т. Синглтона - это когда он обеспечивает реализацию, негибкую для различных сред.

cliff.meyers
источник
Спасибо, это полезный совет. То, что я сделал до сих пор, в основном заключается в том, чтобы продолжить вызов SecurityContextHolder.getContext () (через несколько моих собственных методов-оболочек, поэтому, по крайней мере, он вызывается только из одного класса).
matt b
2
Хотя только одно замечание - я не думаю, что ServletContextHolder имеет какое-либо понятие HttpSession или способ узнать, работает ли он в среде веб-сервера - он использует ThreadLocal, если вы не настроите его для использования чего-то еще (только два других встроенных режима - InheritableThreadLocal и Global)
matt b
Единственный недостаток использования bean-компонентов с ограничением сеанса / запроса в Spring заключается в том, что они не пройдут тест JUnit. Что вы можете сделать, так это реализовать настраиваемую область, которая будет использовать сеанс / запрос, если они доступны, и вернуться к потоку. Я предполагаю, что Spring Security делает нечто подобное ...
cliff.meyers
Моя цель - создать Rest api без сессий. Возможно, с обновляемым токеном. Хотя это не ответило на мой вопрос, это помогло. Спасибо
Pomagranite
173

Просто сделайте это обычным способом, а затем вставьте его, используя SecurityContextHolder.setContext()в своем тестовом классе, например:

Контроллер:

Authentication a = SecurityContextHolder.getContext().getAuthentication();

Контрольная работа:

Authentication authentication = Mockito.mock(Authentication.class);
// Mockito.whens() for your authorization object
SecurityContext securityContext = Mockito.mock(SecurityContext.class);
Mockito.when(securityContext.getAuthentication()).thenReturn(authentication);
SecurityContextHolder.setContext(securityContext);
Леонардо Элой
источник
2
@Leonardo, где это Authentication aнужно добавить в контроллер? Как я могу понять в каждом вызове метода? Можно ли для "весеннего пути" просто добавить его вместо инъекции?
Олег Куц
Но помните, что он не будет работать с TestNG, потому что SecurityContextHolder хранит локальную переменную потока, поэтому вы можете использовать эту переменную между тестами ...
Лукаш Возничка,
Сделайте это в @BeforeEach(JUnit5) или @Before(JUnit 4). Хорошо и просто.
WesternGun
32

Не отвечая на вопрос о том, как создавать и внедрять объекты аутентификации, Spring Security 4.0 предоставляет несколько полезных альтернатив, когда дело доходит до тестирования. @WithMockUserАннотаций позволяет разработчику указывать макет пользователя (с дополнительными органами, имя пользователя, пароль и роли) в опрятном виде:

@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
    String message = messageService.getMessage();
    ...
}

Существует также возможность использовать @WithUserDetailsдля эмуляции UserDetailsвозвращенного UserDetailsService, например

@Test
@WithUserDetails("customUsername")
public void getMessageWithUserDetailsCustomUsername() {
    String message = messageService.getMessage();
    ...
}

Более подробную информацию можно найти в главах @WithMockUser и @WithUserDetails справочной документации Spring Security (из которой были скопированы приведенные выше примеры)

Мацев
источник
30

Вы совершенно правы - вызовы статических методов особенно проблематичны для модульного тестирования, поскольку вы не можете легко имитировать свои зависимости. Я собираюсь показать вам, как позволить контейнеру Spring IoC делать за вас грязную работу, оставляя вам аккуратный, проверяемый код. SecurityContextHolder - это класс фреймворка, и хотя для вашего кода безопасности низкого уровня может быть нормально быть привязанным к нему, вы, вероятно, захотите предоставить более аккуратный интерфейс для ваших компонентов пользовательского интерфейса (то есть контроллеров).

cliff.meyers упомянул один способ обойти это - создать свой собственный «основной» тип и внедрить экземпляр в потребителей. Тег Spring < aop: scoped-proxy />, представленный в 2.x, в сочетании с определением bean-компонента области запроса, и поддержка фабричных методов могут быть билетом к наиболее читаемому коду.

Это может работать следующим образом:

public class MyUserDetails implements UserDetails {
    // this is your custom UserDetails implementation to serve as a principal
    // implement the Spring methods and add your own methods as appropriate
}

public class MyUserHolder {
    public static MyUserDetails getUserDetails() {
        Authentication a = SecurityContextHolder.getContext().getAuthentication();
        if (a == null) {
            return null;
        } else {
            return (MyUserDetails) a.getPrincipal();
        }
    }
}

public class MyUserAwareController {        
    MyUserDetails currentUser;

    public void setCurrentUser(MyUserDetails currentUser) { 
        this.currentUser = currentUser;
    }

    // controller code
}

Пока ничего сложного, правда? Фактически, вам, вероятно, уже приходилось делать большую часть этого. Затем в контексте вашего bean-компонента определите bean-компонент с областью запроса для хранения принципала:

<bean id="userDetails" class="MyUserHolder" factory-method="getUserDetails" scope="request">
    <aop:scoped-proxy/>
</bean>

<bean id="controller" class="MyUserAwareController">
    <property name="currentUser" ref="userDetails"/>
    <!-- other props -->
</bean>

Благодаря магии тега aop: scoped-proxy статический метод getUserDetails будет вызываться каждый раз, когда приходит новый HTTP-запрос, и любые ссылки на свойство currentUser будут разрешаться правильно. Теперь модульное тестирование становится тривиальным:

protected void setUp() {
    // existing init code

    MyUserDetails user = new MyUserDetails();
    // set up user as you wish
    controller.setCurrentUser(user);
}

Надеюсь это поможет!

Павел
источник
9

Лично я бы просто использовал Powermock вместе с Mockito или Easymock, чтобы издеваться над статическим SecurityContextHolder.getSecurityContext () в вашем тесте модуля / интеграции, например

@RunWith(PowerMockRunner.class)
@PrepareForTest(SecurityContextHolder.class)
public class YourTestCase {

    @Mock SecurityContext mockSecurityContext;

    @Test
    public void testMethodThatCallsStaticMethod() {
        // Set mock behaviour/expectations on the mockSecurityContext
        when(mockSecurityContext.getAuthentication()).thenReturn(...)
        ...
        // Tell mockito to use Powermock to mock the SecurityContextHolder
        PowerMockito.mockStatic(SecurityContextHolder.class);

        // use Mockito to set up your expectation on SecurityContextHolder.getSecurityContext()
        Mockito.when(SecurityContextHolder.getSecurityContext()).thenReturn(mockSecurityContext);
        ...
    }
}

По общему признанию, здесь довольно много стандартного кода, т.е. имитирует объект аутентификации, имитирует SecurityContext, чтобы вернуть аутентификацию, и, наконец, издеваться над SecurityContextHolder, чтобы получить SecurityContext, однако он очень гибкий и позволяет вам проводить модульное тестирование для таких сценариев, как null объекты аутентификации и т. д. без изменения вашего (не тестового) кода


источник
7

Использование статики в этом случае - лучший способ написать безопасный код.

Да, статика в целом плохая, но в данном случае статика - это то, что вам нужно. Поскольку контекст безопасности связывает участника с текущим запущенным потоком, наиболее безопасный код будет обращаться к статике из потока как можно напрямую. Скрытие доступа за внедренным классом-оболочкой дает злоумышленнику больше точек для атаки. Им не понадобится доступ к коду (который им было бы трудно изменить, если бы jar был подписан), им просто нужен способ переопределить конфигурацию, что можно сделать во время выполнения или вставить некоторый XML в путь к классам. Даже использование инъекции аннотации можно было бы переопределить с помощью внешнего XML. Такой XML может внедрить в работающую систему мошенника.

Майкл Буше
источник
4

Я задал тот же вопрос сам над здесь , и только отправил ответ , который я недавно нашел. Короткий ответ: введите a SecurityContextи используйте SecurityContextHolderтолько в конфигурации Spring, чтобы получитьSecurityContext

Скотт Бэйл
источник
3

Генеральная

Между тем (начиная с версии 3.2, в 2013 году, благодаря SEC-2298 ) аутентификация может быть введена в методы MVC с помощью аннотации @AuthenticationPrincipal :

@Controller
class Controller {
  @RequestMapping("/somewhere")
  public void doStuff(@AuthenticationPrincipal UserDetails myUser) {
  }
}

Тесты

Очевидно, что в модульном тесте вы можете вызвать этот метод напрямую. В интеграционных тестах с помощью org.springframework.test.web.servlet.MockMvcвы можете org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()ввести пользователя следующим образом:

mockMvc.perform(get("/somewhere").with(user(myUserDetails)));

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

mockMvc.perform(get("/somewhere").with(sessionUser(myUserDetails)));
/* ... */
private static RequestPostProcessor sessionUser(final UserDetails userDetails) {
    return new RequestPostProcessor() {
        @Override
        public MockHttpServletRequest postProcessRequest(final MockHttpServletRequest request) {
            final SecurityContext securityContext = new SecurityContextImpl();
            securityContext.setAuthentication(
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities())
            );
            request.getSession().setAttribute(
                HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext
            );
            return request;
        }
    };
}
янки
источник
2

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

Digitalsanctum
источник
Хотя эти тестовые классы полезны, я не уверен, применимы ли они здесь. Мои тесты не имеют понятия ApplicationContext - он им не нужен. Все, что мне нужно, это убедиться, что SecurityContext заполнен до запуска тестового метода - это просто кажется грязным, если сначала нужно установить его в ThreadLocal
matt b
1

Аутентификация - это свойство потока в серверной среде точно так же, как это свойство процесса в ОС. Наличие экземпляра компонента для доступа к информации аутентификации было бы неудобной конфигурацией и накладными расходами без каких-либо преимуществ.

Что касается тестовой аутентификации, есть несколько способов облегчить себе жизнь. Я предпочитаю создавать собственные аннотации @Authenticatedи прослушиватель выполнения тестов, который им управляет. Ищите DirtiesContextTestExecutionListenerвдохновение.

Павел Горал
источник
0

После довольно большой работы мне удалось воспроизвести желаемое поведение. Я эмулировал вход через MockMvc. Это слишком тяжело для большинства модульных тестов, но полезно для интеграционных тестов.

Конечно, я хочу увидеть в Spring Security 4.0 те новые функции, которые упростят наше тестирование.

package [myPackage]

import static org.junit.Assert.*;

import javax.inject.Inject;
import javax.servlet.http.HttpSession;

import org.junit.Before;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

@ContextConfiguration(locations={[my config file locations]})
@WebAppConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
public static class getUserConfigurationTester{

    private MockMvc mockMvc;

    @Autowired
    private FilterChainProxy springSecurityFilterChain;

    @Autowired
    private MockHttpServletRequest request;

    @Autowired
    private WebApplicationContext webappContext;

    @Before  
    public void init() {  
        mockMvc = MockMvcBuilders.webAppContextSetup(webappContext)
                    .addFilters(springSecurityFilterChain)
                    .build();
    }  


    @Test
    public void testTwoReads() throws Exception{                        

    HttpSession session  = mockMvc.perform(post("/j_spring_security_check")
                        .param("j_username", "admin_001")
                        .param("j_password", "secret007"))
                        .andDo(print())
                        .andExpect(status().isMovedTemporarily())
                        .andExpect(redirectedUrl("/index"))
                        .andReturn()
                        .getRequest()
                        .getSession();

    request.setSession(session);

    SecurityContext securityContext = (SecurityContext)   session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);

    SecurityContextHolder.setContext(securityContext);

        // Your test goes here. User is logged with 
}
Borjab
источник