Spring Test & Security: ¿Cómo simular la autenticación?

124

Estaba tratando de averiguar cómo realizar una prueba unitaria si las URL de mis controladores están protegidas correctamente. En caso de que alguien cambie las cosas y elimine accidentalmente la configuración de seguridad.

Mi método de controlador se ve así:

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

Configuré un WebTestEnvironment así:

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

En mi prueba real intenté hacer algo como esto:

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

}

Recogí esto aquí:

Sin embargo, si uno mira de cerca, esto solo ayuda cuando no se envían solicitudes reales a las URL, sino solo cuando se prueban los servicios a nivel de función. En mi caso, se lanzó una excepción de "acceso denegado":

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

Los siguientes dos mensajes de registro son dignos de mención, básicamente, que dicen que ningún usuario se autenticó, lo que indica que la configuración Principalno funcionó o que se sobrescribió.

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
fuente
El nombre de su empresa, eu.ubicon, se muestra en su importación. ¿No es eso un riesgo de seguridad?
Kyle Bridenstine
2
¡Hola, gracias por el comentario! Aunque no veo por qué. De todos modos, es un software de código abierto. Si está interesado, consulte bitbucket.org/ubicon/ubicon (o bitbucket.org/dmir_wue/everyaware para obtener la última bifurcación). Avísame si me pierdo algo.
Martin Becker
Verifique esta solución (la respuesta es para la primavera 4): stackoverflow.com/questions/14308341/…
Nagy Attila

Respuestas:

101

Buscando una respuesta, no pude encontrar ninguna que fuera fácil y flexible al mismo tiempo, luego encontré Spring Security Reference y me di cuenta de que hay soluciones casi perfectas. Las soluciones de AOP a menudo son las mejores para probar, y Spring las proporciona @WithMockUser, @WithUserDetailsy @WithSecurityContexten este artefacto:

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

En la mayoría de los casos, @WithUserDetailsreúne la flexibilidad y el poder que necesito.

¿Cómo funciona @WithUserDetails?

Básicamente, solo necesita crear un UserDetailsServiceperfil personalizado con todos los posibles perfiles de usuarios que desea probar. P.ej

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

Ahora tenemos a nuestros usuarios listos, así que imagine que queremos probar el control de acceso a esta función del controlador:

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

Aquí tenemos una función de asignación a la ruta / foo / salute y estamos probando una seguridad basada en roles con la @Securedanotación, aunque también puede probar @PreAuthorizey @PostAuthorize. Creemos dos pruebas, una para verificar si un usuario válido puede ver esta respuesta de saludo y la otra para verificar si en realidad está prohibida.

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

Como ve, lo importamos SpringSecurityWebAuxTestConfigpara proporcionar a nuestros usuarios para que lo prueben. Cada uno usado en su caso de prueba correspondiente simplemente usando una anotación sencilla, reduciendo el código y la complejidad.

Utilice mejor @WithMockUser para una seguridad basada en roles más sencilla

Como ve, @WithUserDetailstiene toda la flexibilidad que necesita para la mayoría de sus aplicaciones. Le permite utilizar usuarios personalizados con cualquier autoridad otorgada, como roles o permisos. Pero si solo está trabajando con roles, las pruebas pueden ser aún más fáciles y podría evitar la construcción de una personalizada UserDetailsService. En tales casos, especifique una combinación simple de usuario, contraseña y roles con @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";
}

La anotación define valores predeterminados para un usuario muy básico. Como en nuestro caso, la ruta que estamos probando solo requiere que el usuario autenticado sea un administrador, podemos dejar de usar SpringSecurityWebAuxTestConfigy hacer esto.

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

Tenga en cuenta que ahora, en lugar del usuario [email protected], obtenemos el valor predeterminado proporcionado por@WithMockUser : usuario ; sin embargo, no importará, porque lo que realmente importa es su papel: ROLE_MANAGER.

Conclusiones

Como puede ver con anotaciones como @WithUserDetailsy @WithMockUserpodemos cambiar entre diferentes escenarios de usuarios autenticados sin construir clases alienadas de nuestra arquitectura solo para hacer pruebas simples. También se recomienda que vea cómo funciona @WithSecurityContext para obtener aún más flexibilidad.

EliuX
fuente
¿Cómo burlarse de varios usuarios ? Por ejemplo, la primera solicitud es enviada por tom, mientras que la segunda es por jerry?
ch271828n
Puede crear una función en la que esté su prueba con tom y crear otra prueba con la misma lógica y probarla con Jerry. Habrá un resultado particular para cada prueba, por lo que habrá diferentes afirmaciones y si una prueba falla, le dirá por su nombre qué usuario / rol no funcionó. Recuerde que en una solicitud el usuario puede ser solo uno, por lo que especificar varios usuarios en una solicitud no tiene sentido.
EliuX
Lo siento, me refiero a ese escenario de ejemplo: probamos eso, Tom crea un artículo secreto, luego Jerry intenta leerlo y Jerry no debería verlo (ya que es secreto). Entonces, en este caso, es una prueba unitaria ...
ch271828n
Se parece mucho al escenario BasicUsery Manager Userdado en la respuesta. El concepto clave es que en lugar de preocuparnos por los usuarios, en realidad nos preocupamos por sus roles, pero cada una de esas pruebas, ubicadas en la misma prueba unitaria, en realidad representan consultas diferentes. realizado por diferentes usuarios (con diferentes roles) al mismo punto final.
EliuX
61

Desde Spring 4.0+, la mejor solución es anotar el método de prueba con @WithMockUser

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

Recuerde agregar la siguiente dependencia a su proyecto

'org.springframework.security:spring-security-test:4.2.3.RELEASE'
GummyBear21
fuente
1
La primavera es asombrosa. Gracias
TuGordoBello
Buena respuesta. Además, no necesita usar mockMvc, pero en caso de que esté usando, por ejemplo, PagingAndSortingRepository desde springframework.data, puede simplemente llamar a los métodos desde el repositorio directamente (que están anotados con EL @PreAuthorize (......))
Supertramp
50

Resultó que SecurityContextPersistenceFilter, que es parte de la cadena de filtros de Spring Security, siempre restablece mi SecurityContext, que configuré llamando SecurityContextHolder.getContext().setAuthentication(principal)(o usando el .principal(principal)método). Este filtro establece el SecurityContexten el SecurityContextHoldercon a SecurityContextde un SecurityContextRepository SOBRESCRIBIR el que configuré anteriormente. El repositorio es de forma HttpSessionSecurityContextRepositorypredeterminada. El HttpSessionSecurityContextRepositoryinspecciona lo dado HttpRequeste intenta acceder al correspondiente HttpSession. Si existe, intentará leer el SecurityContextarchivo HttpSession. Si esto falla, el repositorio genera un archivo SecurityContext.

Por lo tanto, mi solución es pasar HttpSessionjunto con la solicitud, que contiene 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
fuente
2
Aún no hemos agregado soporte oficial para Spring Security. Ver jira.springsource.org/browse/SEC-2015 Un esquema de cómo se verá se especifica en github.com/SpringSource/spring-test-mvc/blob/master/src/test/…
Rob Winch
No creo que crear un objeto de autenticación y agregar una sesión con el atributo correspondiente sea tan malo. ¿Crees que esta es una "solución alternativa" válida? Por otro lado, el apoyo directo sería genial, por supuesto. Se ve bastante bien. Gracias por el enlace!
Martin Becker
gran solucion. ¡trabajó para mi! solo un problema menor con el nombre del método protegido getPrincipal()que, en mi opinión, es un poco engañoso. idealmente debería haber sido nombrado getAuthentication(). del mismo modo, en su signedIn()prueba, la variable local debe ser nombrada autho en authenticationlugar deprincipal
Tanvir
¿Qué es "getPrincipal (" test1 ") ¿?? ¿Podrías explicar dónde es eso? Gracias de antemano
user2992476
@ user2992476 Probablemente devuelve un objeto de tipo UsernamePasswordAuthenticationToken. Alternativamente, crea GrantedAuthority y construye este objeto.
bluelurker
31

Agregue pom.xml:

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

y uso org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessorspara solicitud de autorización. Vea el uso de muestra en https://github.com/rwinch/spring-security-test-blog ( https://jira.spring.io/browse/SEC-2592 ).

Actualizar:

4.0.0.RC2 funciona para Spring-Security 3.x. Para spring-security 4 spring-security-test conviértase en parte de spring-security ( http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test , la versión es la misma ).

La configuración ha cambiado: 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();
}

Ejemplo de autenticación básica: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#testing-http-basic-authentication .

Grigory Kislin
fuente
Esto también solucionó mi problema al obtener un 404 al intentar iniciar sesión a través de un filtro de seguridad de inicio de sesión. ¡Gracias!
Ian Newland
Hola, mientras probaba como lo menciona GKislin. Recibo el siguiente error "Autenticación fallida UserDetailsService devolvió nulo, que es una infracción del contrato de interfaz". Cualquier sugerencia por favor. AuthenticationRequest final auth = new AuthenticationRequest (); auth.setUsername (userId); auth.setPassword (contraseña); mockMvc.perform (publicación ("/ api / auth /"). contenido (json (auth)). contentType (MediaType.APPLICATION_JSON));
Sanjeev
7

Aquí hay un ejemplo para aquellos que desean probar Spring MockMvc Security Config utilizando la autenticación básica 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());

Dependencia de Maven

    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.3</version>
    </dependency>
Arrendajo
fuente
3

Respuesta corta:

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

Después de realizar la formLoginprueba de seguridad de primavera, cada una de sus solicitudes se llamará automáticamente como usuario conectado.

Respuesta larga:

Verifique esta solución (la respuesta es para Spring 4): Cómo iniciar sesión un usuario con Spring 3.2 New MVC testing

Nagy Atila
fuente
2

Opciones para evitar el uso de SecurityContextHolder en pruebas:

  • Opción 1 : usar simulacros - me refiero a simular SecurityContextHolderusando alguna biblioteca simulada - EasyMock por ejemplo
  • Opción 2 : envuelva la llamada SecurityContextHolder.get...en su código en algún servicio; por ejemplo, SecurityServiceImplcon el método getCurrentPrincipalque implementa la SecurityServiceinterfaz y luego, en sus pruebas, puede simplemente crear una implementación simulada de esta interfaz que devuelve el principal deseado sin acceso a SecurityContextHolder.
Pavla Nováková
fuente
Mh, quizás no entiendo la imagen completa. Mi problema fue que SecurityContextPersistenceFilter reemplaza SecurityContext usando un SecurityContext de un HttpSessionSecurityContextRepository, que a su vez lee SecurityContext de la correspondiente HttpSession. De ahí la solución utilizando la sesión. Con respecto a la llamada a SecurityContextHolder: edité mi respuesta para que ya no esté usando una llamada a SecurityContextHolder. Pero también sin introducir bibliotecas de envoltura o burlas adicionales. ¿Crees que esta es una mejor solución?
Martin Becker
Lo siento, no entendí exactamente lo que estaba buscando y no puedo brindar una mejor respuesta que la solución que se le ocurrió y, parece ser una buena opción.
Pavla Nováková
Muy bien, gracias. Aceptaré mi propuesta como solución por ahora.
Martin Becker