Spring Test & Security: как имитировать аутентификацию?

124

Я пытался выяснить, как выполнить модульное тестирование, правильно ли защищены мои URL-адреса моих контроллеров. На всякий случай, если кто-то изменит ситуацию и случайно уберет настройки безопасности.

Мой метод контроллера выглядит так:

@RequestMapping("/api/v1/resource/test") 
@Secured("ROLE_USER")
public @ResonseBody String test() {
    return "test";
}

Я настроил WebTestEnvironment так:

import javax.annotation.Resource;
import javax.naming.NamingException;
import javax.sql.DataSource;

import org.junit.Before;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.ActiveProfiles;
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;

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration({ 
        "file:src/main/webapp/WEB-INF/spring/security.xml",
        "file:src/main/webapp/WEB-INF/spring/applicationContext.xml",
        "file:src/main/webapp/WEB-INF/spring/servlet-context.xml" })
public class WebappTestEnvironment2 {

    @Resource
    private FilterChainProxy springSecurityFilterChain;

    @Autowired
    @Qualifier("databaseUserService")
    protected UserDetailsService userDetailsService;

    @Autowired
    private WebApplicationContext wac;

    @Autowired
    protected DataSource dataSource;

    protected MockMvc mockMvc;

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());

    protected UsernamePasswordAuthenticationToken getPrincipal(String username) {

        UserDetails user = this.userDetailsService.loadUserByUsername(username);

        UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(
                        user, 
                        user.getPassword(), 
                        user.getAuthorities());

        return authentication;
    }

    @Before
    public void setupMockMvc() throws NamingException {

        // setup mock MVC
        this.mockMvc = MockMvcBuilders
                .webAppContextSetup(this.wac)
                .addFilters(this.springSecurityFilterChain)
                .build();
    }
}

В моем собственном тесте я пытался сделать что-то вроде этого:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;

import eu.ubicon.webapp.test.WebappTestEnvironment;

public class CopyOfClaimTest extends WebappTestEnvironment {

    @Test
    public void signedIn() throws Exception {

        UsernamePasswordAuthenticationToken principal = 
                this.getPrincipal("test1");

        SecurityContextHolder.getContext().setAuthentication(principal);        

        super.mockMvc
            .perform(
                    get("/api/v1/resource/test")
//                    .principal(principal)
                    .session(session))
            .andExpect(status().isOk());
    }

}

Я взял вот это:

Тем не менее, если присмотреться, это помогает только тогда, когда фактические запросы к URL-адресам не отправляются, а только при тестировании сервисов на функциональном уровне. В моем случае возникло исключение «доступ запрещен»:

org.springframework.security.access.AccessDeniedException: Access is denied
    at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:83) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:206) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor.invoke(MethodSecurityInterceptor.java:60) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:172) ~[spring-aop-3.2.1.RELEASE.jar:3.2.1.RELEASE]
        ...

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

14:20:34.454 [main] DEBUG o.s.s.a.i.a.MethodSecurityInterceptor - Secure object: ReflectiveMethodInvocation: public java.util.List test.TestController.test(); target is of class [test.TestController]; Attributes: [ROLE_USER]
14:20:34.454 [main] DEBUG o.s.s.a.i.a.MethodSecurityInterceptor - Previously Authenticated: org.springframework.security.authentication.AnonymousAuthenticationToken@9055e4a6: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS
Мартин Беккер
источник
Название вашей компании eu.ubicon отображается в вашем импорте. Разве это не угроза безопасности?
Кайл Брайденстайн 01
2
Привет, спасибо за комментарий! Но я не понимаю почему. В любом случае это программное обеспечение с открытым исходным кодом. Если вам интересно, см. Bitbucket.org/ubicon/ubicon (или bitbucket.org/dmir_wue/everyaware для последней версии форка). Дай мне знать, если я что-то пропущу.
Мартин Беккер
Проверьте это решение (ответ для весны 4): stackoverflow.com/questions/14308341/…
Надь Аттила

Ответы:

101

В поисках ответа я не мог найти ни одного простого и гибкого одновременно, затем я нашел Spring Security Reference и понял, что есть почти идеальные решения. Решения АОП часто являются лучшими для тестирования, и Spring предоставляет их @WithMockUser, @WithUserDetailsи @WithSecurityContextв этом артефакте:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <version>4.2.2.RELEASE</version>
    <scope>test</scope>
</dependency>

В большинстве случаев он @WithUserDetailsобладает необходимой мне гибкостью и мощностью.

Как работает @WithUserDetails?

По сути, вам просто нужно создать кастом UserDetailsServiceсо всеми возможными профилями пользователей, которые вы хотите протестировать. Например

@TestConfiguration
public class SpringSecurityWebAuxTestConfig {

    @Bean
    @Primary
    public UserDetailsService userDetailsService() {
        User basicUser = new UserImpl("Basic User", "user@company.com", "password");
        UserActive basicActiveUser = new UserActive(basicUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_USER"),
                new SimpleGrantedAuthority("PERM_FOO_READ")
        ));

        User managerUser = new UserImpl("Manager User", "manager@company.com", "password");
        UserActive managerActiveUser = new UserActive(managerUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_MANAGER"),
                new SimpleGrantedAuthority("PERM_FOO_READ"),
                new SimpleGrantedAuthority("PERM_FOO_WRITE"),
                new SimpleGrantedAuthority("PERM_FOO_MANAGE")
        ));

        return new InMemoryUserDetailsManager(Arrays.asList(
                basicActiveUser, managerActiveUser
        ));
    }
}

Теперь у нас есть пользователи, поэтому представьте, что мы хотим протестировать контроль доступа к этой функции контроллера:

@RestController
@RequestMapping("/foo")
public class FooController {

    @Secured("ROLE_MANAGER")
    @GetMapping("/salute")
    public String saluteYourManager(@AuthenticationPrincipal User activeUser)
    {
        return String.format("Hi %s. Foo salutes you!", activeUser.getUsername());
    }
}

Здесь у нас есть функция получения сопоставления с маршрутом / foo / salute, и мы тестируем безопасность на основе ролей с @Securedаннотацией, хотя вы также можете протестировать @PreAuthorizeи @PostAuthorize. Давайте создадим два теста: один для проверки, видит ли действительный пользователь этот ответ приветствия, а другой - для проверки, действительно ли он запрещен.

@RunWith(SpringRunner.class)
@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        classes = SpringSecurityWebAuxTestConfig.class
)
@AutoConfigureMockMvc
public class WebApplicationSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithUserDetails("manager@company.com")
    public void givenManagerUser_whenGetFooSalute_thenOk() throws Exception
    {
        mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
                .accept(MediaType.ALL))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("manager@company.com")));
    }

    @Test
    @WithUserDetails("user@company.com")
    public void givenBasicUser_whenGetFooSalute_thenForbidden() throws Exception
    {
        mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
                .accept(MediaType.ALL))
                .andExpect(status().isForbidden());
    }
}

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

Лучше использовать @WithMockUser для более простой безопасности на основе ролей

Как видите, @WithUserDetailsобладает всей гибкостью, необходимой для большинства ваших приложений. Он позволяет использовать настраиваемых пользователей с любым GrantedAuthority, например с ролями или разрешениями. Но если вы просто работаете с ролями, тестирование может быть еще проще, и вы можете избежать создания пользовательского UserDetailsService. В таких случаях укажите простую комбинацию пользователя, пароля и ролей с помощью @WithMockUser .

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@WithSecurityContext(
    factory = WithMockUserSecurityContextFactory.class
)
public @interface WithMockUser {
    String value() default "user";

    String username() default "";

    String[] roles() default {"USER"};

    String password() default "password";
}

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

@Test
@WithMockUser(roles = "MANAGER")
public void givenManagerUser_whenGetFooSalute_thenOk() throws Exception
{
    mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
            .accept(MediaType.ALL))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("user")));
}

Обратите внимание, что теперь вместо user manager@company.com мы получаем значение по умолчанию @WithMockUser: user ; но это не будет иметь значения , потому что мы действительно заботимся о его роли: ROLE_MANAGER.

Выводы

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

EliuX
источник
Как издеваться над несколькими пользователями ? Например, первый запрос отправляет tom, а второй - jerry?
ch271828n
Вы можете создать функцию, в которой ваш тест выполняется с Томом, и создать другой тест с той же логикой и протестировать его с Джерри. Для каждого теста будет свой результат, поэтому будут разные утверждения, и если тест не пройден, он сообщит вам по своему имени, какой пользователь / роль не работает. Помните, что в запросе пользователь может быть только один, поэтому указывать несколько пользователей в запросе не имеет смысла.
EliuX
Извините, я имею в виду такой пример сценария: мы проверяем это, Том создает секретную статью, затем Джерри пытается ее прочитать, и Джерри не должен ее видеть (поскольку это секрет). В данном случае это один модульный тест ...
ch271828n
Это выглядит так , как BasicUserи Manager Userсценарий , указанный в ответе. Ключевая концепция заключается в том, что вместо того, чтобы заботиться о пользователях, мы на самом деле заботимся об их ролях, но каждый из этих тестов, расположенных в одном модульном тесте, фактически представляет разные запросы. выполняется разными пользователями (с разными ролями) для одной и той же конечной точки.
EliuX
61

Начиная с Spring 4.0+, лучшее решение - аннотировать метод тестирования с помощью @WithMockUser.

@Test
@WithMockUser(username = "user1", password = "pwd", roles = "USER")
public void mytest1() throws Exception {
    mockMvc.perform(get("/someApi"))
        .andExpect(status().isOk());
}

Не забудьте добавить в свой проект следующую зависимость

'org.springframework.security:spring-security-test:4.2.3.RELEASE'
GummyBear21
источник
1
Весна прекрасна. Спасибо
TuGordoBello
Хороший ответ. Более того - вам не нужно использовать mockMvc, но в случае, если вы используете, например, PagingAndSortingRepository из springframework.data - вы можете просто вызывать методы из репозитория напрямую (которые аннотируются с помощью EL @PreAuthorize (......))
supertramp
50

Оказалось, что SecurityContextPersistenceFilter, который является частью цепочки фильтров Spring Security, всегда сбрасывает мой SecurityContext, который я установил для вызова SecurityContextHolder.getContext().setAuthentication(principal)(или с помощью .principal(principal)метода). Этот фильтр устанавливает SecurityContextв SecurityContextHolderс SecurityContextот А SecurityContextRepository перезаписывающего один я установить ранее. Репозиторий HttpSessionSecurityContextRepositoryпо умолчанию. В HttpSessionSecurityContextRepositoryинспектирует данные HttpRequestи пытаюсь получить доступ к соответствующему HttpSession. Если он существует, он попытается прочитать SecurityContextиз файла HttpSession. Если это не удается, репозиторий генерирует пустой SecurityContext.

Таким образом, мое решение - передать HttpSessionвместе с запросом, который содержит SecurityContext:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;

import eu.ubicon.webapp.test.WebappTestEnvironment;

public class Test extends WebappTestEnvironment {

    public static class MockSecurityContext implements SecurityContext {

        private static final long serialVersionUID = -1386535243513362694L;

        private Authentication authentication;

        public MockSecurityContext(Authentication authentication) {
            this.authentication = authentication;
        }

        @Override
        public Authentication getAuthentication() {
            return this.authentication;
        }

        @Override
        public void setAuthentication(Authentication authentication) {
            this.authentication = authentication;
        }
    }

    @Test
    public void signedIn() throws Exception {

        UsernamePasswordAuthenticationToken principal = 
                this.getPrincipal("test1");

        MockHttpSession session = new MockHttpSession();
        session.setAttribute(
                HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, 
                new MockSecurityContext(principal));


        super.mockMvc
            .perform(
                    get("/api/v1/resource/test")
                    .session(session))
            .andExpect(status().isOk());
    }
}
Мартин Беккер
источник
2
Мы еще не добавили официальную поддержку Spring Security. См. Jira.springsource.org/browse/SEC-2015 Схема того, как это будет выглядеть, указана в github.com/SpringSource/spring-test-mvc/blob/master/src/test/…
Роб Винч,
Я не думаю, что создание объекта аутентификации и добавление сеанса с соответствующим атрибутом - это плохо. Как вы думаете, это действительный «обходной путь»? С другой стороны, прямая поддержка, конечно, была бы замечательной. Смотрится довольно аккуратно. Спасибо за ссылку!
Мартин Беккер
отличное решение. у меня сработало! просто небольшая проблема с названием защищенного метода, getPrincipal()которая, на мой взгляд, немного вводит в заблуждение. в идеале он должен был быть назван getAuthentication(). аналогично, в вашем signedIn()тесте локальная переменная должна быть названа authили authenticationвместоprincipal
Танвир
Что такое "getPrincipal (" test1 ") ¿?? Не могли бы вы объяснить, где это? Заранее спасибо
user2992476
@ user2992476 Вероятно, он возвращает объект типа UsernamePasswordAuthenticationToken. В качестве альтернативы вы создаете GrantedAuthority и конструируете этот объект.
bluelurker 02
31

Добавьте в pom.xml:

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

и использовать org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessorsдля запроса авторизации. См. Пример использования на https://github.com/rwinch/spring-security-test-blog ( https://jira.spring.io/browse/SEC-2592 ).

Обновить:

4.0.0.RC2 работает для spring -security 3.x. Для spring -security 4 spring-security-test становится частью spring-security ( http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test , версия такая же ).

Настройка изменена: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test-mockmvc

public void setup() {
    mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())  
            .build();
}

Пример для базовой аутентификации: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#testing-http-basic-authentication .

Григорий Кислин
источник
Это также устранило мою проблему с получением 404 при попытке войти через фильтр безопасности входа. Спасибо!
Ян Ньюленд
Привет, пока тестирую, как упоминал Г.Кислин. Я получаю следующее сообщение об ошибке «Ошибка аутентификации. UserDetailsService вернула значение null, что является нарушением контракта интерфейса». Любое предложение, пожалуйста. final AuthenticationRequest auth = new AuthenticationRequest (); auth.setUsername (идентификатор пользователя); auth.setPassword (пароль); mockMvc.perform (.. сообщение ( "/ API / авт /") Содержание (JSON (авт)) CONTENTTYPE (MediaType.APPLICATION_JSON));
Sanjeev
7

Вот пример для тех, кто хочет протестировать Spring MockMvc Security Config с использованием базовой аутентификации Base64.

String basicDigestHeaderValue = "Basic " + new String(Base64.encodeBase64(("<username>:<password>").getBytes()));
this.mockMvc.perform(get("</get/url>").header("Authorization", basicDigestHeaderValue).accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk());

Зависимость от Maven

    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.3</version>
    </dependency>
сойка
источник
3

Короткий ответ:

@Autowired
private WebApplicationContext webApplicationContext;

@Autowired
private Filter springSecurityFilterChain;

@Before
public void setUp() throws Exception {
    final MockHttpServletRequestBuilder defaultRequestBuilder = get("/dummy-path");
    this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext)
            .defaultRequest(defaultRequestBuilder)
            .alwaysDo(result -> setSessionBackOnRequestBuilder(defaultRequestBuilder, result.getRequest()))
            .apply(springSecurity(springSecurityFilterChain))
            .build();
}

private MockHttpServletRequest setSessionBackOnRequestBuilder(final MockHttpServletRequestBuilder requestBuilder,
                                                             final MockHttpServletRequest request) {
    requestBuilder.session((MockHttpSession) request.getSession());
    return request;
}

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

Длинный ответ:

Проверьте это решение (ответ для Spring 4): Как войти в систему с помощью Spring 3.2 New mvc testing

Надь Аттила
источник
2

Варианты, позволяющие избежать использования SecurityContextHolder в тестах:

  • Вариант 1 : использовать макеты - я имею в виду макет SecurityContextHolderс использованием какой-то библиотеки макетов - например, EasyMock
  • Вариант 2 : оберните вызов SecurityContextHolder.get...в свой код в какой-либо сервис - например, в SecurityServiceImplметоде, getCurrentPrincipalкоторый реализует SecurityServiceинтерфейс, а затем в своих тестах вы можете просто создать фиктивную реализацию этого интерфейса, которая возвращает желаемый принципал без доступа к SecurityContextHolder.
Павла Новакова
источник
Ммм, может, я не понимаю всей картины. Моя проблема заключалась в том, что SecurityContextPersistenceFilter заменяет SecurityContext, используя SecurityContext из HttpSessionSecurityContextRepository, который, в свою очередь, читает SecurityContext из соответствующего HttpSession. Таким образом, решение с использованием сеанса. Что касается вызова SecurityContextHolder: я отредактировал свой ответ, так что я больше не использую вызов SecurityContextHolder. Но также без введения каких-либо оберток или дополнительных фиктивных библиотек. Как вы думаете, это лучшее решение?
Мартин Беккер
Извините, я не совсем понял, что вы искали, и я не могу дать лучшего ответа, чем решение, которое вы придумали, и - похоже, это хороший вариант.
Павла Новакова
Все хорошо, спасибо. Я пока приму свое предложение как решение.
Мартин Беккер