Bezpieczeństwo REST z JWT przy użyciu Java i Spring Security
Opublikowany: 2022-03-11Bezpieczeństwo
Bezpieczeństwo jest wrogiem wygody i na odwrót. To stwierdzenie jest prawdziwe dla każdego systemu, wirtualnego lub rzeczywistego, od fizycznego wejścia do domu po platformy bankowości internetowej. Inżynierowie nieustannie próbują znaleźć odpowiednią równowagę dla danego przypadku użycia, przechylając się w jedną lub drugą stronę. Zwykle, gdy pojawia się nowe zagrożenie, kierujemy się w stronę bezpieczeństwa i oddalamy się od wygody. Następnie sprawdzamy, czy możemy odzyskać utraconą wygodę bez zbytniego zmniejszania bezpieczeństwa. Co więcej, to błędne koło trwa w nieskończoność.
Spróbujmy przeanalizować obecny stan zabezpieczeń REST, korzystając z prostego samouczka dotyczącego zabezpieczeń Spring, aby zademonstrować go w działaniu.
Usługi REST (co oznacza Representational State Transfer) rozpoczęły się jako niezwykle uproszczone podejście do usług internetowych, które miały ogromne specyfikacje i niewygodne formaty, takie jak WSDL do opisywania usługi lub SOAP do określania formatu wiadomości. W REST nie mamy żadnego z nich. Możemy opisać usługę REST w zwykłym pliku tekstowym i użyć dowolnego formatu wiadomości, takiego jak JSON, XML, a nawet ponownie zwykły tekst. Uproszczone podejście zastosowano również do bezpieczeństwa usług REST; żaden zdefiniowany standard nie narzuca określonego sposobu uwierzytelniania użytkowników.
Chociaż usługi REST nie mają wiele sprecyzowanych, ważnym jest brak stanu. Oznacza to, że serwer nie utrzymuje żadnego stanu klienta, czego dobrym przykładem są sesje. W ten sposób serwer odpowiada na każde żądanie, jakby to było pierwsze, które klient zgłosił. Jednak nawet teraz wiele implementacji nadal korzysta z uwierzytelniania opartego na plikach cookie, które jest dziedziczone ze standardowego projektu architektury witryny. Bezstanowe podejście REST sprawia, że pliki cookie sesji są nieodpowiednie z punktu widzenia bezpieczeństwa, ale mimo to są nadal szeroko stosowane. Oprócz zignorowania wymaganej bezpaństwowości, oczekiwanym kompromisem w zakresie bezpieczeństwa było uproszczone podejście. W porównaniu ze standardem WS-Security stosowanym dla Web Services, znacznie łatwiej jest tworzyć i konsumować usługi REST, stąd wygoda przeszła dach. Kompromisem jest dość szczupłe bezpieczeństwo; Przechwytywanie sesji i fałszowanie żądań między witrynami (XSRF) to najczęstsze problemy z zabezpieczeniami.
Próbując pozbyć się sesji klienta z serwera, od czasu do czasu stosowano inne metody, takie jak uwierzytelnianie podstawowe lub szyfrowane HTTP. Oba używają nagłówka Authorization
do przesyłania danych uwierzytelniających użytkownika, z dodanym kodowaniem (HTTP Basic) lub szyfrowaniem (HTTP Digest). Oczywiście miały te same wady, które można znaleźć na stronach internetowych: HTTP Basic musiał być używany przez HTTPS, ponieważ nazwa użytkownika i hasło są przesyłane w łatwo odwracalnym kodowaniu base64, a HTTP Digest wymusił użycie przestarzałego hashowania MD5, który okazał się niepewny.
Niektóre implementacje używały arbitralnych tokenów do uwierzytelniania klientów. Ta opcja wydaje się na razie najlepsza, jaką mamy. Prawidłowo zaimplementowany rozwiązuje wszystkie problemy związane z bezpieczeństwem HTTP Basic, HTTP Digest lub sesyjnych plików cookie, jest prosty w użyciu i działa zgodnie ze wzorcem bezstanowym.
Jednak z takimi arbitralnymi tokenami nie jest to związane ze standardem. Każdy usługodawca miał swój pomysł, co umieścić w tokenie i jak go zakodować lub zaszyfrować. Korzystanie z usług różnych dostawców wymagało dodatkowego czasu na konfigurację, aby dostosować się do konkretnego używanego formatu tokena. Z drugiej strony inne metody (plik cookie sesji, HTTP Basic i HTTP Digest) są dobrze znane programistom i prawie wszystkie przeglądarki na wszystkich urządzeniach działają z nimi po wyjęciu z pudełka. Struktury i języki są gotowe na te metody, posiadają wbudowane funkcje, które bezproblemowo radzą sobie z każdą z nich.
Uwierzytelnianie JWT
JWT (skrót od JSON Web Token) to brakująca standaryzacja używania tokenów do uwierzytelniania w sieci w ogóle, nie tylko w przypadku usług REST. Obecnie ma status wersji roboczej jako RFC 7519. Jest solidny i może zawierać wiele informacji, ale nadal jest prosty w użyciu, mimo że jego rozmiar jest stosunkowo niewielki. Podobnie jak każdy inny token, token JWT może służyć do przekazywania tożsamości uwierzytelnionych użytkowników między dostawcą tożsamości a dostawcą usług (którzy niekoniecznie są tymi samymi systemami). Może również zawierać wszystkie roszczenia użytkownika, takie jak dane autoryzacyjne, dzięki czemu usługodawca nie musi wchodzić do bazy danych lub systemów zewnętrznych w celu weryfikacji ról użytkowników i uprawnień dla każdego żądania; że dane są wyodrębniane z tokena.
Oto jak ma działać zabezpieczenia JWT:
- Klienci logują się, wysyłając swoje dane uwierzytelniające do dostawcy tożsamości.
- Dostawca tożsamości weryfikuje poświadczenia; jeśli wszystko jest w porządku, pobiera dane użytkownika, generuje token JWT zawierający szczegóły użytkownika i uprawnienia, które będą używane do uzyskiwania dostępu do usług, a także ustawia wygaśnięcie tokenu JWT (który może być nieograniczony).
- Dostawca tożsamości podpisuje iw razie potrzeby szyfruje token JWT i wysyła go do klienta jako odpowiedź na początkowe żądanie z poświadczeniami.
- Klient przechowuje token JWT przez ograniczony lub nieograniczony czas, w zależności od wygaśnięcia ustawionego przez dostawcę tożsamości.
- Klient wysyła przechowywany token JWT w nagłówku autoryzacji dla każdego żądania do dostawcy usług.
- Dla każdego żądania dostawca usług pobiera token JWT z nagłówka
Authorization
i w razie potrzeby go odszyfrowuje, weryfikuje podpis, a jeśli wszystko jest w porządku, wyodrębnia dane użytkownika i uprawnienia. Opierając się wyłącznie na tych danych i ponownie bez wyszukiwania dalszych szczegółów w bazie danych lub kontaktowania się z dostawcą tożsamości, może zaakceptować lub odrzucić żądanie klienta. Jedynym wymaganiem jest to, aby tożsamość i dostawcy usług mieli umowę na szyfrowanie, aby usługa mogła zweryfikować podpis, a nawet odszyfrować, która tożsamość została zaszyfrowana.
Ten przepływ pozwala na dużą elastyczność, a jednocześnie zapewnia bezpieczeństwo i łatwość tworzenia. Dzięki takiemu podejściu łatwo jest dodawać nowe węzły serwerów do klastra dostawców usług, inicjując je jedynie z możliwością weryfikacji podpisu i odszyfrowania tokenów poprzez udostępnienie im wspólnego tajnego klucza. Nie jest wymagana replikacja sesji, synchronizacja bazy danych ani komunikacja między węzłami. ODPOCZYNEK w pełnej krasie.
Główną różnicą między JWT a innymi arbitralnymi tokenami jest standaryzacja zawartości tokena. Innym zalecanym podejściem jest wysłanie tokena JWT w nagłówku Authorization
przy użyciu schematu Bearer. Treść nagłówka powinna wyglądać tak:
Authorization: Bearer <token>
Implementacja zabezpieczeń REST
Aby usługi REST działały zgodnie z oczekiwaniami, potrzebujemy nieco innego podejścia do autoryzacji w porównaniu z klasycznymi, wielostronicowymi witrynami.
Zamiast wyzwalać proces uwierzytelniania przez przekierowanie do strony logowania, gdy klient żąda zabezpieczonego zasobu, serwer REST uwierzytelnia wszystkie żądania przy użyciu danych dostępnych w samym żądaniu, w tym przypadku tokenu JWT. Jeśli takie uwierzytelnienie się nie powiedzie, przekierowanie nie ma sensu. Interfejs API REST po prostu wysyła odpowiedź z kodem HTTP 401 (nieautoryzowany), a klienci powinni wiedzieć, co robić; na przykład przeglądarka pokaże dynamiczny div, aby umożliwić użytkownikowi podanie nazwy użytkownika i hasła.
Z drugiej strony, po udanym uwierzytelnieniu w klasycznych, wielostronicowych witrynach, użytkownik jest przekierowywany za pomocą kodu HTTP 301 (przeniesione na stałe), zwykle na stronę główną lub, jeszcze lepiej, na stronę, o którą użytkownik początkowo prosił, a która wywołała proces uwierzytelniania. Z REST znowu to nie ma sensu. Zamiast tego po prostu kontynuowalibyśmy wykonywanie żądania, tak jakby zasób nie był w ogóle zabezpieczony, zwrócilibyśmy kod HTTP 200 (OK) i oczekiwaną treść odpowiedzi.
Przykład zabezpieczenia wiosennego
Zobaczmy teraz, jak możemy zaimplementować REST API oparte na tokenie JWT za pomocą Javy i Spring, próbując ponownie użyć domyślnego zachowania Spring Security tam, gdzie to możliwe.
Zgodnie z oczekiwaniami, framework Spring Security zawiera wiele gotowych do wtyczek klas, które radzą sobie ze „starymi” mechanizmami autoryzacji: cookies sesji, HTTP Basic i HTTP Digest. Brakuje jednak natywnego wsparcia dla JWT i musimy pobrudzić sobie ręce, aby to zadziałało. Aby uzyskać bardziej szczegółowy przegląd, zapoznaj się z oficjalną dokumentacją Spring Security.
Teraz zacznijmy od zwykłej definicji filtra Spring Security w web.xml
:
<filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
Zwróć uwagę, że nazwa filtra Spring Security musi brzmieć dokładnie springSecurityFilterChain
, aby reszta konfiguracji Spring działała po wyjęciu z pudełka.
Następnie pojawia się deklaracja XML z ziaren Spring związanych z bezpieczeństwem. Aby uprościć XML, ustawimy domyślną przestrzeń nazw na security
, dodając xmlns="http://www.springframework.org/schema/security"
do głównego elementu XML. Reszta XML wygląda tak:
<global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
- (1) W tym wierszu aktywujemy
@PreFilter
,@PreAuthorize
,@PostFilter
,@PostAuthorize
na dowolnych fasolkach szparagowych w kontekście. - (2) Definiujemy punkty końcowe logowania i rejestracji, aby pominąć zabezpieczenia; nawet „anonimowi” powinni być w stanie wykonać te dwie operacje.
- (3) Następnie definiujemy łańcuch filtrów stosowany do wszystkich żądań, dodając dwie ważne konfiguracje: Odwołanie do punktu wejścia i ustawiając tworzenie sesji na
stateless
(nie chcemy, aby sesja była tworzona ze względów bezpieczeństwa, ponieważ używamy tokenów dla każdego żądania) . - (4) Nie potrzebujemy ochrony
csrf
, ponieważ nasze tokeny są na nią odporne. - (5) Następnie podłączamy nasz specjalny filtr uwierzytelniania do predefiniowanego łańcucha filtrów Spring, tuż przed filtrem logowania formularza.
- (6) Ta fasola jest deklaracją naszego filtra uwierzytelniania; ponieważ jest rozszerzeniem
AbstractAuthenticationProcessingFilter
, musimy zadeklarować go w XML, aby połączyć jego właściwości (tutaj auto wire nie działa). Wyjaśnimy później, co robi filtr. - (7) Domyślna procedura obsługi sukcesu
AbstractAuthenticationProcessingFilter
nie jest wystarczająco dobra do celów REST, ponieważ przekierowuje użytkownika do strony sukcesu; dlatego postawiliśmy tutaj własne. - (8) Deklaracja dostawcy utworzona przez
authenticationManager
jest używana przez nasz filtr do uwierzytelniania użytkowników.
Zobaczmy teraz, jak zaimplementujemy określone klasy zadeklarowane w powyższym pliku XML. Zwróć uwagę, że Spring je dla nas podłączy. Zaczynamy od najprostszych.

RestAuthenticationEntryPoint.java
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }
Jak wyjaśniono powyżej, ta klasa po prostu zwraca kod HTTP 401 (Nieautoryzowany), gdy uwierzytelnianie nie powiedzie się, zastępując domyślne przekierowania Spring.
JwtAuthenticationSuccessHandler.java
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }
To proste nadpisanie usuwa domyślne zachowanie udanego uwierzytelnienia (przekierowanie do strony domowej lub dowolnej innej strony, o którą poprosił użytkownik). Jeśli zastanawiasz się, dlaczego nie musimy zastępować AuthenticationFailureHandler
, dzieje się tak, ponieważ domyślna implementacja nie przekieruje nigdzie, jeśli jej adres URL przekierowania nie jest ustawiony, więc po prostu unikamy ustawiania adresu URL, co jest wystarczająco dobre.
JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }
Ta klasa jest punktem wejścia naszego procesu uwierzytelniania JWT; filtr wyodrębnia token JWT z nagłówków żądań i deleguje uwierzytelnianie do wstrzykniętego AuthenticationManager
. Jeśli token nie zostanie znaleziony, zostanie zgłoszony wyjątek, który zatrzymuje przetwarzanie żądania. Potrzebujemy również nadpisania dla pomyślnego uwierzytelnienia, ponieważ domyślny przepływ Spring zatrzyma łańcuch filtrów i wykona przekierowanie. Pamiętaj, że potrzebujemy pełnego wykonania łańcucha, w tym wygenerowania odpowiedzi, jak wyjaśniono powyżej.
JwtAuthenticationProvider.java
public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }
W tej klasie używamy domyślnego AuthenticationManager
Springa, ale wstrzykujemy go z naszym własnym AuthenticationProvider
, który wykonuje rzeczywisty proces uwierzytelniania. Aby to zaimplementować, rozszerzamy AbstractUserDetailsAuthenticationProvider
, który wymaga od nas jedynie zwrócenia UserDetails
na podstawie żądania uwierzytelnienia, w naszym przypadku tokenu JWT opakowanego w klasę JwtAuthenticationToken
. Jeśli token jest nieważny, zgłaszamy wyjątek. Jeśli jednak jest poprawny i odszyfrowanie przez JwtUtil
się powiedzie, wyodrębniamy dane użytkownika (dokładnie zobaczymy w klasie JwtUtil
), bez dostępu do bazy w ogóle. Wszystkie informacje o użytkowniku, w tym o jego rolach, zawarte są w samym tokenie.
JwtUtil.java
public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }
Wreszcie klasa JwtUtil
odpowiada za analizowanie tokenu do obiektu User
i generowanie tokena z obiektu User
. Jest to proste, ponieważ używa biblioteki jjwt
do wykonywania całej pracy JWT. W naszym przykładzie po prostu przechowujemy nazwę użytkownika, identyfikator użytkownika i role użytkownika w tokenie. Moglibyśmy również przechowywać więcej dowolnych rzeczy i dodać więcej funkcji bezpieczeństwa, takich jak wygaśnięcie tokena. Analiza tokenu jest używana w AuthenticationProvider
, jak pokazano powyżej. Metoda generateToken()
jest wywoływana z usług REST logowania i rejestracji, które są niezabezpieczone i nie wywołują żadnych kontroli bezpieczeństwa ani nie wymagają obecności tokenu w żądaniu. Na koniec generuje token, który zostanie zwrócony klientom, na podstawie użytkownika.
Wniosek
Chociaż stare, ustandaryzowane podejścia do bezpieczeństwa (plik cookie sesji, HTTP Basic i HTTP Digest) również będą działać z usługami REST, wszystkie mają problemy, których warto byłoby uniknąć, używając lepszego standardu. JWT przybywa w samą porę, aby uratować sytuację, a co najważniejsze, jest bardzo bliski osiągnięcia standardu IETF.
Główną siłą JWT jest obsługa uwierzytelniania użytkowników w sposób bezstanowy, a zatem skalowalny, przy jednoczesnym zachowaniu bezpieczeństwa dzięki aktualnym standardom kryptografii. Przechowywanie oświadczeń (roli użytkowników i uprawnień) w samym tokenie daje ogromne korzyści w rozproszonych architekturach systemów, w których serwer wysyłający żądanie nie ma dostępu do źródła danych uwierzytelniających.