Jeśli rozmawiamy o autoryzacji, zawsze przychodzi na myśl JWT w Spring Security. Ale dlaczego właśnie JWT? Spring Security przecież świetnie sobie radzi z Session Cookie, w dodatku jest to domyślna metoda autoryzacji w tym frameworku. Spróbujmy sobie więc odpowiedzieć, czym jest JWT i jakie korzyści daje nad ciasteczkiem sesji. Na koniec spróbujmy w prosty sposób zaimplementować JWT w aplikacji opartej na Spring Boot.
Kod zawarty w tym artykule znajdziecie tutaj. Nie zapomnijcie przejrzeć innych branchy, zawierających kod odpowiadający kolejnym sekcjom artykułu.
JWT w teorii

JWT, czyli JSON Web Token to otwarty standard bezpiecznego transferu informacji o uprawnieniach, opisany w rfc7519. Każdy token jest ciągiem znaków, składających się z 3 części: nagłówka (Header), danych (Payload) i podpisu (Signature). Header zawiera takie informacje, jak typ tokenu i algorytm wykorzystany do podpisu. Payload natomiast zawiera dane zależne od aplikacji, część z nich jest opisana w standardzie:
- iss (Issuer) – aplikacja wydająca token.
- sub (Subject) – odbiorca tokenu.
- aud (Audience) – lista podmiotów, w komunikacji z którymi token powinien być używany.
- exp (Expiration Time) – timestamp momentu w przyszłości, po którym token nie powinien być akceptowany.
- nbf (Not Before) – timestamp momentu w przyszłości, przed którym token nie powinien być akceptowany.
- iat (Issued At) – timestamp momentu, w którym token został wydany.
- jti (JWT ID) – unikalny identyfikator tokenu.
Wszystkie te parametry są opcjonalne, powstały tylko po to, aby developerzy mieli pewien punkt zaczepienia, definiując najczęściej używane w tokenach rzeczy.
Zarówno Header, jak i Payload są w tokenie w formie enkodowanej w Base64 i w takiej formie są składnikami do podpisu, który jest ostatnią częścią tokena. Do podpisu jest też używany sekret, który zna tylko serwer go wydający. W zależności od potrzeb JWT może być podpisywane algorytmem symetrycznym (wtedy zweryfikować go może tylko aplikacja-wydawca) lub asymetrycznym (wtedy część klucza jest dostępna publicznie i każdy może zweryfikować poprawność podpisu).
JWT w praktyce
JWT jest bardzo często używany jako standard nadawania uprawnień, również w implementacji innych frameworków jak OAuth2 czy Device Cookie. Przekazywanie tokena JWT w nagłówku zapytania ma kilka przewag nad standardowym Session Cookie:
- O wiele lepiej się skaluje, gdyż aby zweryfikować JWT aplikacja nie musi wykonywać kosztownych zapytań do bazy danych.
- Nie dotyczą go ataki CSRF, przez co jest jedynym sensownym wyborem dla Single Page Application, gdzie trudno jest przekazać token CSRF z backendu.
- Zapewnia prawdziwie bezstanową komunikację, w przeciwieństwie do Session Cookie.
JWT w Spring Security

Teraz zobaczmy, jak zmienić standardowy sposób autoryzacji w Spring Security na JSON Web Token.
Implementacja użytkowników
Ponieważ standardowa implementacja danych użytkownika w Spring Security nie posiada ID, musimy zrobić własną, implementującą interfejs UserDetails
:
@Entity @Getter @NoArgsConstructor(access = AccessLevel.PACKAGE) public class AppUser implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String password; @Convert(converter = AuthoritiesToStringConverter.class) private Set<Authority> authorities; public static AppUser newInstance(String username, String password, Authority... authorities) { final AppUser user = new AppUser(); user.username = username; user.password = password; user.authorities = Set.of(authorities); return user; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
Dlaczego AppUser
a nie User
? Nazwa User
mogłaby sprawiać problemy w zapytaniach do bazy danych. Natomiast użyty w klasie enum Authority
musi implementować interfejs GrantedAuthority
:
public enum Authority implements GrantedAuthority { USER, ADMIN; @Override public String getAuthority() { return this.name(); } }
Teraz pozostał już tylko serwis odpowiedzialny za użytkowników, implementujący interfejs UserDetailsService
.
@Component @RequiredArgsConstructor public class AppUserService implements UserDetailsService { private final AppUserRepository repository; private final PasswordEncoder passwordEncoder; public AppUser createUser(String username, String password) { return repository.save(AppUser.newInstance(username, passwordEncoder.encode(password), Authority.USER)); } public AppUser createAdmin(String username, String password) { return repository.save(AppUser.newInstance(username, passwordEncoder.encode(password), Authority.USER, Authority.ADMIN)); } @Override public AppUser loadUserByUsername(String username) throws UsernameNotFoundException { return repository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("No user with such username!")); } }
PasswordEncoder
zdefiniujmy w oddzielnej klasie konfiguracyjnej:
@Configuration public class PasswordHandlingConfiguration { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
Mając to, możemy stworzyć kilku przykładowych użytkowników do testów, na starcie aplikacji:
@Component @RequiredArgsConstructor public class AddExampleUserRunner implements CommandLineRunner { private final AppUserService userService; @Override public void run(String... args) { userService.createUser("user", "pass"); userService.createUser("user2", "pass"); userService.createAdmin("admin", "pass"); } }
Konfiguracja
Następnie przechodzimy do konfiguracji Spring Security, na początek musimy podpiąć stworzony UserDetailsService
oraz PasswordEncoder
:
@Configuration @RequiredArgsConstructor public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { private final PasswordEncoder passwordEncoder; private final AppUserService userService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userService) .passwordEncoder(passwordEncoder); } // more.. }
Dalej w konfiguracji mamy do zrobienia jeszcze kilka rzeczy:
- wyłączyć mechanizm tworzenia sesji, ustawiając go na wartość
STATELESS
, - podłączyć filtr, który będzie „rozpakowywał” token JWT jeszcze zanim Spring sprawdzi tożsamość użytkownika,
- podłączyć
AuthenticationSuccessHandler
, który będzie tworzył nowy token JWT.
private final AccessTokenProvider accessTokenProvider; @Override protected void configure(HttpSecurity http) throws Exception { http .addFilterBefore(new AccessTokenPreAuthorizationFilter(accessTokenProvider), UsernamePasswordAuthenticationFilter.class) .authorizeRequests() .antMatchers(GET, "/posts/**").permitAll() .anyRequest().authenticated() .and() .formLogin() .successHandler(new AccessTokenAuthenticationSuccessHandler(accessTokenProvider, userService)) .and() .exceptionHandling() .authenticationEntryPoint((req, resp, ex) -> resp.setStatus(UNAUTHORIZED.value())) .and() .sessionManagement().sessionCreationPolicy(STATELESS) .and() .csrf().disable(); }
AccessTokenPreAuthorizationFilter
Filtr odczytuje nagłówek Authorization
i wyciąga z niego token JWT, aby potem zamienić go na obiekt Authentication
i wstawić go w SecurityContext
sesji. Do operacji na JWT używa on AccessTokenProvider
, który zdefiniujemy później.
@Slf4j @RequiredArgsConstructor public class AccessTokenPreAuthorizationFilter extends OncePerRequestFilter { private final AccessTokenProvider accessTokenProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authorizationHeader = request.getHeader(AUTHORIZATION); if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { String token = authorizationHeader.substring("Bearer ".length()); SecurityContextHolder.getContext() .setAuthentication(accessTokenProvider.extractAuthentication(token)); } filterChain.doFilter(request, response); } }
AccessTokenAuthenticationSuccessHandler
Teraz zdefiniujmy AuthenticationSuccessHandler
, który po pomyślnym uwierzytelnieniu wygeneruje dla usera Access Token i przekaże go w nagłówku. On również do operacji na JWT używa obiektu AccessTokenProvider
.
@RequiredArgsConstructor public class AccessTokenAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private final AccessTokenProvider accessTokenProvider; private final AppUserService userService; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication auth) { final AppUser user = (AppUser) auth.getPrincipal(); final String accessToken = accessTokenProvider.getAccessToken( userService.loadUserByUsername(user.getUsername()).getId().toString(), user.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); response.setHeader("access_token", accessToken); } }
AccessTokenProvider
Interfejs AccessTokenProvider
zawiera tylko dwie metody, używane do tej pory przez klasy obsługujące flow JWT:
public interface AccessTokenProvider { String getAccessToken(String userId, List<String> roles); Authentication extractAuthentication(String token); }
Aby go zaimplementować, potrzebujemy biblioteki obsługującej JWT. Ja wybrałem na potrzeby tego przykładu implementację od auth0:
<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.18.1</version> </dependency>
Obsługując tokeny JWT warto skupić się na minimum, definiując tylko „sub” (Subject) w postaci id użytkownika, „exp” (Expiration Time), po upłynięciu którego biblioteka automatycznie odrzuci token, oraz role użytkownika w polu „roles”.
@Component public class JwtAccessTokenProvider implements AccessTokenProvider { private final JwtAccessTokenProperties properties; private final Algorithm algorithm; private final JWTVerifier verifier; public JwtAccessTokenProvider(JwtAccessTokenProperties properties) { this.properties = properties; this.algorithm = Algorithm.HMAC256(properties.getSecret().getBytes(StandardCharsets.UTF_8)); this.verifier = JWT.require(algorithm).build(); } @Override public String getAccessToken(String userId, List<String> roles) { return JWT.create() .withSubject(userId) .withExpiresAt(new Date(System.currentTimeMillis() + properties.getTokenLifespanSeconds() * 1000)) .withClaim("roles", roles) .sign(algorithm); } @Override public UsernamePasswordAuthenticationToken extractAuthentication(String token) { DecodedJWT jwt = verifier.verify(token); return new UsernamePasswordAuthenticationToken( jwt.getSubject(), null, jwt.getClaim("roles").asList(String.class).stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()) ); } }
Kontrola dostępu
Zdefiniujmy teraz kontroler, w którym użytkownik może tworzyć własne wpisy i przeglądać wpisy innych. Ponadto, oddajmy w ręce użytkowników opcję usuwania wpisów.
@RestController @RequestMapping("posts") @RequiredArgsConstructor public class PostController { private final PostService postService; @GetMapping("{postId}") public Post getPost(@PathVariable Long postId) { return postService.getPost(postId); } @PostMapping public ResponseEntity<Void> createPost(@RequestBody CreatePostDto dto) { postService.createPost(dto.content); return ResponseEntity.status(CREATED).build(); } @DeleteMapping("{postId}") // … public ResponseEntity<Void> deletePost(@PathVariable Long postId) { postService.deletePost(postId); return ResponseEntity.status(NO_CONTENT).build(); } @Data public static class CreatePostDto { private String content; } }
Tutaj pojawia się zagadnienie Ownership-based Access Control, ponieważ użytkownik powinien móc usunąć tylko własny wpis. Poza tym, administrator, którego wcześniej stworzyliśmy, powinien być w stanie usunąć każdy wpis (Role-based Access Control). To drugie zagadnienie załatwimy, dodając nad deletePost()
adnotację @PreAuthorize("hasAuthority('ADMIN')")
. To jednak nie wszystko, musimy jeszcze włączyć ten mechanizm, dodając nad klasą konfiguracyjną @EnableGlobalMethodSecurity(prePostEnabled = true)
. Ownership-based Access Control jest już trudniejszy, musimy zdefiniować komponent, który będzie mógł wyciągnąć wpis z bazy i sprawdzić, czy id jego autora jest takie samo jak to, które przyszło w JWT.
@Component @RequiredArgsConstructor public class AuthorChecker { private final PostService postService; public boolean check(Long postId) { final Post post = postService.getPost(postId); final Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return post.getAuthorId().equals(principal); } }
Teraz musimy jeszcze użyć metody check()
w adnotacji @PreAuthorize
. Gotowa metoda delete będzie wyglądała tak:
@DeleteMapping("{postId}") @PreAuthorize("hasAuthority('ADMIN') or @authorChecker.check(#postId)") public ResponseEntity<Void> deletePost(@PathVariable Long postId) { postService.deletePost(postId); return ResponseEntity.status(NO_CONTENT).build(); }
JWT vs Session Cookie
Zajrzyjmy jeszcze do zapytań do bazy żeby zobaczyć, jak dużą przewagę ma JWT nad Session Cookie. Niestety nie odnotujemy wewnętrznych zapytań robionych przez Springa na poziomie Hibernate, musimy zejść poziom niżej. W tym celu pobieramy bibliotekę zdolną założyć proxy na DataSource
:
<dependency> <groupId>net.ttddyy</groupId> <artifactId>datasource-proxy</artifactId> <version>1.4.1</version> </dependency>
Teraz musimy jeszcze zapakować oryginalny DataSource
w nasze proxy i sprawdzić, aby był używany w całej aplikacji. Idealnie nadaje się do tego BeanPostProcessor
, który pozwala modyfikować beany po ich zainicjalizowaniu:
@Component public class DatasourceProxyBeanPostProcessor implements BeanPostProcessor { @Override public Object postProcessBeforeInitialization(final Object bean, final String beanName) { return bean; } @Override public Object postProcessAfterInitialization(final Object bean, final String beanName) { if (bean instanceof DataSource dataSourceBean) { return ProxyDataSourceBuilder .create(dataSourceBean) .name("DataSourceProxyLogger") .listener(new QueryLoggingListener()) .build(); } return bean; } } @Slf4j public class QueryLoggingListener implements QueryExecutionListener { @Override public void afterQuery(ExecutionInfo executionInfo, List<QueryInfo> list) { for (QueryInfo queryInfo: list) { log.info("Query: " + queryInfo.getQuery()); } } // … }
Zobaczmy więc, co nasz logger powie, jeśli użytkownikowi uda się usunąć wpis:
QueryLoggingListener: Query: select post0_.id as id1_1_0_, post0_.author_id as author_i2_1_0_, post0_.content as co[...] QueryLoggingListener: Query: delete from post where id=?
Tyle, dwa zapytania – wyjmij wpis, usuń wpis. Dla porównania skonfigurowałem tę aplikację, żeby korzystała z sesji zapisywanej w JDBC. Oto wynik jednej operacji usunięcia wpisu:
QueryLoggingListener: Query: SELECT S.PRIMARY_ID, S.SESSION_ID, S.CREATION_TIME, S.LAST_ACCESS_TIME, S.MAX_INACTIVE[...] QueryLoggingListener: Query: select post0_.id as id1_1_0_, post0_.author_id as author_i2_1_0_, post0_.content as co[...] QueryLoggingListener: Query: delete from post where id=? QueryLoggingListener: Query: UPDATE SPRING_SESSION SET SESSION_ID = ?, LAST_ACCESS_TIME = ?, MAX_INACTIVE_INTERVAL [...] QueryLoggingListener: Query: SELECT S.PRIMARY_ID, S.SESSION_ID, S.CREATION_TIME, S.LAST_ACCESS_TIME, S.MAX_INACTIVE[...]
Spring aby usunąć jeden post wykonał aż pięć zapytań. Choć JWT również nie jest bez kosztów, ponieważ wymaga sprawdzenia poprawności podpisu, zdecydowanie wygrywa on w tym starciu. Kod użyty to testu z Session Cookie znajdziecie na branchu session-cookie.
Podsumowanie
JWT w Spring Security to konieczność w większych aplikacjach i technologia, którą na pewno warto znać. W artykule zobaczyliśmy, czym jest JWT i jak skorzystać z niego w Springu, rozwiązując 2 problemy: Ownership-based i Role-based Access Control, a także przewagę wydajnościową, jaką JWT daje nad Session Cookie.