Пример приложения с 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-токеном уже написаны утилиты, так что используем готовую. В ней есть метод формирования токена, валидации токена, методы извлечения имени пользователя и других данных.

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

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

@Service
public class JWTUtil {

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

    @Value("${jwt.sessionTime}")
    private long sessionTime;
    
    // генерация токена (кладем в него имя пользователя)
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, userDetails.getUsername());
    }
    
    //извлечение имени пользователя из токена

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }
 

    // валидация токена  
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

   // другие методы
}

Это была подготовка. Перейдем, наконец, к написанию своего кода, касающегося 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);
        }

        final String jwt = jwtTokenUtil.generateToken((UserDetails) authentication.getPrincipal());
        System.out.println(jwt);

        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. Извлекаем из него имя пользователя (которое записывали при формировании токена).
  3. Проверяем подпись (validateToken())
  4. Если все ок, устанавливаем в SecurityContext объект Authencation (а в AuthencationUserDetails). Так нужно для Spring Security.
  5. Если с токеном не все ок, то фильтр не пропустит запрос в контроллер к защищенному /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);
            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);
            }
        }
        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 получена.

Исходный код

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

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

  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?

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

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