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.
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 } }
- Wyłączamy obsługę csrf tokena – nie będzie nam tutaj potrzebna.
- Pozwalamy wchodzić na root context (
/
) aplikacji bez logowania. - 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 }
- 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
.
Po zalogowaniu się możemy już wejść na endpoint secured (Postman zajmie się obsługą cookie za ciebie):
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; }
- Dodajemy successHandler, który zaimplemntujemy tak, by nie przekierowywał.
- Następnie dodajemy standardowy failureHandler.
- 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)); }
- Konfigurujemy filtr
- 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/
Super art., ostatnio z tym walczyłem i ten tekst trochę wyjaśnił 🙂
Dzięki za komentarz. Cieszę się, że mogłem pomóc 😉
a .formLogin() nie jest czasem stateful?
Tak, jest. Cały ten przykład jest oparty o tradycyjną sesję http (klient przesyła cookie).
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ź!
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.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.
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.
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
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.
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.
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.
Dzięki za miłe słowa Damian
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.
Cześć Piotr dzięki za komentarz, ale nie do końca rozumiem twoje uwagi. Ja po prostu dostosowuje Spring Security do swoich potrzeb.
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ć.
Rozumiem, dzięki za odpowiedz 🙂 W takim razie nie zrozumiałem zamiaru 🙂