Testowanie jednostkowe za pomocą Spring Security

140

Moja firma oceniała Spring MVC, aby określić, czy powinniśmy go użyć w jednym z naszych następnych projektów. Jak dotąd podoba mi się to, co widziałem, a teraz przyglądam się modułowi Spring Security, aby określić, czy jest to coś, czego możemy / powinniśmy użyć.

Nasze wymagania dotyczące bezpieczeństwa są dość podstawowe; wystarczy, że użytkownik będzie mógł podać nazwę użytkownika i hasło, aby uzyskać dostęp do niektórych części witryny (na przykład uzyskać informacje o swoim koncie); w witrynie znajduje się kilka stron (często zadawane pytania, pomoc techniczna itp.), do których anonimowy użytkownik powinien mieć dostęp.

W utworzonym przeze mnie prototypie zapisałem obiekt „LoginCredentials” (zawierający tylko nazwę użytkownika i hasło) w sesji dla uwierzytelnionego użytkownika; niektóre kontrolery sprawdzają, czy ten obiekt jest w sesji, na przykład, aby uzyskać odniesienie do nazwy zalogowanego użytkownika. Chcę zamiast tego zastąpić tę rodzimą logikę rozwiązaniem Spring Security, co przyniosłoby przyjemną korzyść w postaci usunięcia wszelkiego rodzaju „jak śledzimy zalogowanych użytkowników?” i „jak uwierzytelniamy użytkowników?” z mojego kontrolera / kodu biznesowego.

Wygląda na to, że Spring Security udostępnia obiekt „kontekstu” (dla każdego wątku), aby móc uzyskać dostęp do informacji o nazwie użytkownika / głównym z dowolnego miejsca w aplikacji ...

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

... co wydaje się bardzo nie-Spring, ponieważ ten obiekt jest w pewnym sensie (globalnym) singletonem.

Moje pytanie brzmi: jeśli jest to standardowy sposób uzyskiwania dostępu do informacji o uwierzytelnionym użytkowniku w Spring Security, jaki jest akceptowany sposób wstrzyknięcia obiektu Authentication do SecurityContext, aby był dostępny dla moich testów jednostkowych, gdy testy jednostkowe wymagają Uwierzytelniony użytkownik?

Czy muszę to połączyć w metodzie inicjalizacji każdego przypadku testowego?

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

Wydaje się, że jest to zbyt szczegółowe. Czy istnieje prostszy sposób?

Sam SecurityContextHolderobiekt wydaje się bardzo nie-wiosenny ...

matowe b
źródło

Odpowiedzi:

48

Problem polega na tym, że Spring Security nie udostępnia obiektu uwierzytelniania jako fasoli w kontenerze, więc nie ma możliwości łatwego wstrzyknięcia lub automatycznego połączenia po wyjęciu z pudełka.

Zanim zaczęliśmy korzystać z Spring Security, utworzyliśmy komponent bean o zasięgu sesji w kontenerze do przechowywania jednostki głównej, wstrzyknęliśmy go do usługi „AuthenticationService” (singleton), a następnie wstrzyknęliśmy ten komponent bean do innych usług, które wymagały znajomości aktualnego podmiotu głównego.

Jeśli wdrażasz własną usługę uwierzytelniania, możesz zasadniczo zrobić to samo: utworzyć komponent bean o zasięgu sesji z właściwością „Principal”, wstrzyknąć go do usługi uwierzytelniania, poprosić usługę autoryzacji o ustawienie właściwości na pomyślne uwierzytelnianie, a następnie udostępnić usługę autoryzacji innym ziarnom, gdy tego potrzebujesz.

Nie czułbym się źle, używając SecurityContextHolder. chociaż. Wiem, że jest to statyczny / singleton i że Spring odradza używanie takich rzeczy, ale ich implementacja dba o odpowiednie zachowanie w zależności od środowiska: zakres sesji w kontenerze serwletu, zakres wątku w teście JUnit itp. Prawdziwy czynnik ograniczający Singletona ma miejsce, gdy zapewnia implementację, która jest nieelastyczna w różnych środowiskach.

cliff.meyers
źródło
Dzięki, to przydatna rada. To, co zrobiłem do tej pory, to po prostu kontynuowanie wywoływania SecurityContextHolder.getContext () (przez kilka własnych metod opakowujących, więc przynajmniej jest wywoływana tylko z jednej klasy).
mat b
2
Chociaż tylko jedna uwaga - nie sądzę, że ServletContextHolder ma jakąkolwiek koncepcję HttpSession lub sposób na sprawdzenie, czy działa w środowisku serwera WWW - używa ThreadLocal, chyba że skonfigurujesz go do używania czegoś innego (jedyne dwa pozostałe wbudowane tryby to InheritableThreadLocal i Global)
mat b
Jedyną wadą używania komponentów bean o zasięgu sesji / żądań na wiosnę jest niepowodzenie testu JUnit. Możesz zaimplementować niestandardowy zakres, który będzie korzystał z sesji / żądania, jeśli będzie dostępny, i powróci do wątku. Domyślam się, że Spring Security robi coś podobnego ...
cliff.meyers,
Moim celem jest zbudowanie interfejsu API Rest bez sesji. Być może z odświeżalnym tokenem. Chociaż to nie odpowiadało na moje pytanie, pomogło. Dzięki
Pomagranite
166

Po prostu zrób to w zwykły sposób, a następnie wstaw go używając SecurityContextHolder.setContext()w swojej klasie testowej, na przykład:

Kontroler:

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

Test:

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);
Leonardo Eloy
źródło
2
@Leonardo, gdzie należy to Authentication adodać w kontrolerze? Jak rozumiem w każdym wywołaniu metody? Czy jest w porządku na „wiosenny sposób” po prostu go dodać zamiast wstrzykiwać?
Oleg Kuts
Ale pamiętaj, że nie będzie działać z TestNG, ponieważ SecurityContextHolder przechowuje zmienną lokalnego wątku, więc udostępniasz tę zmienną między testami ...
Łukasz Woźniczka
Zrób to w @BeforeEach(JUnit5) lub @Before(JUnit 4). Dobra i prosta.
WesternGun
30

Nie odpowiadając na pytanie, jak tworzyć i wstrzykiwać obiekty uwierzytelniania, Spring Security 4.0 zapewnia kilka pożądanych alternatyw, jeśli chodzi o testowanie. @WithMockUserAdnotacji pozwala programiście określić mock użytkownika (z organami opcjonalne, nazwę użytkownika, hasło i ról) w zgrabny sposób:

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

Istnieje również możliwość wykorzystania @WithUserDetailsdo emulacji UserDetailszwracanego z UserDetailsServicenp

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

Więcej szczegółów można znaleźć w rozdziałach @WithMockUser i @WithUserDetails w dokumentacji Spring Security (z której skopiowano powyższe przykłady)

matsev
źródło
29

Masz rację, jeśli chodzi o obawy - statyczne wywołania metod są szczególnie problematyczne w przypadku testów jednostkowych, ponieważ nie można łatwo kpić ze swoich zależności. Pokażę ci, jak pozwolić kontenerowi Spring IoC wykonać za Ciebie brudną robotę, pozostawiając zgrabny, testowalny kod. SecurityContextHolder jest klasą frameworkową i chociaż może być w porządku, aby twój kod bezpieczeństwa niskiego poziomu był z nią powiązany, prawdopodobnie chcesz udostępnić bardziej estetyczny interfejs do komponentów UI (tj. Kontrolerów).

cliff.meyers wspomniał o tym w jeden sposób - utwórz własny typ „Principal” i wstrzyknij instancję konsumentom. Znacznik Spring < aop: scoped-proxy /> wprowadzony w 2.x w połączeniu z definicją komponentu bean zakresu żądania i obsługą metod fabrycznych może być przepustką do najbardziej czytelnego kodu.

To mogłoby działać następująco:

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
}

Na razie nic skomplikowanego, prawda? W rzeczywistości większość z tych czynności prawdopodobnie musiałaś już zrobić. Następnie, w kontekście twojego beana, zdefiniuj komponent bean o zakresie żądania, który będzie przechowywać jednostkę główną:

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

Dzięki magii tagu aop: scoped-proxy, statyczna metoda getUserDetails będzie wywoływana za każdym razem, gdy nadejdzie nowe żądanie HTTP, a wszelkie odniesienia do właściwości currentUser zostaną poprawnie rozwiązane. Teraz testy jednostkowe stają się trywialne:

protected void setUp() {
    // existing init code

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

Mam nadzieję że to pomoże!

Pavel
źródło
9

Osobiście po prostu użyłbym Powermock wraz z Mockito lub Easymock do mockowania statycznego SecurityContextHolder.getSecurityContext () w twoim urządzeniu / teście integracji, np.

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

Trzeba przyznać, że jest tutaj sporo kodu kotłowego, tj. Mockowanie obiektu Authentication, mockowanie SecurityContext w celu zwrócenia Authentication i wreszcie mockowanie SecurityContextHolder w celu uzyskania SecurityContext, jednak jest bardzo elastyczny i pozwala na testy jednostkowe w scenariuszach takich jak null obiekty uwierzytelniania itd. bez konieczności zmiany (nie testowego) kodu


źródło
7

Użycie statycznego w tym przypadku jest najlepszym sposobem pisania bezpiecznego kodu.

Tak, statyka jest ogólnie zła - ogólnie rzecz biorąc, ale w tym przypadku statystyka jest tym, czego chcesz. Ponieważ kontekst zabezpieczeń kojarzy jednostkę główną z aktualnie uruchomionym wątkiem, najbezpieczniejszy kod uzyskiwałby dostęp do statycznej z wątku tak bezpośrednio, jak to możliwe. Ukrycie dostępu za wstrzykniętą klasą opakowującą zapewnia atakującemu więcej punktów do zaatakowania. Nie potrzebowaliby dostępu do kodu (który mieliby trudności ze zmianą, gdyby jar był podpisany), po prostu potrzebują sposobu na przesłonięcie konfiguracji, co można zrobić w czasie wykonywania lub wrzucić trochę XML do ścieżki klas. Nawet użycie iniekcji adnotacji byłoby możliwe do zastąpienia przez zewnętrzny kod XML. Taki XML mógłby wstrzyknąć działającemu systemowi nieuczciwą jednostkę główną.

Michael Bushe
źródło
4

I to samo pytanie sobie ponad tutaj , i właśnie opublikował odpowiedź, że Niedawno znalazłem. Krótka odpowiedź brzmi: wstrzyknij a SecurityContexti odwołuj się SecurityContextHoldertylko do konfiguracji Springa, aby uzyskać plikSecurityContext

Scott Bale
źródło
3

Generał

W międzyczasie (od wersji 3.2, w roku 2013 dzięki SEC-2298 ) uwierzytelnianie można wprowadzić do metod MVC za pomocą adnotacji @AuthenticationPrincipal :

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

Testy

W teście jednostkowym możesz oczywiście bezpośrednio wywołać tę metodę. W testach integracyjnych za pomocą org.springframework.test.web.servlet.MockMvcmożesz org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()wstrzyknąć użytkownika w następujący sposób:

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

Spowoduje to jednak bezpośrednie wypełnienie SecurityContext. Jeśli chcesz mieć pewność, że użytkownik jest ładowany z sesji w twoim teście, możesz użyć tego:

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;
        }
    };
}
Jankes
źródło
2

Chciałbym przyjrzeć się abstrakcyjnym klasom testowym Springa i obiektom pozorowanym, o których tutaj mowa . Zapewniają skuteczny sposób automatycznego okablowania obiektów zarządzanych przez Spring, ułatwiając testowanie jednostek i integracji.

digitalsanctum
źródło
Chociaż te klasy testowe są pomocne, nie jestem pewien, czy mają zastosowanie tutaj. Moje testy nie mają pojęcia ApplicationContext - nie potrzebują go. Wszystko, czego potrzebuję, to upewnić się, że SecurityContext jest wypełniony przed uruchomieniem metody testowej - po prostu czuję się brudny, aby najpierw ustawić go w ThreadLocal
mat b
1

Uwierzytelnianie jest właściwością wątku w środowisku serwera w taki sam sposób, jak jest właściwością procesu w systemie operacyjnym. Posiadanie instancji bean do uzyskiwania dostępu do informacji uwierzytelniających byłoby niewygodną konfiguracją i kosztem okablowania bez żadnych korzyści.

Jeśli chodzi o uwierzytelnianie testowe, istnieje kilka sposobów na ułatwienie sobie życia. Moim ulubionym jest tworzenie niestandardowych adnotacji @Authenticatedi nasłuchiwania wykonywania testów, który nim zarządza. Poszukaj DirtiesContextTestExecutionListenerinspiracji.

Pavel Horal
źródło
0

Po wielu pracach udało mi się odtworzyć pożądane zachowanie. Emulowałem logowanie za pomocą MockMvc. Jest zbyt ciężki dla większości testów jednostkowych, ale pomocny przy testach integracyjnych.

Oczywiście chciałbym zobaczyć te nowe funkcje w Spring Security 4.0, które ułatwią nam testowanie.

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
źródło