spring-security-json

Spring Security – uwierzytelnienie przy pomocy jsona

Spring Security to narzędzie, które pomaga uporządkować kwestie związane z uwierzytelnieniem i autoryzacją. Generalnie robi to wszystko za nas. Jednak jedynym z jego minusów jest to, że nie do końca jest przystosowane do pracy z usługami restowymi z obsługą jsona. W tym artykule zajmę się właśnie tą kwestią.

Spring security domyślnie obsługuje uwierzytelnienie przy pomocy formularza (application/x-www-form-urlencoded). Jednak gdy chcemy użyć jsona w REST API, trzeba to odpowiednio skonfigurować. Spring nie ma niestety żadnego magicznego przełącznika dla takich sytuacji.

 

Cały artykuł opiera się o Spring Boot. Na końcu podlinkuję końcowy rezultat, w postaci projektu na githubie.

 

Domyślna konfiguracja Spring Security

Zacznijmy od dodania dwóch prostych endpointów. Jeden z nich będzie ogólnie dostępny, drugi dostępny po zalogowaniu.

@RestController
public class HelloController {

  @GetMapping("/")
  public MessageDto hello() {
    return new MessageDto("Hello world");
  }

  @GetMapping("/secured")
  public MessageDto helloSecured() {
    return new MessageDto("Hello secured");
  }
}

MessageDto to prosty dtos, który ma tylko jedno pole message wypełniane przez konstruktor.

Następnie dodajmy zależność do Spring Security.

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-security' // dodajemy zależność
  implementation 'org.springframework.boot:spring-boot-starter-web'
}

W Spring Boot dodanie zależności w gradlew wystarczy, żeby uruchomiła się autokonfiguracja. Od tego momentu, po wejściu na jakikolwiek endpoint, Spring będzie nas przekierowywał na domyślny formularz logowania (/login), który dostarcza biblioteka.

sing in form

Do tego formularza można zalogować się używając użytkownika user i hasła wygenerowanego przez waszą aplikację. Będzie ono widoczne w logach w takiej formie:

Using generated security password: ed2f7269-d94d-469c-915b-b85f2efc5ef6

Po zalogowaniu, aplikacja powinna przekierować Cię do root contextu (/) i powinieneś zobaczyć komunikat:

{"message":"Hello world"}

 

Logowanie przy użyciu wygenerowanego hasła jest mało wygodne, ponieważ zmienia się ono po każdym restarcie aplikacji. Dodajmy więc użytkownika i hasło, tak by testowanie było wygodniejsze.

Dodaj klasę konfiguracyjną SecurityConfig, która dziedziczy po WebSecurityConfigurerAdapter i zawiera metodę, konfigurującą nam w pamięci bazę użytkowników (możemy w ten sposób konfigurować dowolną ilość użytkowników):

@Configuration 
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(AuthenticationManagerBuilder builder) throws Exception {
    builder.inMemoryAuthentication()
      .withUser("user")
      .password("password")
      .roles("USER");
}

Konfigurujemy użytkowników pamięci tylko na potrzeby testów. W produkcyjnej aplikacji użytkownicy najczęściej będą przechowywani w bazie danych.

I tutaj możesz natknąć się na taki błąd (to istotne, bo różne źródła podają różny sposób konfiguracji Spring Security):

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

Błąd ten oznacza, że musisz jawnie podać, w jaki sposób jest zakodowane twoje hasło (w Spring Security 5 zmienił się format przechowywania hasła i teraz przed hasłem trzeba podać algorytm kodowania hasła). Na potrzeby testów korzystamy z inMemoryAuthentication, a dla wygody nie będziemy kodować hasła, więc potrzebujemy przed hasłem dodać {noop}:

.password("{noop}password")

Możesz także skorzystać z różnego rodzaju algorytmów hashujących takich jak: {bcrypt}, {pbkdf2}, {scrypt}, {sha256}

Dostosowanie konfiguracji do własnych potrzeb

Jak już napisałem wcześniej, po wejściu na dowolny endpoint zostaniesz przekierowany na formularz logowania. Przekierowanie to nie jest nam potrzebne, bo chcemy logować się do aplikacji za pomocą jsona wysyłanego postem, a zamiast przekierowania chcemy zawsze otrzymywać błąd 401 Unauthorized. Dodajmy więc odpowiednią konfigurację, która pozwoli wyłączyć przekierowanie, a także pozwoli wejść na root context (/) bez logowania.

Warto także włączyć debugowanie w security(@EnableWebSecurity(debug = true) – tylko na czas developmentu). Dodajemy kolejną metodę configure(HttpSecurity http) wraz z konfiguracją:

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable(); // 1
    http
      .authorizeRequests()
      .antMatchers("/").permitAll() // 2
      .anyRequest().authenticated()
      .and()
      .formLogin().permitAll(); // 3
  }
}
  1. Wyłączamy obsługę csrf tokena – nie będzie nam tutaj potrzebna.
  2. Pozwalamy wchodzić na root context (/) aplikacji bez logowania.
  3. Pozwalamy wchodzić bez logowania na formularz – co jest odwzorowaniem domyślnej konfiguracji.

Po tych zmianach, jedyna różnica jest taka, że root context (/) aplikacji jest dostępny bez logowania.

Teraz musimy dodać dodatkową konfigurację, która sprawi, że zamiast przekierowania na zabezpieczonych endpointach dostaniesz błąd 401 Unauthorized.

@Override
protected void configure(HttpSecurity http) throws Exception {
  http.csrf().disable();
  http
    .authorizeRequests()
    .antMatchers("/").permitAll()
    .anyRequest().authenticated()
    .and()
    .formLogin().permitAll()
    .and()
    .exceptionHandling() // 1
    .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); // 1
}
  1. Te dwie linijki sprawiają, że po wejściu na zabezpieczony endpoint, otrzymasz błąd 401 Unauthorized, zamiast przekierowania na formularz logowania.

 

W tym miejscu warto zaopatrzyć się w jakiegoś desktopowego klienta http, ponieważ domyślny formularz przestanie już działać i nie będziesz mógł na niego wejść. Ja użyłem Postmana (jako alternatywy możesz użyć innego klienta http, np. SoupUI, Insomnia REST Client, a nawet klienta konsolowego cUrl).

Nadal możemy  zalogować się za pomocą endpointu /login.

postman

Po zalogowaniu się możemy już wejść na endpoint secured (Postman zajmie się obsługą cookie za ciebie):

postman-secured

 

Wszystko działa, ale nadal endpoint /login konsumuje dane w formacie application/x-www-form-urlencoded i nie jest to spójne z usługami restowymi, które konsumują i produkują jsona (application/json). Kolejna kwestią jest to, że ciągle po zalogowaniu mamy przekierowanie na root context (/) aplikacji i usługa logowania zwraca {"message": "Hello world"}. Wszystkich te problemy rozwiążemy w następnym kroku.

Żeby zmienić opisane powyżej zachowania, musisz nadpisać domyśli filtr UsernamePasswordAuthenticationFilter, który jest odpowiedzialny za odczytywanie danych z formularza, tak żeby odczytywał dane z jsona.

public class JsonObjectAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

  private final ObjectMapper objectMapper = new ObjectMapper();

  @Override
  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
    try {
      BufferedReader reader = request.getReader();
      StringBuilder sb = new StringBuilder();
      String line;
      while ((line = reader.readLine()) != null) {
        sb.append(line);
      }
      LoginCredentials authRequest = objectMapper.readValue(sb.toString(), LoginCredentials.class);
      UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
        authRequest.getUsername(), authRequest.getPassword()
      );
      setDetails(request, token);
      return this.getAuthenticationManager().authenticate(token);
    } catch (IOException e) {
      throw new IllegalArgumentException(e.getMessage());
    }
  }
}

Klasa przechowująca dane logowania:

public class LoginCredentials {
  private String username;
  private String password;

  public String getUsername() {
    return username;
  }

  public String getPassword() {
    return password;
  }
}

Dodatkowo, musisz także odpowiednio skonfigurować instancję filtra tak, żeby korzystała z domyślnego authentication managera oraz musisz ustawić własną implementację successHandlera i failureHandlera, co pozwoli na pozbycie się przekierowania po poprawnym zalogowaniu:

@Bean
public JsonObjectAuthenticationFilter authenticationFilter() throws Exception {
  JsonObjectAuthenticationFilter filter = new JsonObjectAuthenticationFilter();
  filter.setAuthenticationSuccessHandler(authenticationSuccessHandler); // 1
  filter.setAuthenticationFailureHandler(authenticationFailureHandler); // 2
  filter.setAuthenticationManager(super.authenticationManager()); // 3
  return filter;
}
  1. Dodajemy successHandler, który zaimplemntujemy tak, by nie przekierowywał.
  2. Następnie dodajemy standardowy failureHandler.
  3. Ustawiamy domyślny authenticationManager.

Poniżej kod successHandler:

@Component
public class RestAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, 
                                      Authentication authentication) throws IOException, ServletException {
    clearAuthenticationAttributes(request);
  }
}

i failureHandler:

@Component
public class RestAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, 
                                      AuthenticationException exception) throws IOException, ServletException {
    super.onAuthenticationFailure(request, response, exception);
  }
}

 

Ostatnią rzeczą jest dodanie filtra do głównej konfiguracji Spring Security:

@Override
protected void configure(HttpSecurity http) throws Exception {
  http.csrf().disable();
  http
    .authorizeRequests()
    .antMatchers("/").permitAll()
    .anyRequest().authenticated()
    .and()
    .addFilterBefore(authenticationFilter(), UsernamePasswordAuthenticationFilter.class) // 1
    .exceptionHandling()
    .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
}
  1. Konfigurujemy filtr
  2. Usuwamy .formLogin().permitAll()

W tym miejscu wszystko powinno już działać. Nie zapomnij w Postmanie zmienić Content-Type na application/json. I koniecznie wyłącz debugowanie Spring Security @EnableWebSecurity(debug = false).

 

Aktualizacja: A jeżeli potrzebujesz innej metody uwierzytelnienia np. JWT, to możesz też zajrzeć do kolejnego z tej serii artykułu Spring Security i Json Web Token. Artykuł ten jest kontynuacją obecnego (w dużej mierze bazuje na tym artykule) i przedstawiam w nim jak skorzystać z wspomnianej metody autoryzacji czyli Json Web Token. Ta metoda uwierzytelnienia jest ostatnio bardzo popularna, więc zachęcam także do zapoznania się z nią.

 

 

Podsumowanie

Muszę przyznać, że już kilka razy implementowałem logowanie w usługach restowych ze Spring Security. I za każdym razem wygląda to trochę inaczej. Ale ten sposób, który tu przedstawiłem jest chyba najbardziej zwarty, więc może przyda mi się kolejnym razem 😉 Mam nadzieję, że tobie też.

Link do projektu na githubie: security-example

 

Żródła:

https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/

https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/authentication/UsernamePasswordAuthenticationFilter.html

 

Mateusz Dąbrowski

Cześć jestem Mateusz, zajmuję się programowaniem już ponad 12 lat z czego ponad 8 programuję w Javie. Zapraszam Cię do lektury mojego bloga. Możesz przeczytać więcej o mnie >>TUTAJ<<

16 thoughts to “Spring Security – uwierzytelnienie przy pomocy jsona”

  1. Super art., ostatnio z tym walczyłem i ten tekst trochę wyjaśnił 🙂

    1. Dzięki za komentarz. Cieszę się, że mogłem pomóc 😉

    1. Tak, jest. Cały ten przykład jest oparty o tradycyjną sesję http (klient przesyła cookie).

      1. A, spoko. Uczę się dopiero Springa i zmyliło mnie połączenie wzmianki o „usługach restowych” i formlogin w jednym wpisie i myślałem, że coś źle rozumiem. Dzięki za szybką odpowiedź!

        1. Jasne. W artykule opisuje tylko jak to zrobić jsonem. Nie poruszam innych problemów. Chciałem to opisać najprościej jak się da, więc dlatego jest na sesji http.

          I dopiero teraz przyszło mi do głowy, że linijka .formLogin().permitAll() w przykładzie na samym końcu artykułu jest zbędna 😉 Poprawię to. Dzięki za komentarz.

  2. Dodałem z artykułu klasę SecurityConfig z metodą konfigurującą w pamięci bazę użytkowników. Coś mi to nie działa. Dalej muszę wpisywać hasło wygenerowane przez aplikacje w momencie startu. A powinno logować z hasłem password.

    1. Na githubie jest przykładowy kod (na końcu artykułu jest link). Zajrzyj do przykładu, może gdzieś jest jakiś mały błąd.

      Edit: Był mały błąd, zamiast super.authenticationManagerBean() trzeba użyć super.authenticationManager(). Poprawiłem w tekście.

  3. Hej zastanawia mnie jedna rzecz, jak powiązać sceurity z JWT – przy aplikacji z react/angularem ?
    Tzn. mam oczywiście core – w oparciu o CRUD i springboot, ale mam też warstwę UI – opartą o angular lub react js, zastanawiam się czy implementacja autoryzacji wystarcza po stronie serwera? Chyba nie bo np chcąc tworzyć componenty – w react musze jakoś wpleść logowanie etc również w frontend?
    Jakiś tutorial znacie dobry do tego ? Pozdrawiam

    1. Implementacja logowania tylko po stronie backendu nie zawsze wystarczy. Jeśli backend korzysta z sesji http, to obsługą ciasteczek zajmie się przeglądarka i w zasadzie nie wiele musisz robić poza obsługą samego formularza logowania i przekierowywanie użytkownika na niego jeśli nie jest zalogowany.

      Jeśli chcesz korzystać z tokena (co opisałem w kolejnym artykule), to musisz już zając się obsługą tego tokena w aplikacji React/Angular (w Angularze robi się to zwykle przez interceptory). Tokena możesz zapisać np. w local storage i wysyłać z każdym requestem do serwera w nagłówku Authorize.

      Nie znam żadnych ciekawych tutoriali frontendowych na ten temat.

      1. Szukając podobnych rozwiązań do frontu znalazłem dwa ciekawe opisy:
        https://spring.io/guides/tutorials/spring-security-and-angular-js/#_the_login_page_angular_js_and_spring_security_part_ii
        i
        https://blog.angular-university.io/angular-jwt-authentication/

        Niestety, chociaż każdy z nich jest dobry w tym co opisuje, to nie do końca rozwiązują ten sam problem. Aktualnie implementuję jeden projekt, gdzie chcę właśnie wykorzystać wszystkie 3 rozwiązania łącznie.

  4. Jesteś mistrz! Chodzi mi o to, że to co pokazujesz i tłumaczysz jest celnie „dobrane”, skrojone, wypunktowane. Jak celny strzał w 10. Pokazujesz aktualne, nie rzadko, nie trywialne zagadnienia, dostosowane i pokazane na przykładzie „na czasie”. W internecie gdzie często pokazuje się albo mnóstwo po trosze, albo w ogóle za mało to ten blog jest oazą nadziei.

  5. Cześć Mateusz,
    Trafiłem na ten wątek ponieważ potrzebowałem dokładnie tej funkcjonalności.
    Niestety podczas implementacji trafiłem na kilka problemów wynikających z tego. Cała konfiguracja + trochę benefitów jakie spring security daje nam out of box tracimy, filtr który zdefiniujemy w ten sposób jest goły. Czyli cała konfiguracja domyślna, jak i nie domyślna, która tworzymy na HttpSecurity leci w czarną dziurę. I tak, ochrona przed SessionFixationAttack znika, możliwość zmiany login URL na jakiś własny, funkcjonalność remember-me i wiele innych.

    1. Cześć Piotr dzięki za komentarz, ale nie do końca rozumiem twoje uwagi. Ja po prostu dostosowuje Spring Security do swoich potrzeb.

      „Czyli cała konfiguracja domyślna, jak i nie domyślna, która tworzymy na HttpSecurity leci w czarną dziurę.”

      No ale ja nie mogę polegać na domyślnej konfiguracji, bo właśnie mi ona nie odpowiada. Dlatego ją zmieniam.

      Generalnie artykuł jest częścią serii. W kolejnym opisuje jak przerobić logowanie na tokena (i tam nie ma sesji http, wiec ochrona sesji nie jest potrzebna). W tej części przygotowuje wszystko pod tym kontem, wiec być może zbyt wiele niechcący wyciąłem.

      Zawsze możesz włączyć sobie potrzebne rzeczy w konfiguracji tak, żeby wszystko dostosować do swoich potrzeb. Nie jest tak, że ja coś popsułem w ten sposób, po prostu musisz to inaczej skonfigurować.

      1. Rozumiem, dzięki za odpowiedz 🙂 W takim razie nie zrozumiałem zamiaru 🙂

Komentarze są zamknięte.