Je Spring Security opravdu bezpečný? Otestujte si sami!

V dnešním článku si detailně přiblížíme postup vytvoření automatizovaných testů, které ověří s pomocí JUnit a dalších knihoven správnost Spring Security konfigurace pro několik REST volání. Pro vytvoření testů bude nezbytné implementovat několik pomocných tříd, které pomohou nasimulovat HTTP requesty a ověří výsledky. Na konci najdete možnost stáhnout si zdarma ukázkový projekt, na němž je celý postup vysvětlen.

 

Co je Spring Security?

 

Spring Security je jeden ze Spring projektů, jehož pomocí lze řešit autentizaci, autorizaci, cross site request forgery a další aspekty spojené se zabezpečením aplikace. Jedním ze základních postupů je konfigurace přístupu jednotlivých uživatelských rolí na konkrétní prostředky (resource). V tomto případě bude pomocí Spring Security zabezpečena REST vrstva aplikace.

Ukázkový projekt je postaven na Java 8, Mavenu, Spring Boot a Spring Security. Je definována 1 uživatelská role ADMIN. Dále existují obsluhy 3 jednoduchých REST volání:

  • GET “/car_brands”, které vrací seznam značek aut. Má být dostupné pouze pro přihlášené uživatele (nezáleží na roli).
  • GET “/cars”, které vrací seznam aut. Má být dostupné komukoli (tedy i anonymním uživatelům bez přihlášení).
  • POST “/manage”, jemuž posíláme konfigurační parametry. Má být dostupné pouze pro přihlášeného uživatele s rolí ADMIN.

 

Pohledem na obsluhy volání v projektu zjistíme, že nedělají nic smysluplného. Pro účely tohoto článku není podstatné, co volání přesně dělají, ale to, komu a za jakých podmínek je povoleno je využívat.

Pojďme se nejdříve podívat na třídu User:

/* importy a package vynechány */
public class User implements UserDetails {

  private String email;
  private String password;
  private List<Role> roles;

  @Override
  public String getPassword() {
    return password;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    Collection<GrantedAuthority> authorities;

    if (this.roles != null) {
        authorities = new HashSet<>(this.roles.size() + 1);
        authorities.addAll(this.roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role))
        .collect(Collectors.toList()));
    } else {
        authorities = new HashSet<>();
    }

    return authorities;
  }

  @Override
  public String getUsername() {
    return email;
  }

    /* ostatní gettery, settery, equals, hashCode a toString vynechány */
  
    public static class UserBuilder {
  	/* tělo vynecháno */
  }
}

Takto vypadá security konfigurace:

/* importy a package vynechány */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

 private static final String ADMIN_ROLE = "ADMIN";

 @Autowired
 private UserDetailsService userService;

 @Override
 protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable().httpBasic().and().authorizeRequests()
          .antMatchers(MappingURLConstants.CARS).permitAll()
          .antMatchers(MappingURLConstants.CAR_BRANDS).authenticated()
          .antMatchers(MappingURLConstants.MANAGE).hasRole(ADMIN_ROLE);
 }

 @Autowired
 @Override
 protected void configure(AuthenticationManagerBuilder authManagerBuilder) throws Exception {
    authManagerBuilder.userDetailsService(userService);
 }
}

Pokud se podíváme na metodu configure(HttpSecurity http), nezdá se nijak komplikovaná. Pokud ale projekt roste a volání začne přibývat, pravděpodobně se stane nepřehlednou a špatně udržovatelnou. Vždy, když přidáme nové volání a potřebujeme nakonfigurovat, kdo na něj smí a kdo ne, vystavujeme se riziku, že nechtěně změníme zabezpečení stávajících volání. Tohle si přímo říká o automatizované testy!

 

Jak na ně?

Použijeme k tomu spring-test a spring-security-test knihovny. (Závislosti jsou definované v pom.xml).

Budeme vytvářet MockMvc requesty s nastaveným uživatelem a pošleme je proti controlleru. Podle status kódu pak rozpoznáme, zda máme zabezpečení nastavené správně.

Prvním krokem je vytvoření abstraktní třídy, z níž budou dědit všechny ostatní testovací třídy. Abstraktní je z důvodu, že nebude obsahovat žádné testy a potomci budou jejím prostřednictvím sdílet jednotnou konfiguraci. V opačném případě by běh testů skončil chybou, že ve třídě není co testovat.

 

/* importy a package vynechány */
public class TutorialControllerSecurityTest extends SpringSecurityTestingApplicationTests {

  @Autowired
  private TutorialController tutorialController;

  @Autowired
  private Filter springSecurityFilterChain;

  private MockMvc mockMvc;

  @Before
  public void setUp() {
      mockMvc = MockMvcBuilders.standaloneSetup(tutorialController)
      .addFilters(springSecurityFilterChain)
      .build();

  }
}

V ukázce vidíme nainjectovaný testovaný controller a dále filterChain. Na ten nesmíme zapomenout, jinak nebude security vrstva vůbec brána v potaz. V setUp() metodě vytváříme instanci třídy MockMvc, jejímž prostřednictvím budeme posílat requesty.

Ve WebSecurityConfigu jsme mj. deklarovali, že volat GET “/car_brands” může pouze přihlášený uživatel. První test bude vypadat tak, že zkusíme poslat mock request typu GET na URL “/car_brands” bez jakéhokoli nastaveného uživatele. Následně ověříme, že byl vrácen HTTP status code “Unauthorized” (401).

 

@Test
public void getCarBrandsUnauthorizedAnonymous() throws Exception {
  MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(MappingURLConstants.CAR_BRANDS);
  mockMvc.perform(requestBuilder)
          .andExpect(MockMvcResultMatchers.status().isUnauthorized());
}

Druhý test bude posílat request s uživatelem bez role. Simulujme tak situaci, kdy je někdo přihlášený.

 

@Test
public void getCarBrandsPermitUserWithoutRole() throws Exception {
  MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(MappingURLConstants.CAR_BRANDS);
  mockMvc.perform(requestBuilder.with(SecurityMockMvcRequestPostProcessors.user(User.UserBuilder.anUser().build())))
          .andExpect(MockMvcResultMatchers.status().isOk());
}

Když si tyto 2 testy spustíme, projdou. S druhým testem je ale něco špatně. Může se stát, že bude po změně v budoucnu vracen jiný status kód (např. “Created” (201)) a test přestane procházet, ačkoli máme security konfiguraci správně.

Vytvoříme si proto utitlity třídu SecurityVerifier, která bude do requestů přidávat vše potřebné a ověří status kód trochu odlišným způsobem. Prozatím bude obsahovat jen 1 metodu s názvem testPermitUserWithoutRole():

 

/* importy a package vynechány */
public class SecurityVerifier {

  public static void testPermitUserWithoutRole(MockMvc mockMvc, MockHttpServletRequestBuilder requestBuilder) throws Exception {
      mockMvc.perform(requestBuilder.with(SecurityMockMvcRequestPostProcessors.user(new User())))
         .andExpect(CustomMatchers.status().isNotNotFound())
         .andExpect(CustomMatchers.status().isNotForbidden());
  }
}

Původně použitá třída MockMvcResultMatchers neumožňuje znegovat ověřování status kódu. Proto musí vzniknout třída CustomMatchers, která toto dokáže. Třída CustomMatchers pracuje s další novou třídou EnhancedStatusResultMatchers.

 

/* importy a package vynechány */
public class CustomMatchers {

  public static EnhancedStatusResultMatchers status() {
      return new EnhancedStatusResultMatchers();
  }
}

/* importy a package vynechány */
public class EnhancedStatusResultMatchers extends StatusResultMatchers {

  private static final String STATUS = "Status";

  public ResultMatcher isNotForbidden() {
      return this.inverseMatcher(HttpStatus.FORBIDDEN);
  }

public ResultMatcher isNotNotFound() {
  return this.inverseMatcher(HttpStatus.NOT_FOUND);
}

  private ResultMatcher inverseMatcher(HttpStatus status) {
      return (result) -> assertThatGivenStatusIsNotInResult(result, status);
  }

  private void assertThatGivenStatusIsNotInResult(MvcResult result, HttpStatus status) {
      EnhancedAssertion
         .assertNotEquals(
              STATUS,
              Integer.valueOf(status.value()),
              Integer.valueOf(result.getResponse().getStatus())
      );
  }
}

Ještě si prohlédněme třídu EnhancedAssertion.

 

/* importy a package vynechány */
public class EnhancedAssertion {

  public static void assertNotEquals(String message, Object expected, Object actual) {
      if(ObjectUtils.nullSafeEquals(expected, actual)) {
          inverseFail(message, expected, actual);
      }
  }

  public static void assertFalse(String message, boolean condition) {
      if(condition) {
          AssertionErrors.fail(message);
      }
  }

  private static void inverseFail(String message, Object expected, Object actual) {
      throw new AssertionError(message + " expected:<OTHER THAN " + expected + "> but was:<" + actual + ">");
  }
}

Proč ověřujeme i to, jestli se nevrátil status “Not found” (404)? Pokud to neověříme, mohli bychom posílat requesty neexistujícímu controlleru. Věděli bychom, že status není “Forbidden” (403) a mysleli si, že je vše v pořádku. Přitom by mohlo jít třeba jen o překlep ve volaném URL.

 

Do třídy SecurityVerifier teď můžeme přidat další metody, které budeme moci využívat ve všech security testech. Máme-li v aplikaci pouze 1 roli ADMIN, doporučuji mít v SecurityVerifieru následující metody, jejichž obsah si můžete prohlédnout přímo v projektu:

  • testDenyAdmin(),
  • testDenyUserWithoutRole(),
  • testUnauthorizedAnonymous(),
  • testPermitAdmin(),
  • testPermitUserWithoutRole(),
  • testPermitAnonymous().

 

Nyní si můžeme konečně prohlédnout celou třídu, která testuje náš controller.

 

/* importy a package vynechány */
public class TutorialControllerSecurityTest extends SpringSecurityTestingApplicationTests {

  @Autowired
  private TutorialController tutorialController;

  @Autowired
  private Filter springSecurityFilterChain;

  private MockMvc mockMvc;

  @Before
  public void setUp() {
      mockMvc = MockMvcBuilders.standaloneSetup(tutorialController)
              .addFilters(springSecurityFilterChain)
              .build();
  }

  @Test
  public void getCarBrandsPermitAdmin() throws Exception {
      MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(MappingURLConstants.CAR_BRANDS);
      SecurityVerifier.testPermitAdmin(mockMvc, requestBuilder);
  }

  @Test
  public void getCarBrandsPermitUserWithoutRole() throws Exception {
      MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(MappingURLConstants.CAR_BRANDS);
      SecurityVerifier.testPermitUserWithoutRole(mockMvc, requestBuilder);
  }

  @Test
  public void getCarBrandsUnauthorizedAnonymous() throws Exception {
      MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(MappingURLConstants.CAR_BRANDS);
      SecurityVerifier.testUnauthorizedAnonymous(mockMvc, requestBuilder);
  }

  @Test
  public void getCarsPermitAdmin() throws Exception {
      MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(MappingURLConstants.CARS);
      SecurityVerifier.testPermitAdmin(mockMvc, requestBuilder);
  }

  @Test
  public void getCarsPermitUserWithoutRole() throws Exception {
      MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(MappingURLConstants.CARS);
      SecurityVerifier.testPermitUserWithoutRole(mockMvc, requestBuilder);
  }

  @Test
  public void getCarsPermitAnonymous() throws Exception {
      MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(MappingURLConstants.CARS);
      SecurityVerifier.testPermitAnonymous(mockMvc, requestBuilder);
  }

  @Test
  public void postManageParamsPermitAdmin() throws Exception {
      MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post(MappingURLConstants.MANAGE);
      SecurityVerifier.testPermitAdmin(mockMvc, requestBuilder);
  }

  @Test
  public void postManageParamsDenyUserWithoutRole() throws Exception {
      MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post(MappingURLConstants.MANAGE);
      SecurityVerifier.testDenyUserWithoutRole(mockMvc, requestBuilder);
  }

  @Test
  public void postManageParamsUnauthorizedAnonymous() throws Exception {
      MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post(MappingURLConstants.MANAGE);
      SecurityVerifier.testUnauthorizedAnonymous(mockMvc, requestBuilder);
  }
}

Pokud např. v předposledním testu zavoláme místo původní metody na SecurityVerifieru metodu testPermitUserWithoutRole(), test neprojde se srozumitelnou chybou:

 

java.lang.AssertionError: Status

Expected :OTHER THAN 403

Actual   :403

 

Při testování můžeme narazit na 2 nástrahy:

  1. Test skončí výjimkou a proto nedojde vůbec k ověření status kódu.
  2. Při testu nechtěně změníme stav aplikace tím, že controller s requesty skutečně pracuje (a potenciálně něco zapíše do databáze apod.).

 

Ošetření výjimky

Řešením první nástrahy může být to, že vytvoříme Springový ExceptionHandler a umístíme jej do třídy oanotované jako @ControllerAdvice. Třídy oanotované jako @ControllerAdvice mohou obsahovat metody s anotacemi @ExceptionHandler, @InitBinder nebo @ModelAttribute a vykonávají pro controllery operace, které bychom museli opakovat stále dokola pro každou @RequestMapping metodu. Exception handler bude ošetřovat globální Exception.class a bude vracet status kód “Internal server error” (500).

 

@ControllerAdvice
public class TutorialExceptionHandler {

  private static Logger LOGGER = LoggerFactory.getLogger(TutorialExceptionHandler.class);

  @ExceptionHandler(Exception.class)
  @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
  @ResponseBody
  public ResponseEntity<?> handleException(final Exception exception) {
      LOGGER.error("GlobalExceptionHandler caught exception: {}", exception);
      return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
  }
}

Abychom exception handler zapojili i při testu, musíme jej nainjectovat a pak upravit setUp() metodu v testovací třídě:

 

@Autowired
private TutorialExceptionHandler tutorialExceptionHandler;

@Before
public void setUp() {
  mockMvc = MockMvcBuilders.standaloneSetup(tutorialController)
          .setControllerAdvice(tutorialExceptionHandler)
          .addFilters(springSecurityFilterChain)
          .build();
}

Při testu pak dostaneme status kód “Internal server error” (500) pouze v případě testPermit* metod. Vrácený status kód 500 není roven ani kódu “Not found” (404), ani “Forbidden” (403). Vše je tedy v pořádku a test oprávněně projde. U testDeny* metod nedojde vůbec k volání logiky v controlleru, protože je vracen status kód “Forbidden” (403).

Změna stavu aplikace

Druhá situace nastává tehdy, kdy logika controlleru volá servisní vrstvu nebo obecně dělá jakékoli změny v aplikaci. Pokud zavoláme některou z testPermit* metod, servisní vrstva je zavolána a toho je potřeba se vyvarovat. Řešením je zamockovat servisní či jinou komponentu např. pomocí knihovny Mockito a zabránit tak volání skutečných servisních komponent.

Zdrojový kód

Celý projekt si můžete naklonovat z veřejného GIT repozitáře: https://vitherain@bitbucket.org/vitherain/spring-security-test-tutorial.git

 

Zdroje

http://projects.spring.io/spring-security/

http://docs.spring.io/spring-security/site/docs/current/reference/html/test-mockmvc.html

 

Autor: Vít Herain