J'essayais de comprendre comment tester un peu si les URL de mes contrôleurs étaient correctement sécurisées. Juste au cas où quelqu'un changerait les choses et supprimerait accidentellement les paramètres de sécurité.
Ma méthode de contrôleur ressemble à ceci:
@RequestMapping("/api/v1/resource/test")
@Secured("ROLE_USER")
public @ResonseBody String test() {
return "test";
}
J'ai mis en place un WebTestEnvironment comme ceci:
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();
}
}
Dans mon test actuel, j'ai essayé de faire quelque chose comme ceci:
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());
}
}
J'ai ramassé ça ici:
Pourtant, si vous y regardez de près, cela n’aidera que si vous n’envoyez pas de demandes réelles à des URL, mais seulement lorsque vous testez des services au niveau fonction. Dans mon cas, une exception "accès refusé" a été levée:
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]
...
Il convient de noter que les deux messages suivants du journal indiquent qu'aucun utilisateur n'a été authentifié, indiquant que la définition de la variable Principal
ne fonctionnait pas ou qu'elle était écrasée.
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
Il s’est avéré que la SecurityContextPersistenceFilter
, qui fait partie de la chaîne de filtres Spring Security, réinitialise toujours ma SecurityContext
, que je mets en appelant SecurityContextHolder.getContext().setAuthentication(principal)
(ou en utilisant la méthode .principal(principal)
). Ce filtre définit le SecurityContext
dans le SecurityContextHolder
avec un SecurityContext
à partir d'un SecurityContextRepository
ÉCRASANT celui défini précédemment. Le référentiel est un HttpSessionSecurityContextRepository
par défaut. La HttpSessionSecurityContextRepository
inspecte la HttpRequest
donnée et tente d'accéder à la HttpSession
correspondante. S'il existe, il essaiera de lire le SecurityContext
du HttpSession
. Si cela échoue, le référentiel génère un SecurityContext
vide.
Ainsi, ma solution est de passer un HttpSession
avec la requête, qui contient le 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());
}
}
En cherchant une réponse, je ne trouvais aucune solution facile et flexible à la fois, puis j’ai trouvé le Spring Security Reference et j’ai réalisé qu’il existe des solutions presque parfaites. Les solutions AOP sont souvent les meilleures pour les tests, et Spring les fournit avec @WithMockUser
, @WithUserDetails
et @WithSecurityContext
, dans cet artefact:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<version>4.2.2.RELEASE</version>
<scope>test</scope>
</dependency>
Dans la plupart des cas, @WithUserDetails
réunit la souplesse et le pouvoir dont j'ai besoin.
Fondamentalement, il vous suffit de créer une UserDetailsService
personnalisée avec tous les profils d'utilisateurs que vous souhaitez tester. Par exemple
@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
));
}
}
Maintenant que nos utilisateurs sont prêts, imaginons que nous voulons tester le contrôle d’accès à cette fonction du contrôleur:
@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());
}
}
Ici nous avons une fonction get mappée à la route /foo/salute et nous testons un rôle sécurité basée sur l’annotation @Secured
, bien que vous puissiez également tester @PreAuthorize
et @PostAuthorize
. Créons deux tests, l'un pour vérifier si un utilisateur valide peut voir cette réponse de salut, l'autre pour vérifier si elle est réellement interdite.
@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());
}
}
Comme vous le voyez, nous avons importé SpringSecurityWebAuxTestConfig
pour permettre à nos utilisateurs d'effectuer des tests. Chacun utilisait son cas de test correspondant en utilisant simplement une annotation simple, ce qui réduisait le code et la complexité.
Comme vous le voyez, @WithUserDetails
dispose de toute la flexibilité dont vous avez besoin pour la plupart de vos applications. Il vous permet d'utiliser des utilisateurs personnalisés avec n'importe quel GrantedAuthority, tels que des rôles ou des autorisations. Mais si vous travaillez uniquement avec des rôles, les tests peuvent être encore plus faciles et vous pouvez éviter de créer un UserDetailsService
personnalisé. Dans ce cas, spécifiez une combinaison simple d'utilisateur, de mot de passe et de rôles avec @ 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";
}
L'annotation définit les valeurs par défaut pour un utilisateur très basique. Comme dans notre cas, la route que nous testons exige simplement que l'utilisateur authentifié soit un gestionnaire, nous pouvons quitter avec SpringSecurityWebAuxTestConfig
et le faire.
@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")));
}
Notez que maintenant, au lieu de l'utilisateur [email protected] , nous obtenons la valeur par défaut fournie par @WithMockUser
: utilisateur ; mais cela n’a aucune importance, car ce qui nous importe vraiment, c’est son rôle: ROLE_MANAGER
.
Comme vous le voyez avec des annotations telles que @WithUserDetails
et @WithMockUser
, nous pouvons alterner entre différents scénarios d’utilisateurs authentifiés sans créer de classes éloignées de notre architecture uniquement pour effectuer des tests simples. Il vous a également recommandé de voir comment @ WithSecurityContext fonctionne pour encore plus de flexibilité.
Depuis Spring 4.0+, la meilleure solution consiste à annoter la méthode de test avec @WithMockUser.
@Test
@WithMockUser(username = "user1", password = "pwd", roles = "USER")
public void mytest1() throws Exception {
mockMvc.perform(get("/someApi"))
.andExpect(status().isOk());
}
N'oubliez pas d'ajouter la dépendance suivante à votre projet
'org.springframework.security:spring-security-test:4.2.3.RELEASE'
Ajouter dans pom.xml:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<version>4.0.0.RC2</version>
</dependency>
et utilisez org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
pour la demande d'autorisation. Voir l'exemple d'utilisation à l'adresse https://github.com/rwinch/spring-security-test-blog ( https://jira.spring.io/browse/SEC-2592 ).
Mise à jour:
4.0.0.RC2 fonctionne pour Spring-security 3.x. Pour Spring-security, 4 spring-test fait partie de Spring-security ( http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test , la version est la même).
La configuration est modifiée: 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();
}
Exemple pour l'authentification de base: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#testing-http-basic-authentication .
Voici un exemple pour ceux qui souhaitent tester Spring MockMvc Security Config à l'aide de l'authentification de base 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());
Dépendance Maven
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.3</version>
</dependency>
Réponse courte:
@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;
}
Après avoir effectué formLogin
à partir du test de sécurité du printemps, chacune de vos demandes sera automatiquement appelée en tant qu’utilisateur connecté.
Réponse longue:
Vérifiez cette solution (la réponse concerne le printemps 4): Comment connecter un utilisateur avec un nouveau test mvc du printemps 3.2
Options pour éviter d'utiliser SecurityContextHolder dans les tests:
SecurityContextHolder
en utilisant une bibliothèque simulacre - EasyMock par exempleSecurityContextHolder.get...
dans votre code dans un service - par exemple, dans SecurityServiceImpl
avec la méthode getCurrentPrincipal
qui implémente l'interface SecurityService
, puis dans vos tests, vous pouvez simplement créer une implémentation fictive de cette interface qui renvoie le principal souhaité sans accéder à SecurityContextHolder
.