Unit-Test mit Spring Security

140

Mein Unternehmen hat Spring MVC evaluiert, um festzustellen, ob wir es in einem unserer nächsten Projekte verwenden sollten. Bisher liebe ich das, was ich gesehen habe, und im Moment schaue ich mir das Spring Security-Modul an, um festzustellen, ob es etwas ist, das wir verwenden können / sollten.

Unsere Sicherheitsanforderungen sind ziemlich einfach. Ein Benutzer muss lediglich einen Benutzernamen und ein Kennwort angeben können, um auf bestimmte Teile der Website zugreifen zu können (z. B. um Informationen über sein Konto zu erhalten). und es gibt eine Handvoll Seiten auf der Website (FAQs, Support usw.), auf die ein anonymer Benutzer Zugriff erhalten sollte.

In dem von mir erstellten Prototyp habe ich ein "LoginCredentials" -Objekt (das nur Benutzername und Kennwort enthält) in Session für einen authentifizierten Benutzer gespeichert. Einige der Controller überprüfen, ob sich dieses Objekt in der Sitzung befindet, um beispielsweise einen Verweis auf den angemeldeten Benutzernamen zu erhalten. Ich möchte stattdessen diese selbst entwickelte Logik durch Spring Security ersetzen, was den netten Vorteil hätte, jede Art von "Wie verfolgen wir angemeldete Benutzer?" Zu entfernen. und "Wie authentifizieren wir Benutzer?" von meinem Controller / Business Code.

Es scheint, dass Spring Security ein (pro Thread) "Kontext" -Objekt bereitstellt, um von überall in Ihrer App auf den Benutzernamen / die Hauptinformationen zugreifen zu können ...

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

... was sehr unfrühlinghaft erscheint, da dieses Objekt in gewisser Weise ein (globaler) Singleton ist.

Meine Frage lautet: Wenn dies die Standardmethode für den Zugriff auf Informationen über den authentifizierten Benutzer in Spring Security ist, wie wird ein Authentifizierungsobjekt in den SecurityContext eingefügt, damit es für meine Komponententests verfügbar ist, wenn für die Komponententests eine erforderlich ist authentifizierter Nutzer?

Muss ich dies in der Initialisierungsmethode jedes Testfalls verkabeln?

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

Dies scheint zu ausführlich. Gibt es einen einfacheren Weg?

Das SecurityContextHolderObjekt selbst wirkt sehr frühlingshaft ...

matt b
quelle

Antworten:

48

Das Problem ist, dass Spring Security das Authentifizierungsobjekt nicht als Bean im Container verfügbar macht, sodass es nicht einfach ist, es sofort zu injizieren oder automatisch zu verdrahten.

Bevor wir mit der Verwendung von Spring Security begonnen haben, haben wir im Container eine Bean mit Sitzungsbereich erstellt, um den Principal zu speichern, diese in einen "AuthenticationService" (Singleton) zu injizieren und diese Bean dann in andere Services zu injizieren, die Kenntnisse über den aktuellen Principal benötigen.

Wenn Sie Ihren eigenen Authentifizierungsdienst implementieren, können Sie im Grunde das Gleiche tun: Erstellen Sie eine Bean mit Sitzungsbereich mit einer "Principal" -Eigenschaft, fügen Sie diese in Ihren Authentifizierungsdienst ein, lassen Sie den Auth-Dienst die Eigenschaft auf erfolgreiche Authentifizierung festlegen und dann Stellen Sie den Authentifizierungsdienst nach Bedarf anderen Beans zur Verfügung.

Ich würde mich bei der Verwendung von SecurityContextHolder nicht schlecht fühlen. obwohl. Ich weiß, dass es sich um einen statischen / Singleton handelt und dass Spring davon abhält, solche Dinge zu verwenden, aber ihre Implementierung achtet darauf, dass sie sich je nach Umgebung angemessen verhält: Sitzungsbereich in einem Servlet-Container, Thread-Bereich in einem JUnit-Test usw. Der eigentliche einschränkende Faktor eines Singleton ist, wenn es eine Implementierung bereitstellt, die für verschiedene Umgebungen unflexibel ist.

cliff.meyers
quelle
Danke, das ist ein nützlicher Rat. Was ich bisher getan habe, ist im Grunde, SecurityContextHolder.getContext () aufzurufen (über einige eigene Wrapper-Methoden, also wird es zumindest nur von einer Klasse aufgerufen).
Matt B
2
Obwohl nur ein Hinweis - ich glaube nicht, dass ServletContextHolder ein Konzept von HttpSession oder eine Methode zum Erkennen, ob es in einer Webserverumgebung ausgeführt wird - verwendet, verwendet es ThreadLocal, es sei denn, Sie konfigurieren es für die Verwendung eines anderen (die einzigen anderen zwei integrierten Modi sind InheritableThreadLocal und Global)
matt b
Der einzige Nachteil bei der Verwendung von Beans mit Sitzungs- / Anforderungsbereich im Frühjahr besteht darin, dass sie in einem JUnit-Test fehlschlagen. Sie können einen benutzerdefinierten Bereich implementieren, der Sitzung / Anforderung verwendet, sofern verfügbar, und auf den Thread zurückgreifen muss. Ich vermute, dass Spring Security etwas Ähnliches tut ...
cliff.meyers
Mein Ziel ist es, eine Rest-API ohne Sitzungen zu erstellen. Vielleicht mit einem aktualisierbaren Token. Obwohl dies meine Frage nicht beantwortete, half es. Danke
Pomagranite
166

Machen Sie es einfach wie gewohnt und fügen Sie es dann mit SecurityContextHolder.setContext()in Ihre Testklasse ein, zum Beispiel:

Regler:

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

Prüfung:

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
quelle
2
@Leonardo wo soll das Authentication aim controller hinzugefügt werden? Wie kann ich in jedem Methodenaufruf verstehen? Ist es in Ordnung, wenn "spring way" es nur hinzufügt, anstatt zu injizieren?
Oleg Kuts
Aber denken Sie daran, dass es mit TestNG nicht funktionieren wird, da SecurityContextHolder die lokale Thread-Variable enthält, sodass Sie diese Variable zwischen den Tests teilen ...
Łukasz Woźniczka
Mach es in @BeforeEach(JUnit5) oder @Before(JUnit 4). Gut und einfach.
WesternGun
30

Ohne die Frage zu beantworten, wie Authentifizierungsobjekte erstellt und eingefügt werden, bietet Spring Security 4.0 einige willkommene Alternativen zum Testen. Die @WithMockUserAnmerkung ermöglicht es dem Entwickler, einen Scheinbenutzer (mit optionalen Berechtigungen, Benutzernamen, Kennwort und Rollen) auf übersichtliche Weise anzugeben:

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

Es besteht auch die Möglichkeit, @WithUserDetailseine UserDetailsRückgabe von zu emulieren UserDetailsService, z

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

Weitere Details finden Sie in den Kapiteln @WithMockUser und @WithUserDetails in den Spring Security-Referenzdokumenten (aus denen die obigen Beispiele kopiert wurden).

matsev
quelle
29

Sie haben Recht, besorgt zu sein - statische Methodenaufrufe sind besonders problematisch für Unit-Tests, da Sie Ihre Abhängigkeiten nicht einfach verspotten können. Was ich Ihnen zeigen werde, ist, wie Sie den Spring IoC-Container die Drecksarbeit für Sie erledigen lassen und Ihnen ordentlichen, testbaren Code hinterlassen. SecurityContextHolder ist eine Framework-Klasse, und obwohl es in Ordnung sein kann, dass Ihr Sicherheitscode auf niedriger Ebene daran gebunden ist, möchten Sie wahrscheinlich eine übersichtlichere Schnittstelle für Ihre UI-Komponenten (dh Controller) bereitstellen.

cliff.meyers erwähnte einen Weg, um es zu umgehen - erstellen Sie Ihren eigenen "Haupt" -Typ und fügen Sie eine Instanz in die Verbraucher ein. Das in 2.x eingeführte Spring < aop: scoped-proxy /> -Tag in Kombination mit einer Bean-Definition für den Anforderungsbereich und die Unterstützung der Factory-Methode sind möglicherweise das Ticket für den am besten lesbaren Code.

Es könnte folgendermaßen funktionieren:

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
}

Bisher nichts Kompliziertes, oder? Tatsächlich mussten Sie das meiste wahrscheinlich schon tun. Definieren Sie als Nächstes in Ihrem Bean-Kontext eine Bean mit Anforderungsbereich, die den Principal enthält:

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

Dank der Magie des aop: scoped-proxy-Tags wird die statische Methode getUserDetails jedes Mal aufgerufen, wenn eine neue HTTP-Anforderung eingeht, und alle Verweise auf die Eigenschaft currentUser werden korrekt aufgelöst. Jetzt wird das Testen von Einheiten trivial:

protected void setUp() {
    // existing init code

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

Hoffe das hilft!

Pavel
quelle
9

Persönlich würde ich Powermock zusammen mit Mockito oder Easymock verwenden, um den statischen SecurityContextHolder.getSecurityContext () in Ihrem Unit- / Integrationstest zu verspotten, z

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

Zugegeben, hier gibt es eine Menge Code für die Kesselplatte, z. B. ein Authentifizierungsobjekt verspotten, einen SecurityContext verspotten, um die Authentifizierung zurückzugeben, und schließlich den SecurityContextHolder verspotten, um den SecurityContext zu erhalten. Er ist jedoch sehr flexibel und ermöglicht es Ihnen, Unit-Tests für Szenarien wie Null-Authentifizierungsobjekte durchzuführen usw., ohne dass Sie Ihren (Nicht-Test-) Code ändern müssen


quelle
7

Die Verwendung einer statischen Aufladung ist in diesem Fall der beste Weg, um sicheren Code zu schreiben.

Ja, Statik ist im Allgemeinen schlecht - im Allgemeinen, aber in diesem Fall ist die Statik genau das, was Sie wollen. Da der Sicherheitskontext einen Principal mit dem aktuell ausgeführten Thread verknüpft, würde der sicherste Code so direkt wie möglich vom Thread auf die Statik zugreifen. Durch das Ausblenden des Zugriffs hinter einer injizierten Wrapper-Klasse erhält ein Angreifer mehr Angriffspunkte. Sie benötigen keinen Zugriff auf den Code (den sie nur schwer ändern können, wenn das JAR signiert wird). Sie benötigen lediglich eine Möglichkeit, die Konfiguration zu überschreiben. Dies kann zur Laufzeit erfolgen oder indem XML in den Klassenpfad verschoben wird. Selbst die Verwendung von Annotation Injection wäre mit externem XML überschreibbar. Solches XML könnte dem laufenden System einen Rogue-Principal hinzufügen.

Michael Bushe
quelle
4

Ich fragte die gleiche Frage mich über hier , und habe gerade gebucht eine Antwort , dass ich vor kurzem gefunden. Die kurze Antwort lautet: Injizieren Sie a SecurityContextund beziehen Sie sich SecurityContextHoldernur auf Ihre Spring-Konfiguration, um die zu erhaltenSecurityContext

Scott Bale
quelle
3

Allgemeines

In der Zwischenzeit (seit Version 3.2 im Jahr 2013 dank SEC-2298 ) kann die Authentifizierung mithilfe der Annotation @AuthenticationPrincipal in MVC-Methoden eingefügt werden :

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

Tests

In Ihrem Unit-Test können Sie diese Methode natürlich direkt aufrufen. In Integrationstests mit können org.springframework.test.web.servlet.MockMvcSie org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()den Benutzer folgendermaßen injizieren:

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

Dies füllt jedoch nur direkt den SecurityContext aus. Wenn Sie sicherstellen möchten, dass der Benutzer aus einer Sitzung in Ihrem Test geladen wird, können Sie Folgendes verwenden:

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;
        }
    };
}
Yankee
quelle
2

Ich würde einen Blick auf die abstrakten Testklassen und Scheinobjekte von Spring werfen, über die hier gesprochen wird . Sie bieten eine leistungsstarke Möglichkeit zur automatischen Verkabelung Ihrer von Spring verwalteten Objekte, wodurch das Testen von Einheiten und Integrationen vereinfacht wird.

digitalsanctum
quelle
Obwohl diese Testklassen hilfreich sind, bin ich mir nicht sicher, ob sie hier zutreffen. Meine Tests haben kein Konzept für den ApplicationContext - sie benötigen keines. Ich muss nur sicherstellen, dass der SecurityContext gefüllt ist, bevor die Testmethode ausgeführt wird - es fühlt sich einfach schmutzig an, ihn zuerst in einem ThreadLocal festlegen zu müssen
matt b
1

Die Authentifizierung ist eine Eigenschaft eines Threads in einer Serverumgebung, genauso wie sie eine Eigenschaft eines Prozesses in einem Betriebssystem ist. Eine Bean-Instanz für den Zugriff auf Authentifizierungsinformationen wäre unpraktisch für die Konfiguration und den Verkabelungsaufwand, ohne dass dies von Vorteil wäre.

In Bezug auf die Testauthentifizierung gibt es verschiedene Möglichkeiten, wie Sie Ihr Leben einfacher gestalten können. Mein Favorit ist es, einen benutzerdefinierten Annotations- @Authenticatedund Testausführungs-Listener zu erstellen, der ihn verwaltet. Suchen Sie DirtiesContextTestExecutionListenernach Inspiration.

Pavel Horal
quelle
0

Nach ziemlich viel Arbeit konnte ich das gewünschte Verhalten reproduzieren. Ich hatte den Login über MockMvc emuliert. Es ist zu schwer für die meisten Unit-Tests, aber hilfreich für Integrationstests.

Natürlich bin ich bereit, diese neuen Funktionen in Spring Security 4.0 zu sehen, die unsere Tests erleichtern.

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
quelle