Пример приложения с JWT-токеном

Напишем REST API с использованием JWT-токена.

Здесь о JWT-токене.

Задача

Ранее мы писали приложения с формой логина, которую предоставляет  Spring Security (SS) по умолчанию. В этой статье так не получится, поскольку по умолчанию SS не поддерживает JWT. Сейчас будет чистый REST-сервис без фронтенда. Подразумевается, что фронтенд написан отдельно: например, на каком-нибудь JavaScript-фрейворке.

Для отправки запросов мы будем использовать программу POSTMAN. Например, для “входа” с именем/паролем и получения JWT-токена. А также для запроса защищенных страниц.

В этом примере наш старый REST-контроллер останется, а настройка Spring Security не особо поменяется – скорее, она дополнится.

  1. Мы добавим в приложение конечную точку /authenticate для аутентификации. Сюда приходят имя и пароль от пользователя. Приложение проверяет пароль, и если он верный, высылает пользователю в ответ JWT-токен.
  2. Во всех дальнейших запросах пользователь обязан высылать в заголовке JWT-токен, наше приложение проверяет подлинность токена в специально написанном фильтре JWTFilter и, если он корректен, пропускает запрос дальше.

Подготовка

REST-контроллер

Итак, наш основной REST-контроллер остается прежним:

@RestController
public class HelloController {

    @GetMapping("/")
    public String hello() {
        return "Hello";
    }

    // сюда доступ разрешен только user и admin 
    @GetMapping("/user")
    public String user() {
        return "User";
    }

    // сюда доступ разрешен только admin 
    @GetMapping("/admin")
    public String admin() {
        return "Admin";
    }

}

Он нужен для того, чтобы запретить к нему доступ и потом разрешить только авторизованным пользователям.

Аутентификация с пользовательским UserDetailsService

Настройка аутентификации такая:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    //... 

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

}

Подробнее об аутентификации с UserDetailsService есть статья.

Если кратко, мы переопределяем метод loadUserByUsername(), чтобы Spring Security понимал, как взять пользователя по его имени из хранилища. Имея этот метод, SS может сравнить переданный пароль с настоящим и аутентифицировать пользователя (либо не аутентифицировать).

CustomUserDetailsService:

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private MyUserRepository dao;
    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        MyUser myUser= dao.findByLogin(userName);
        if (myUser == null) {
            throw new UsernameNotFoundException("Unknown user: "+userName);
        }
        UserDetails user = User.builder()
                .username(myUser.getLogin())
                .password(myUser.getPassword())
                .roles(myUser.getRole())
                .build();
        return user;
    }
}

Пользователи хранятся в In-Memory базе данных H2. Работаем через Hibernate.

Модель пользователя:

@Entity
public class MyUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    long id;
    private String login;
    private String password;
    private String position;
    private String role;
  
   // геттеры/сеттеры

}

И репозиторий:

@Repository
public interface MyUserRepository extends JpaRepository<MyUser, Long> {
    MyUser findByLogin(String login);
}

А данными заполняем базу на старте приложения с помощью data.sql (этот файл надо положить в папку /resources, а в application.yml включить его запуск):

insert into my_user(login, position, password, role) values('user', '1', 'password', 'USER');
insert into my_user( login, position, password, role) values('admin', '2', 'password', 'ADMIN');

А теперь перейдем собственно в JWT-токену.

Библиотека для работы с JWT-токеном

Для работы с JWT добавим Maven-зависимость:

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

Для работы с JWT-токеном уже написаны утилиты, так что используем готовую. В ней есть метод формирования токена, методы извлечения имени пользователя и других данных. Метод формирования токена изменим так, чтобы записывать в токен еще список authorities в виде клейма authorities.

Во всех методах извлечения данных JWT-токен заодно проверяется на предмет не истек ли он, и валидна ли подпись.

Эти методы утилиты нам пригодятся:

@Service
public class JWTUtil {

    @Value("${jwt.secret}")
    private String SECRET_KEY;

    @Value("${jwt.sessionTime}")
    private long sessionTime;

    // генерация токена (кладем в него имя пользователя и authorities)
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        String commaSeparatedListOfAuthorities=  userDetails.getAuthorities().stream().map(a->a.getAuthority()).collect(Collectors.joining(","));
        claims.put("authorities", commaSeparatedListOfAuthorities);
        return createToken(claims, userDetails.getUsername());
    }
    
    //извлечение имени пользователя из токена (внутри валидация токена)
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    //извлечение authorities (внутри валидация токена)
    public String extractAuthorities(String token) {
       return extractClaim(token, claims -> (String)claims.get("authorities"));
    }
   // другие методы
}

Это была подготовка. Перейдем, наконец, к написанию своего кода, касающегося JWT.

Конечная точка аутентификации для выдачи JWT-токена

Тут собственно выдается  JWT-токен. Пользователь делает POST-запрос с именем и паролем по адресу /authenticate, а в ответ получает сгенерированынй токен. Токен генерится методом generateToken() из утилиты выше.

@RestController
public class AuthenticationController {


    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JWTUtil jwtTokenUtil;


    @PostMapping("/authenticate")
    @ResponseStatus(HttpStatus.OK)
    public AuthResponse createAuthenticationToken(@RequestBody AuthRequest authRequest) {
        Authentication authentication;
        try {
            authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(authRequest.getName(), authRequest.getPassword()));
            System.out.println(authentication);
        } catch (BadCredentialsException e) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Имя или пароль неправильны", e);
        }
        // при создании токена в него кладется username как Subject claim и список authorities как кастомный claim
        String jwt = jwtTokenUtil.generateToken((UserDetails) authentication.getPrincipal());

        return new AuthResponse(jwt);
    }
}

Запрос имеет такой формат:

public class AuthRequest {
    private String name;
    private String password;
    // геттеры сеттеры
}

А ответ такой:

public class AuthResponse {

    private String jwtToken;

   // геттер и сеттер
}

Если имя и пароль верные, токен возвращается в AuthResponse, а если нет – выбрасывается исключение и на фронтенд приходит сообщение об ошибке.

Фронтенд сохраняет у себя JWT-токен, и потом использует его при каждом запросе.

Немного о “разлогине”

Если пользователь хочет выйти, токен должен быть уничтожен на фронтенде. На бэкенде (в нашем Spring приложении) он продолжит действовать до истечения своего срока. А вообще теоретически можно сделать черный список токенов, но не в этом примере.

Еще: чтобы сделать токены сразу всех пользователей недействительными, достаточно поменять секретный код. Но тогда разлогинены будут все сразу.

В этом проблема JWT-токена  – нужны обходные пути для того, чтобы сделать его недействительным.

Перейдем ко второй принципиальной части – фильтру, проверяющему токен при каждом запросе.

Фильтр, проверяющий JWT-токен при каждом запросе

Итак, JWT-токен выдан, клиент его нам отправляет при каждом запросе, надо этот токен при каждом запросе проверять (и извлекать из него имя пользователя). Для этого напишем фильтр JWTFilter. Он расширяет OncePerRequestFilter и происходит в нем следующее:

  1. При каждом запросе из заголовка Authorization берем JWT-токен (он начинается с  префикса “Bearer“).
  2. Извлекаем из него имя пользователя (claim subject) и список authorities (claim authorities). Оба клейма мы записывали в токен при его генерации в контроллере.
  3. Одновременно при извлечении claims проверяется валидность токена. Для этого не надо делать никаких запросов в базу: достаточно самого токена и jwt.secret (прописанного в application.yml). На основе этого секрета токен генерился, и на основе него он потом каждый раз проверяется с помощью хеш-функции (это делает библиотека jjwt).
  4. Если все ок, то имея имя пользователя и список authorities (извлеченные в п.2), создаем объект Authentication (точнее, его подкласс UsernamePasswordAuthenticationToken). И устанавливаем объект Authentication  в SecurityContext. Так нужно для Spring Security.
  5. Если с токеном не все ок, то в п. 2-3 выбросился исключение, и  фильтр не пропустит запрос в контроллер к защищенному /url.
@Component
public class JWTFilter extends OncePerRequestFilter {

    @Autowired
    private JWTUtil jwtUtil;

    @Autowired
    CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        final String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            //если подпись не совпадает с вычисленной, то SignatureException
            //если подпись некорректная (не парсится), то MalformedJwtException
            //если время подписи истекло, то ExpiredJwtException
            username = jwtUtil.extractUsername(jwt);
        }


        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            String commaSeparatedListOfAuthorities = jwtUtil.extractAuthorities(jwt);
            List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(commaSeparatedListOfAuthorities);
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                    new UsernamePasswordAuthenticationToken(
                            username, null, authorities);

            SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

        }
        chain.doFilter(request, response);
    }

}

Настройка авторизации: собираем все вместе

Тут все как раньше, но только:

  1. отключаем csrf,
  2. отключаем сессии
  3. и добавляем наш фильтр JWTFilter перед фильтром UsernamePasswordAuthenticationFilter.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Autowired
    private JWTFilter jwtFilter;

   // еще поля и методы
 
    // Бин AuthenticationManager используется в контроллере аутентификации (см. выше)

    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    // тут отключаем сессии и добавляем фильтр JWTFilter 

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/authenticate").permitAll()
                .and().authorizeRequests().antMatchers("/user/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
                .and().authorizeRequests().antMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
                .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

Проверка

Запустим приложении и убедимся, что все работает.

Получение JWT-токена

Отправим POST-запрос нужного формата по адресу /authencate:

Этим запросом получаем jwt-токен
Этим запросом получаем jwt-токен

Отправка запроса с JWT-токеном

Токен получен, теперь с ним можно попасть на защищенную страницу. Для этого добавим его к запросу /user в заголовок Authorization.

При этом выберем тип Bearer-токен – это значит, что префикс Bearer будет добавлен к токену.

А теперь с ним запрашиваем защищенный url /user
А теперь с ним запрашиваем защищенный url /user

Как видно, страница /user получена.

Вид JWT-токена

Если декодировать токен на сайте https://jwt.io/, то в нем видны наши добавленные клеймы sub (имя пользователя) и authorities:

Декодированный токен

Остальные два клейма:

  • exp – когда истекает токен
  • iat -когда выпущен токен

Исходный код

Он находится на GitHub.

Пример приложения с JWT-токеном: 24 комментария

  1. Здравствуйте, а можно увидеть статью с применением refresh токена который обновляется автоматически через 60 дней + access token 30мин.

      1. отлично) я в свое время писал у меня не вышло… и примера реализации не нашел…((

  2. Спасибо за статью. Только один вопрос: если, например, в методе Rest addRole есть необходимость вернуть фронту данные юзера (поля юзера из бд для обновления стейтов на фронте, т.е. кучу всего, кроме логина и ролей юзера, записанных в токен из userDetails) плюс обновленный токен (с продленной датой и дополненными ролями), как быть? Фильтр же просто авторизирует, чтобы пропустить к самому методу контроллера, но не записывает новый токен в headers респонса. Да и смысла нет, если роли меняются в методе уже после прохождения фильтра. А если в самом rest методе вызывать генерацию нового токена – получается, надо utils и т.п. пихать в каждый микросервис, что ли

    1. Вопрос интересный, требуется время разобраться, увы. Но в данном примере токен не обновляется, по истечении жизни токена пользователь должен перелогиниться.
      Вообще обновление (рефреш) токена – часть (причем необязательная) протокола OAauth2, где участвуют сервер авторизации, сервер ресурсов и клиент. Сервер авторизации создается как отдельное приложение, и в нем делается endpoint для обновления токена. Причем в текущей версии Spring Security, есть своя библиотека для работы с токеном (никаких jjwt не надо).

      1. Спасибо! Но у меня по тз сервис с работой с токеном лежит вместе с аккаунтингом. При этом другие микросервисы тоже должны иметь возможность через этот jwt сервис авторизироваться.
        Грубо говоря, юзер нажимает на сайте кнопку “добавить коммент” (реквест должен проверить+обновить токен и отправиться дальше в другой микросервис для паблиша поста). Получается, на фронте в каждом методе для каждого сервера нужно делать 2 запроса: сначала отправлять токен по эндпоинту сервиса авторизации jwt, а если response.ok – то уже в микросервис с нужным методом в контроллере? Фильтр тогда вообще не нужен, если переносим все манипуляции в JwtServiceImpl?

  3. В этом коде если при валидации токена обнаружится проблема, тогда сервер вернёт на постман 500-ю ошибку. А как сделать чтобы с класса JWTFilter отправить более подходящий код ошибки произошедшей из-за SignatureException, например 403-й и увидеть это в постмане?

    1. Можно обернуть код в try-catch (только всё – jwtUtil.extractUsername(jwt) тоже делает проверку под капотом и это исключение выбрасывает) и вручную назначить статус ответу response.setStatus(HttpServletResponse.SC_UNAUTHORIZED):

      try {

      if (authorizationHeader != null && authorizationHeader.startsWith(“Bearer “)) {
      jwt = authorizationHeader.substring(7);
      username = jwtUtil.extractUsername(jwt);
      }

      if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

      UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

      //если подпись неправильная, то SignatureException
      if (this.jwtUtil.validateToken(jwt, userDetails)) {

      UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
      new UsernamePasswordAuthenticationToken(
      userDetails, null, userDetails.getAuthorities());

      usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

      SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
      }
      }
      } catch (SignatureException e) {
      response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
      }
      chain.doFilter(request, response);

      1. Извините, я так сейчас сделал.
        Вижу что в блоке кетч ловится 401-й ответ.
        Но ответ по видимому дальше ни куда не идёт и в постмане теперь ответ от сервера 200 всегда (как при успешной так и неуспешной валидации)

        1. Хм…да, вы правы. Попробуйте так (сразу return и не идти по цепочке фильтров):
          response.sendError(HttpServletResponse.SC_UNAUTHORIZED, “SignatureException”);
          return;
          У меня в этом случае ответ:
          {
          “timestamp”: “2020-10-08T16:12:43.397+0000”,
          “status”: 401,
          “error”: “Unauthorized”,
          “message”: “SignatureException”,
          “path”: “/user”
          }
          ПС это вместо response.setStatus

          1. Да, спасибо большое, работает превосходно.
            Ваш сайт просто находка в рунете да и не только.

  4. Делал всё по гайду, столкнулся с ошибкой.
    При попытке получить информацию через postman
    {
    “timestamp”: “2020-10-26T23:38:04.653+00:00”,
    “status”: 403,
    “error”: “Forbidden”,
    “message”: “Forbidden”,
    “path”: “/orders”
    }

    1. Информации, к сожалению, недостаточно, чтобы понять причину. Но необязательно пользоваться Postman, еще тесты есть, можно их запустить (в изначальном примере они проходят).
      ПС проверьте что для /orders соответствующее разрешение стоит в конфигурации, например.

  5. Подскажите пожалуйста, зачем в AuthenticationController вызывается метод authenticate? Я так понимаю для получения UserDetails объекта? По сути метод createAuthenticationToken несёт в себе только лишь создание токена, правильно я понимаю? То есть я понимаю так, как вы описывали в другой статье, аутентификация проходит в фильтре UsernamePasswordAuthenticationFilter. И по факту для создания токена мы можем и через SpringSecurityHolder вытащить, так как аутентификация уже пройдёт и объект UserDetails будет лежать в authentication.getPrincipal(). Правильно я понимаю?

    1. Не, UsernamePasswordAuthenticationFilter не всегда же вызывается, а только если данные приходят с формы (http.formLogin() в конфиге). В этом примере они приходят хоть с POSTMAN.
      В createAuthenticationToken не только создается токен, но и проверяется корректность пришедшего имени и пароля (authenticationManager.authenticate проверяет их и выбрасывает исключение, если они не корректны. То же самое делал бы фильтр UsernamePasswordAuthenticationFilter, но тут мы в контроллере делаем аутентификацию).

      1. Спасибо, стало яснее, а то немного момент с аутентификацией разными способами, запутал и я сам написал своё приложение в расчете что UsernamePasswordAuthenticationFilter Всегда срабатывает для аутентификации. Вообще, мы же можем написать свой фильтр, который будет, если нет в хеадере token, аутентифицировать до прихода запроса в контролер, мне кажется так будет кошернее. Тогда на контроллер login уже будут приходить аутентифицированные запросы.

      2. И ещё вопрос, зачем мы устанавливаем наш jwt фильтр перед UsernamePasswordAuthenticationFilter? Если он не участвует в flow, как я понял

          1. Ещё такой вопрос, у меня проект StackOverFlow exception вылетал, если в SecurityConfig
            @Bean
            public AuthenticationManager authenticationManagerBean() throws Exception {
            return super.authenticationManagerBean();
            }
            вызывать такой метод, но если просто вызвать super.authenticationManager() всё будет окей

          2. Добрый день, пытался реализовать аутентификацию на этапе фильтра, но встретил множество проблем, единственную проблему которую не могу понять как решить, то что в фильтр мой с одного запроса посланного с постмана заходит несколько раз, не могу понять почему?

          3. возможно возвращается перенаправление (302 с header location), Postman незаметно инициирует еще один запрос. Это можно посмотреть в консоли Postman (в левом нижнем углу кнопка Консоль)

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *