Strona główna » JWT w Spring Security

JWT w Spring Security

by Grzegorz Sowa

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

sekcje jwt

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

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.

You may also like

5 komentarzy

Michał 27 września, 2021 - 11:03 pm

Bardzo przyjemny i konkretny artykuł. Dzięki za takie treści!

Reply
Grzegorz Sowa 28 września, 2021 - 4:52 pm

Dzięki Michał! 🙂

Reply
Greg 2 października, 2021 - 11:11 am

Wszystko fajnie, tylko jak to przetestować? Nie można się zalogować, endpoint do logowania zwraca 401, coś chyba nie tak z konfiguracją.

Reply
Grzegorz Sowa 3 października, 2021 - 10:02 pm

Hej, z logowaniem działa request POST 🙂
Spróbuj tego:

POST /login HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded

username=user&password=pass

Jeśli chcesz widok stronki logowania zwracanej z backendu to możesz usunąć w WebSecurityConfig:

.exceptionHandling()
.authenticationEntryPoint((req, resp, ex) -> resp.setStatus(UNAUTHORIZED.value()))

Reply
Greg 4 października, 2021 - 11:16 am

O to chodziło! Dziękuję.
No i super, takiego artykułu mi brakowało w temacie jwt!

Reply

Leave a Comment