Spring Test & Security: Jak udawać uwierzytelnianie?

124

Próbowałem dowiedzieć się, jak przeprowadzić test jednostkowy, czy moje adresy URL moich kontrolerów są odpowiednio zabezpieczone. Na wypadek, gdyby ktoś zmienił rzeczy i przypadkowo usunął ustawienia zabezpieczeń.

Moja metoda kontrolera wygląda następująco:

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

Skonfigurowałem WebTestEnvironment w następujący sposób:

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

W moim rzeczywistym teście próbowałem zrobić coś takiego:

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

}

Podniosłem to tutaj:

Jednak jeśli przyjrzeć się bliżej, pomaga to tylko wtedy, gdy nie wysyłamy rzeczywistych żądań do adresów URL, ale tylko podczas testowania usług na poziomie funkcji. W moim przypadku został zgłoszony wyjątek „odmowa dostępu”:

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]
        ...

Poniższe dwa komunikaty dziennika są warte uwagi, mówiąc zasadniczo, że żaden użytkownik nie został uwierzytelniony, co wskazuje, że ustawienie Principalnie zadziałało lub zostało nadpisane.

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
Martin Becker
źródło
Nazwa Twojej firmy, eu.ubicon, jest wyświetlana podczas importu. Czy to nie jest zagrożenie bezpieczeństwa?
Kyle Bridenstine
2
Cześć, dzięki za komentarz! Nie rozumiem jednak dlaczego. W każdym razie jest to oprogramowanie typu open source. Jeśli jesteś zainteresowany, odwiedź stronę bitbucket.org/ubicon/ubicon (lub bitbucket.org/dmir_wue/everyaware, aby uzyskać najnowszy fork). Daj mi znać, jeśli coś przegapię.
Martin Becker,
Sprawdź to rozwiązanie (odpowiedź na wiosnę 4): stackoverflow.com/questions/14308341/…
Nagy Attila

Odpowiedzi:

101

Szukając odpowiedzi, nie mogłem znaleźć żadnej, która byłaby jednocześnie łatwa i elastyczna, po czym znalazłem Spring Security Reference i zdałem sobie sprawę, że są blisko doskonałych rozwiązań. Rozwiązania AOP często są największymi z nich do testowania, i wiosna zapewnia ją @WithMockUser, @WithUserDetailsa @WithSecurityContextw tym artefaktem:

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

W większości przypadków @WithUserDetailszapewnia elastyczność i moc, której potrzebuję.

Jak działa @WithUserDetails?

Zasadniczo wystarczy utworzyć niestandardowy UserDetailsServiceze wszystkimi możliwymi profilami użytkowników, które chcesz przetestować. Na przykład

@TestConfiguration
public class SpringSecurityWebAuxTestConfig {

    @Bean
    @Primary
    public UserDetailsService userDetailsService() {
        User basicUser = new UserImpl("Basic User", "[email protected]", "password");
        UserActive basicActiveUser = new UserActive(basicUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_USER"),
                new SimpleGrantedAuthority("PERM_FOO_READ")
        ));

        User managerUser = new UserImpl("Manager User", "[email protected]", "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
        ));
    }
}

Teraz mamy gotowych naszych użytkowników, więc wyobraź sobie, że chcemy przetestować kontrolę dostępu do tej funkcji kontrolera:

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

Tutaj mamy funkcję get mapped do route / foo / salute i testujemy zabezpieczenia oparte na rolach z @Securedadnotacją, chociaż możesz również przetestować @PreAuthorizei @PostAuthorize. Stwórzmy dwa testy, jeden, aby sprawdzić, czy prawidłowy użytkownik może zobaczyć tę odpowiedź pozdrowienia, a drugi, aby sprawdzić, czy jest to rzeczywiście zabronione.

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

    @Autowired
    private MockMvc mockMvc;

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

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

Jak widzisz, dokonaliśmy importu, SpringSecurityWebAuxTestConfigaby udostępnić naszym użytkownikom do testów. Każdy z nich został użyty w odpowiadającym mu przypadku testowym po prostu za pomocą prostej adnotacji, zmniejszając kod i złożoność.

Lepiej użyj @WithMockUser dla prostszego zabezpieczenia opartego na rolach

Jak widzisz, @WithUserDetailszapewnia pełną elastyczność, której potrzebujesz do większości aplikacji. Umożliwia korzystanie z niestandardowych użytkowników z dowolnym GrantedAuthority, takim jak role lub uprawnienia. Ale jeśli pracujesz tylko z rolami, testowanie może być jeszcze łatwiejsze i możesz uniknąć tworzenia niestandardowych UserDetailsService. W takich przypadkach określ prostą kombinację użytkownika, hasła i ról za pomocą @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";
}

Adnotacja definiuje wartości domyślne dla bardzo prostego użytkownika. Ponieważ w naszym przypadku testowana przez nas trasa wymaga tylko, aby uwierzytelniony użytkownik był menedżerem, możemy zakończyć korzystanie SpringSecurityWebAuxTestConfigi zrobić to.

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

Zauważ, że teraz zamiast user [email protected] otrzymujemy wartość domyślną dostarczoną przez @WithMockUser: user ; jeszcze nie będzie to większego znaczenia, ponieważ to, co naprawdę dbają o to jego rola ROLE_MANAGER.

Wnioski

Jak widać z adnotacjami takimi jak @WithUserDetailsi @WithMockUser, możemy przełączać się między różnymi scenariuszami uwierzytelnionych użytkowników bez budowania klas wyalienowanych z naszej architektury tylko do wykonywania prostych testów. Zaleca się również, aby zobaczyć, jak działa @WithSecurityContext, zapewniając jeszcze większą elastyczność.

EliuX
źródło
Jak kpić z wielu użytkowników ? Na przykład, pierwsze żądanie jest wysyłane przez tom, a drugie przez jerry?
ch271828n
Możesz utworzyć funkcję, w której twój test jest z tomem, i utworzyć kolejny test z tą samą logiką i przetestować go z Jerrym. Każdy test będzie miał określony wynik, więc będą różne asercje, a jeśli test się nie powiedzie, powie ci po nazwie, który użytkownik / rola nie zadziałał. Pamiętaj, że w żądaniu użytkownik może być tylko jeden, więc określanie wielu użytkowników w żądaniu nie ma sensu.
EliuX
Przepraszam, mam na myśli taki przykładowy scenariusz: Testujemy to, Tom tworzy tajny artykuł, a potem Jerry próbuje go przeczytać, a Jerry nie powinien tego widzieć (ponieważ jest tajny). W tym przypadku jest to jeden test jednostkowy ...
ch271828n
Wygląda prawie tak, jak scenariusz BasicUseri Manager Userpodany w odpowiedzi. Kluczową koncepcją jest to, że zamiast dbać o użytkowników, tak naprawdę zależy nam na ich rolach, ale każdy z tych testów, umieszczony w tym samym teście jednostkowym, w rzeczywistości reprezentuje różne zapytania. wykonywane przez różnych użytkowników (z różnymi rolami) do tego samego punktu końcowego.
EliuX
61

Od wiosny 4.0+ najlepszym rozwiązaniem jest oznaczenie metody testowej adnotacją @WithMockUser

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

Pamiętaj, aby dodać do projektu następującą zależność

'org.springframework.security:spring-security-test:4.2.3.RELEASE'
GummyBear21
źródło
1
Wiosna jest niesamowita. Dzięki
TuGordoBello
Dobra odpowiedź. Co więcej - nie musisz używać mockMvc, ale w przypadku, gdy korzystasz np. Z PagingAndSortingRepository z springframework.data - możesz po prostu wywołać metody bezpośrednio z repozytorium (które są opatrzone adnotacją EL @PreAuthorize (......))
supertramp
50

Okazało się, że SecurityContextPersistenceFilterbędący częścią łańcucha filtrów Spring Security zawsze resetuje mój SecurityContext, który ustawiłem wywołując SecurityContextHolder.getContext().setAuthentication(principal)(lub używając .principal(principal)metody). Ten filtr ustawia SecurityContextin the SecurityContextHolderz a SecurityContextfrom a SecurityContextRepository OVERWRITING ten, który ustawiłem wcześniej. Repozytorium to HttpSessionSecurityContextRepositorydomyślnie. W HttpSessionSecurityContextRepositorykontroluje dany HttpRequesti próbuje uzyskać dostęp do odpowiadającego HttpSession. Jeśli istnieje, spróbuje odczytać SecurityContextplik HttpSession. Jeśli to się nie powiedzie, repozytorium generuje pusty plik SecurityContext.

Zatem moim rozwiązaniem jest przekazanie HttpSessionwraz z żądaniem, które zawiera 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());
    }
}
Martin Becker
źródło
2
Nie dodaliśmy jeszcze oficjalnego wsparcia dla Spring Security. Zobacz jira.springsource.org/browse/SEC-2015 Zarys tego, jak będzie wyglądać, jest określony na github.com/SpringSource/spring-test-mvc/blob/master/src/test/ ...
Rob Winch
Nie sądzę, aby tworzenie obiektu uwierzytelniania i dodanie sesji z odpowiednim atrybutem było takie złe. Czy uważasz, że jest to uzasadnione „obejście”? Z drugiej strony, bezpośrednie wsparcie byłoby oczywiście świetne. Wygląda całkiem schludnie. Dzięki za link!
Martin Becker
świetne rozwiązanie. pracował dla mnie! tylko drobna kwestia z nazewnictwem chronionej metody, getPrincipal()która moim zdaniem jest nieco myląca. Idealnie powinien zostać nazwany getAuthentication(). podobnie w twoim signedIn()teście zmienna lokalna powinna być nazwana authlub authenticationzamiastprincipal
Tanvir
Co to jest „getPrincipal („ test1 ”) ¿?? Czy możesz wyjaśnić, gdzie to jest? Z góry
dziękuję
@ user2992476 Prawdopodobnie zwraca obiekt typu UsernamePasswordAuthenticationToken. Alternatywnie możesz utworzyć GrantedAuthority i skonstruować ten obiekt.
bluelurker
31

Dodaj w pom.xml:

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

i użyj org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessorsdo żądania autoryzacji. Zobacz przykładowe użycie na https://github.com/rwinch/spring-security-test-blog ( https://jira.spring.io/browse/SEC-2592 ).

Aktualizacja:

4.0.0.RC2 działa dla zabezpieczenia wiosennego 3.x. Do wiosennego testu bezpieczeństwa 4 wiosenny test bezpieczeństwa stał się częścią spring-security ( http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test , wersja jest taka sama ).

Konfiguracja została zmieniona: 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();
}

Przykład podstawowego uwierzytelnienia: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#testing-http-basic-authentication .

Grigorij Kislin
źródło
Rozwiązało to również mój problem z uzyskaniem 404 podczas próby logowania za pomocą filtru bezpieczeństwa logowania. Dzięki!
Ian Newland
Cześć, podczas testów, jak wspomniał GKislin. Otrzymuję następujący komunikat o błędzie „Uwierzytelnianie nie powiodło się. Usługa UserDetailsService zwróciła wartość null, co jest naruszeniem umowy interfejsu”. Wszelkie sugestie proszę. final AuthenticationRequest auth = new AuthenticationRequest (); auth.setUsername (userId); auth.setPassword (hasło); mockMvc.perform (post ("/ api / auth /"). content (json (auth)). contentType (MediaType.APPLICATION_JSON));
Sanjeev
7

Oto przykład dla tych, którzy chcą przetestować Spring MockMvc Security Config przy użyciu podstawowego uwierzytelniania 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());

Zależność Mavena

    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.3</version>
    </dependency>
Sójka
źródło
3

Krótka odpowiedź:

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

Po wykonaniu formLoginwiosennego testu bezpieczeństwa każde Twoje żądanie będzie automatycznie wywoływane jako zalogowany użytkownik.

Długa odpowiedź:

Sprawdź to rozwiązanie (odpowiedź na wiosnę 4): Jak zalogować użytkownika za pomocą nowego testu MVC wiosna 3.2

Nagy Attila
źródło
2

Opcje, których należy unikać przy użyciu SecurityContextHolder w testach:

  • Opcja 1 : użyj makiet - mam na myśli makietę z SecurityContextHolderużyciem jakiejś biblioteki - na przykład EasyMock
  • Opcja 2 : zawiń wywołanie SecurityContextHolder.get...w swoim kodzie w jakiejś usłudze - na przykład za SecurityServiceImplpomocą metody getCurrentPrincipalimplementującej SecurityServiceinterfejs, a następnie w testach możesz po prostu utworzyć makietę implementacji tego interfejsu, która zwraca żądaną jednostkę główną bez dostępu do SecurityContextHolder.
Pavla Nováková
źródło
Mh, może nie rozumiem całego obrazu. Mój problem polegał na tym, że SecurityContextPersistenceFilter zastępuje SecurityContext przy użyciu SecurityContext z HttpSessionSecurityContextRepository, który z kolei odczytuje SecurityContext z odpowiedniego HttpSession. Stąd rozwiązanie wykorzystujące sesję. Odnośnie wywołania SecurityContextHolder: zredagowałem moją odpowiedź tak, że nie używam już wywołania SecurityContextHolder. Ale także bez wprowadzania jakichkolwiek zawijających lub dodatkowych bibliotek szyderczych. Myślisz, że to lepsze rozwiązanie?
Martin Becker
Przepraszam, że nie zrozumiałem dokładnie, czego szukasz i nie mogę udzielić lepszej odpowiedzi niż rozwiązanie, które wymyśliłeś i - wydaje się, że to dobra opcja.
Pavla Nováková
W porządku, dzięki. Na razie przyjmuję moją propozycję jako rozwiązanie.
Martin Becker,