Pruebas unitarias con Spring Security

140

Mi compañía ha estado evaluando Spring MVC para determinar si deberíamos usarlo en uno de nuestros próximos proyectos. Hasta ahora me encanta lo que he visto, y ahora estoy echando un vistazo al módulo Spring Security para determinar si es algo que podemos / debemos usar.

Nuestros requisitos de seguridad son bastante básicos; un usuario solo necesita poder proporcionar un nombre de usuario y una contraseña para poder acceder a ciertas partes del sitio (como para obtener información sobre su cuenta); y hay un puñado de páginas en el sitio (preguntas frecuentes, soporte, etc.) donde se debe dar acceso a un usuario anónimo.

En el prototipo que he estado creando, he estado almacenando un objeto "LoginCredentials" (que solo contiene nombre de usuario y contraseña) en Session para un usuario autenticado; algunos de los controladores verifican si este objeto está en sesión para obtener una referencia al nombre de usuario conectado, por ejemplo. Estoy buscando reemplazar esta lógica local con Spring Security, que tendría el beneficio de eliminar cualquier tipo de "¿cómo rastreamos a los usuarios registrados?" y "¿cómo autenticamos a los usuarios?" de mi controlador / código comercial.

Parece que Spring Security proporciona un objeto "contextual" (por subproceso) para poder acceder al nombre de usuario / información principal desde cualquier lugar de su aplicación ...

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

... que parece muy poco primaveral ya que este objeto es un singleton (global), en cierto modo.

Mi pregunta es esta: si esta es la forma estándar de acceder a la información sobre el usuario autenticado en Spring Security, ¿cuál es la forma aceptada de inyectar un objeto de autenticación en SecurityContext para que esté disponible para mis pruebas unitarias cuando las pruebas unitarias requieren un usuario autenticado?

¿Necesito conectar esto en el método de inicialización de cada caso de prueba?

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

Esto parece demasiado detallado. hay una manera mas facil?

El SecurityContextHolderobjeto en sí parece muy poco parecido a Spring ...

mate b
fuente

Respuestas:

48

El problema es que Spring Security no hace que el objeto de autenticación esté disponible como un bean en el contenedor, por lo que no hay forma de inyectarlo o conectarlo automáticamente.

Antes de comenzar a usar Spring Security, creábamos un bean de ámbito de sesión en el contenedor para almacenar el Principal, lo inyectamos en un "AuthenticationService" (singleton) y luego inyectamos este bean en otros servicios que necesitaban conocimiento del Principal actual.

Si está implementando su propio servicio de autenticación, básicamente podría hacer lo mismo: crear un bean de ámbito de sesión con una propiedad "principal", inyectar esto en su servicio de autenticación, hacer que el servicio de autenticación establezca la propiedad en autenticación exitosa, y luego ponga el servicio de autenticación a disposición de otros beans según lo necesite.

No me sentiría tan mal por usar SecurityContextHolder. aunque. Sé que es un estático / Singleton y que Spring desalienta el uso de tales cosas, pero su implementación se encarga de comportarse adecuadamente según el entorno: ámbito de sesión en un contenedor de Servlet, ámbito de hilo en una prueba JUnit, etc. El factor limitante real de Singleton es cuando proporciona una implementación que es inflexible a diferentes entornos.

acantilado
fuente
Gracias, este es un consejo útil. Lo que he hecho hasta ahora es básicamente proceder a llamar a SecurityContextHolder.getContext () (a través de algunos métodos de envoltura propios, por lo que al menos solo se llama desde una clase).
mate b
2
Aunque solo una nota: no creo que ServletContextHolder tenga ningún concepto de HttpSession o una forma de saber si está funcionando en un entorno de servidor web: usa ThreadLocal a menos que lo configure para usar otra cosa (los únicos dos modos incorporados son InheritableThreadLocal y Global)
mate b
El único inconveniente de usar beans de sesión / ámbito de solicitud en Spring es que fallarán en una prueba JUnit. Lo que puede hacer es implementar un ámbito personalizado que utilizará sesión / solicitud si está disponible y es necesario recurrir al hilo. Supongo que Spring Security está haciendo algo similar ...
cliff.meyers
Mi objetivo es construir una API de descanso sin sesiones. Quizás con una ficha renovable. Si bien esto no respondió a mi pregunta, ayudó. Gracias
Pomagranite
166

Simplemente hágalo de la manera habitual y luego insértelo usando SecurityContextHolder.setContext()en su clase de prueba, por ejemplo:

Controlador:

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

Prueba:

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
fuente
2
@Leonardo, ¿dónde se debe Authentication aagregar esto en el controlador? Como puedo entender en cada método de invocación? ¿Está bien para "spring way" solo agregarlo, en lugar de inyectar?
Oleg Kuts
Pero recuerde su no va a trabajar con TestNG porque SecurityContextHolder de mantenimiento variable de secuencia de procesamiento local por lo que entonces se comparte esta variable entre las pruebas ...
Łukasz Woźniczka
Hazlo en @BeforeEach(JUnit5) o @Before(JUnit 4). Bueno y sencillo.
WesternGun
30

Sin responder la pregunta sobre cómo crear e inyectar objetos de autenticación, Spring Security 4.0 ofrece algunas alternativas bienvenidas cuando se trata de pruebas. La @WithMockUseranotación permite al desarrollador especificar un usuario simulado (con autoridades opcionales, nombre de usuario, contraseña y roles) de una manera clara:

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

También existe la opción de usar @WithUserDetailspara emular una UserDetailsdevolución de UserDetailsService, por ejemplo,

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

Se pueden encontrar más detalles en los capítulos @WithMockUser y @WithUserDetails en los documentos de referencia de Spring Security (de los cuales se copiaron los ejemplos anteriores)

matsev
fuente
29

Tiene mucha razón en preocuparse: las llamadas a métodos estáticos son particularmente problemáticas para las pruebas unitarias, ya que no puede burlarse fácilmente de sus dependencias. Lo que le voy a mostrar es cómo dejar que el contenedor Spring IoC haga el trabajo sucio por usted, dejándolo con un código limpio y comprobable. SecurityContextHolder es una clase de marco y, aunque puede estar bien que su código de seguridad de bajo nivel esté vinculado a él, es probable que desee exponer una interfaz más ordenada a sus componentes de la interfaz de usuario (es decir, controladores).

cliff.meyers mencionó una forma de evitarlo: cree su propio tipo "principal" e inyecte una instancia en los consumidores. La etiqueta Spring < aop: scoped-proxy /> introducida en 2.x combinada con una definición de bean de alcance de solicitud, y el soporte del método de fábrica puede ser el ticket para el código más legible.

Podría funcionar de la siguiente manera:

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
}

Nada complicado hasta ahora, ¿verdad? De hecho, probablemente ya tenías que hacer la mayor parte de esto. A continuación, en su contexto de bean, defina un bean con ámbito de solicitud para contener el principal:

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

Gracias a la magia de la etiqueta aop: scoped-proxy, se llamará al método estático getUserDetails cada vez que ingrese una nueva solicitud HTTP y cualquier referencia a la propiedad currentUser se resolverá correctamente. Ahora las pruebas unitarias se vuelven triviales:

protected void setUp() {
    // existing init code

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

¡Espero que esto ayude!

Pavel
fuente
9

Personalmente, solo usaría Powermock junto con Mockito o Easymock para burlar el SecurityContextHolder.getSecurityContext () estático en su prueba de unidad / integración, por ejemplo

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

Es cierto que aquí hay bastante código de placa de caldera, es decir, simula un objeto de autenticación, simula un SecurityContext para devolver la autenticación y finalmente simula el SecurityContextHolder para obtener el SecurityContext, sin embargo es muy flexible y le permite realizar pruebas unitarias para escenarios como objetos de autenticación nulos etc. sin tener que cambiar su código (no prueba)


fuente
7

Usar una estática en este caso es la mejor manera de escribir código seguro.

Sí, las estadísticas son generalmente malas, generalmente, pero en este caso, la estática es lo que quieres. Dado que el contexto de seguridad asocia un Principal con el subproceso que se está ejecutando actualmente, el código más seguro accedería a la estática del subproceso lo más directamente posible. Ocultar el acceso detrás de una clase de contenedor que se inyecta proporciona al atacante más puntos para atacar. No necesitarían acceso al código (que les resultaría difícil cambiar si se firmara el jar), solo necesitan una forma de anular la configuración, que se puede hacer en tiempo de ejecución o deslizar algún XML en el classpath. Incluso el uso de la inyección de anotaciones sería reemplazable con XML externo. Tal XML podría inyectar al sistema en ejecución con un principal falso.

Michael Bushe
fuente
4

Yo mismo hice la misma pregunta aquí , y acabo de publicar una respuesta que encontré recientemente. La respuesta corta es: inyecte a SecurityContext, y consulte SecurityContextHoldersolo en su configuración de Spring para obtener elSecurityContext

Scott Bale
fuente
3

General

Mientras tanto (desde la versión 3.2, en el año 2013, gracias a SEC-2298 ) la autenticación se puede inyectar en métodos MVC utilizando la anotación @AuthenticationPrincipal :

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

Pruebas

En su prueba unitaria, obviamente, puede llamar a este Método directamente. En las pruebas de integración que use org.springframework.test.web.servlet.MockMvc, puede usar org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()para inyectar al usuario de esta manera:

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

Sin embargo, esto solo llenará directamente el SecurityContext. Si desea asegurarse de que el usuario se carga desde una sesión en su prueba, puede usar esto:

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;
        }
    };
}
yanqui
fuente
2

Echaría un vistazo a las clases de prueba abstractas de Spring y los objetos simulados de los que se habla aquí . Proporcionan una forma poderosa de cablear automáticamente sus objetos administrados por Spring, lo que facilita las pruebas de unidad e integración.

digitalsanctum
fuente
Si bien esas clases de prueba son útiles, no estoy seguro de si se aplican aquí. Mis pruebas no tienen un concepto de ApplicationContext; no necesitan uno. Todo lo que necesito es asegurarme de que el SecurityContext esté poblado antes de que se ejecute el método de prueba: simplemente se siente sucio tener que configurarlo primero en un ThreadLocal
mate b
1

La autenticación es una propiedad de un subproceso en el entorno del servidor de la misma manera que es una propiedad de un proceso en el sistema operativo. Tener una instancia de bean para acceder a la información de autenticación sería inconveniente para la configuración y la sobrecarga del cableado sin ningún beneficio.

Con respecto a la autenticación de prueba, hay varias formas de facilitarle la vida. Mi favorito es hacer una anotación personalizada @Authenticatedy un oyente de ejecución de prueba, que lo gestiona. Busca DirtiesContextTestExecutionListenerinspiración.

Pavel Horal
fuente
0

Después de mucho trabajo pude reproducir el comportamiento deseado. Había emulado el inicio de sesión a través de MockMvc. Es demasiado pesado para la mayoría de las pruebas unitarias, pero útil para las pruebas de integración.

Por supuesto, estoy dispuesto a ver esas nuevas características en Spring Security 4.0 que facilitarán nuestras pruebas.

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
fuente