Реализация JWT в Spring Boot

В этой статье мы реализуем JWT авторизацию с использованием Spting-Boot приложений.

· 10 мин.
Реализация JWT в Spring Boot

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

Одно из преимуществ авторизации с использованием JWT – это возможность выделить выдачу токенов и работу с данными пользователей в отдельное приложение, переложив механизм валидации токенов на клиентские приложения. Этот механизм отлично подходит микросервисной архитекутуре.

Что такое JWT токен?
Эта статья посвящена детальному разбору JWT и его возможностей. Мы изучим структуру токена и построим его с нуля. Затем рассмотрим наиболее распространенные способы использования.
Предыдущая статья описывает механизм работы, преимущества и недостатки, JWT авторизации.

Сначала мы сделаем приложение, которое будет совмещать в себе API и выдачу токенов. А чуть позже мы сделаем отдельное приложение, которое будет только валидировать токены, но не будет их выдавать. Таких приложений может быть много.

Схема авторизации будет такая:

  • Пользователь (фронт) присылает нам объект с логином и паролем.
  • Если пароль подходит, то мы генерируем access и refresh токены и отправляем их в ответ.
  • Access токен пользователь (фронт) используется для взаимодействия с остальным нашим API.
  • Через пять минут, когда access токен протухает, фронт отправляет refresh токен и получает новый access токен. И так по кругу, пока не протухнет refresh токен.
  • Рефреш токен выдается на 30 дней. Примерно на 25-29 день фронт отправляет валидный refresh токен вместе с валидным access токеном и взамен получает новую пару токенов.
  • Также обычно в обучающих статьях опускают наличие refresh токена. Мы разберем и refresh токен, так как он является важной частью в авторизации с помощью JWT.

В этой статье будет чистый REST-сервис без фронтенда. Подразумевается, что фронтенд написан отдельно: например, на каком-нибудь JavaScript-фрейворке.

Создание сервера авторизации

Готовый проект на GitHub: github.com/Example-uPagge/jwt-server-spring

Я уже собрал начальную конфигурацию приложения на сайте start.spring.io, вам остается только скачать ее, распаковать, и открыть с помощью Idea.

После этого в pom.xml добавим еще 2 зависимости. Они понадобятся нам для генерации JWT токенов.

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.1</version>
</dependency>
<dependency>
  <groupId>javax.xml.bind</groupId>
  <artifactId>jaxb-api</artifactId>
  <version>2.4.0-b180830.0359</version>
</dependency>

Сперва создадим наш незамысловатый API. У нас будет 2 роли для пользователей и два endpoints:

  • /api/hello/user – будет выводить приветственное сообщение для пользователей с ролью "USER"
  • /api/hello/admin – будет выводить приветственное сообщение для пользователей с ролью "ADMIN".
package org.sadtech.example.jwt.server.controller;
... ... ... ... ...
@RestController
@RequestMapping("api")
@RequiredArgsConstructor
public class Controller {

    private final AuthService authService;

    @PreAuthorize("hasAuthority('USER')")
    @GetMapping("hello/user")
    public ResponseEntity<String> helloUser() {
        final JwtAuthentication authInfo = authService.getAuthInfo();
        return ResponseEntity.ok("Hello user " + authInfo.getPrincipal() + "!");
    }

    @PreAuthorize("hasAuthority('ADMIN')")
    @GetMapping("hello/admin")
    public ResponseEntity<String> helloAdmin() {
        final JwtAuthentication authInfo = authService.getAuthInfo();
        return ResponseEntity.ok("Hello admin " + authInfo.getPrincipal() + "!");
    }

}

Теперь создадим класс пользователя системы User и enum отвечающий за роль пользователя:

package org.sadtech.example.jwt.server.domain;
... ... ... ... ...
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User {

    private String login;
    private String password;
    private String firstName;
    private String lastName;
    private Set<Role> roles;

}
package org.sadtech.example.jwt.server.domain;
... ... ... ... ...
@RequiredArgsConstructor
public enum Role implements GrantedAuthority {

    ADMIN("ADMIN"),
    USER("USER");

    private final String vale;

    @Override
    public String getAuthority() {
        return vale;
    }

}

Далее создадим UserService, у него будет только один метод, который возвращает пользователя по логину. Пользователи будут заранее создаваться в конструкторе.

Так как моя цель - это реализация работы JWT, то я умышленно упрощаю архитектуру приложения, и опускаю такие неважные детали, как добавление новых пользователей и шифрование паролей.
package org.sadtech.example.jwt.server.service;
... ... ... ... ...
@Service
@RequiredArgsConstructor
public class UserService {

    private final List<User> users;

    public UserService() {
        this.users = List.of(
                new User("anton", "1234", "Антон", "Иванов", Collections.singleton(Role.USER)),
                new User("ivan", "12345", "Сергей", "Петров", Collections.singleton(Role.ADMIN))
        );
    }

    public Optional<User> getByLogin(@NonNull String login) {
        return users.stream()
                .filter(user -> login.equals(user.getLogin()))
                .findFirst();
    }

}

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

package org.sadtech.example.jwt.server.domain;
... ... ... ... ...
@Setter
@Getter
public class JwtRequest {

    private String login;
    private String password;

}

Создадим еще один объект JwtResponse, который будет содержать access и refresh токены. Этот объект мы будем возвращать в ответ.

@Getter
@AllArgsConstructor
public class JwtResponse {

    private final String type = "Bearer";
    private String accessToken;
    private String refreshToken;

}

Теперь создадим компонент JwtProvider. Он будет генерировать и валидировать access и refresh токены.

@Slf4j
@Component
public class JwtProvider {

    private final String jwtAccessSecret;
    private final String jwtRefreshSecret;

    public JwtProvider(
            @Value("${jwt.secret.access}") String jwtAccessSecret,
            @Value("${jwt.secret.refresh}") String jwtRefreshSecret
    ) {
        this.jwtAccessSecret = jwtAccessSecret;
        this.jwtRefreshSecret = jwtRefreshSecret;
    }

    public String generateAccessToken(@NonNull User user) {
        final LocalDateTime now = LocalDateTime.now();
        final Instant accessExpirationInstant = now.plusMinutes(5).atZone(ZoneId.systemDefault()).toInstant();
        final Date accessExpiration = Date.from(accessExpirationInstant);
        final String accessToken = Jwts.builder()
                .setSubject(user.getLogin())
                .setExpiration(accessExpiration)
                .signWith(SignatureAlgorithm.HS512, jwtAccessSecret)
                .claim("roles", user.getRoles())
                .claim("firstName", user.getFirstName())
                .compact();
        return accessToken;
    }

    public String generateRefreshToken(@NonNull User user) {
        final LocalDateTime now = LocalDateTime.now();
        final Instant refreshExpirationInstant = now.plusDays(30).atZone(ZoneId.systemDefault()).toInstant();
        final Date refreshExpiration = Date.from(refreshExpirationInstant);
        final String refreshToken = Jwts.builder()
                .setSubject(user.getLogin())
                .setExpiration(refreshExpiration)
                .signWith(SignatureAlgorithm.HS512, jwtRefreshSecret)
                .compact();
        return refreshToken;
    }

    public boolean validateAccessToken(@NonNull String token) {
        return validateToken(token, jwtAccessSecret);
    }

    public boolean validateRefreshToken(@NonNull String token) {
        return validateToken(token, jwtRefreshSecret);
    }

    private boolean validateToken(@NonNull String token, @NonNull String secret) {
        try {
            Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException expEx) {
            log.error("Token expired", expEx);
        } catch (UnsupportedJwtException unsEx) {
            log.error("Unsupported jwt", unsEx);
        } catch (MalformedJwtException mjEx) {
            log.error("Malformed jwt", mjEx);
        } catch (SignatureException sEx) {
            log.error("Invalid signature", sEx);
        } catch (Exception e) {
            log.error("invalid token", e);
        }
        return false;
    }

    public Claims getAccessClaims(@NonNull String token) {
        return getClaims(token, jwtAccessSecret);
    }

    public Claims getRefreshClaims(@NonNull String token) {
        return getClaims(token, jwtRefreshSecret);
    }

    private Claims getClaims(@NonNull String token, @NonNull String secret) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }

}

В конструктор мы передаем секретные ключи, для подписи и валидации токенов.

Почему два секретных ключа?

Один будет использоваться для генерации access токен, а второй для генерации refresh токен. Приложение К будет знать ключ от access токена, так как ему нужно валидировать поступающие ему токены. Но он не будет знать ключ от refresh токен, а значит если Приложение К будет скомпрометировано, то мы просто заменим ключ от access токена, и не придется разлогинивать всех пользователей из-за того что их refresh токены станут не валидны.

С помощью аннотации @Value Spring подставляет значение из файла application.properties. Поэтому нужно записать туда значение ключа:

jwt.secret.access=supermegasecret
jwt.secret.refresh=supermegarefreshsecret

Метод generateAccessToken принимает объект пользователя и генерирует access токен для него. Строки 17-19 отвечают за определение времени жизни токена. В данном случае это пять минут. Библиотека, которая генерирует токены не работает с LocalDateTime, поэтому приходится конвертировать все в старый формат Date.

Строки 20-26 отвечают за непосредственное создание access токена. В токен мы указываем логин пользователя, дату до которой токен валиден, алгоритм шифрования и наши произвольные claims: роли и имя пользователя. Для примера мы добавили в токен информацию об имени пользователя, чтобы на фронте не запрашивать бэкенд каждый раз, когда его нужно вывести.

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

Методы validateAccessToken и validateRefreshToken отвечают за проверку валидности токена. Если токен протух, или подписан неправильно, то в лог запишется соотсветсвующее сообщение, а метод вернет false.

Вы могли заметить, что мы не валидируем пароль пользователя. Для проверки пароля мы создадим отдельный сервис AuthService, который также будет отвечать за получение новых access и refresh токенов взамен протухающим.

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserService userService;
    private final Map<String, String> refreshStorage = new HashMap<>();
    private final JwtProvider jwtProvider;

    public JwtResponse login(@NonNull JwtRequest authRequest) {
        final User user = userService.getByLogin(authRequest.getLogin())
                .orElseThrow(() -> new AuthException("Пользователь не найден"));
        if (user.getPassword().equals(authRequest.getPassword())) {
            final String accessToken = jwtProvider.generateAccessToken(user);
            final String refreshToken = jwtProvider.generateRefreshToken(user);
            refreshStorage.put(user.getLogin(), refreshToken);
            return new JwtResponse(accessToken, refreshToken);
        } else {
            throw new AuthException("Неправильный пароль");
        }
    }

    public JwtResponse getAccessToken(@NonNull String refreshToken) {
        if (jwtProvider.validateRefreshToken(refreshToken)) {
            final Claims claims = jwtProvider.getRefreshClaims(refreshToken);
            final String login = claims.getSubject();
            final String saveRefreshToken = refreshStorage.get(login);
            if (saveRefreshToken != null && saveRefreshToken.equals(refreshToken)) {
                final User user = userService.getByLogin(login)
                        .orElseThrow(() -> new AuthException("Пользователь не найден"));
                final String accessToken = jwtProvider.generateAccessToken(user);
                return new JwtResponse(accessToken, null);
            }
        }
        return new JwtResponse(null, null);
    }

    public JwtResponse refresh(@NonNull String refreshToken) {
        if (jwtProvider.validateRefreshToken(refreshToken)) {
            final Claims claims = jwtProvider.getRefreshClaims(refreshToken);
            final String login = claims.getSubject();
            final String saveRefreshToken = refreshStorage.get(login);
            if (saveRefreshToken != null && saveRefreshToken.equals(refreshToken)) {
                final User user = userService.getByLogin(login)
                        .orElseThrow(() -> new AuthException("Пользователь не найден"));
                final String accessToken = jwtProvider.generateAccessToken(user);
                final String newRefreshToken = jwtProvider.generateRefreshToken(user);
                refreshStorage.put(user.getLogin(), newRefreshToken);
                return new JwtResponse(accessToken, newRefreshToken);
            }
        }
        throw new AuthException("Невалидный JWT токен");
    }

    public JwtAuthentication getAuthInfo() {
        return (JwtAuthentication) SecurityContextHolder.getContext().getAuthentication();
    }

}

Разберем метод login. Сначала мы находим пользователя по логину. Если пользователь найден, и присланный пароль совпадает с паролем пользователя, то передаем объект пользователя в JwtProvider и получаем от него токены. Далее сохраняем выданный refresh токен в мапу refreshStorage и возвращаем объект JwtResponse с токенами. Зачем сохранять refresh токены, я объясню позже.

Переходим к методу getAccessToken, который принимает refresh токен, а возвращает новый access токен. Сначала мы проверяем, что присланный rehresh токен валиден. Если валиден, то получаем claims и оттуда получаем логин пользователя. Далее по логину находим выданный пользователю refresh токен в мапе refreshStorage, и сверяем его с присланным пользователем. Если токены одинаковые, то получаем объект User, который отправляем в JwtProvider и получаем новый access токен, без обновления refresh токена.

Зачем сохранять токены?

Сохранять их не обязательно, но это дает некоторые преимущества. Если каким-то образом злоумышленник заполучит секретный ключ для генерации refresh токенов, он не сможет создавать токены. Потому что ему нужно будет знать время создания токена конкретным пользователем.

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

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

Еще один плюс, вы можете забанить пользователя в системе и отозвать его refresh токен, реализовав удаление токена из хранилища сохраненных и запрет на выдачу новых забаненым пользователям. В другом случае пользователь смог бы выпускать себе новые access токены, пока не протух бы refresh токен.

Теперь создадим контроллер AuthController.

@RestController
@RequestMapping("api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("login")
    public ResponseEntity<JwtResponse> login(@RequestBody JwtRequest authRequest) {
        final JwtResponse token = authService.login(authRequest);
        return ResponseEntity.ok(token);
    }

    @PostMapping("token")
    public ResponseEntity<JwtResponse> getNewAccessToken(@RequestBody RefreshJwtRequest request) {
        final JwtResponse token = authService.getAccessToken(request.getRefreshToken());
        return ResponseEntity.ok(token);
    }

    @PostMapping("refresh")
    public ResponseEntity<JwtResponse> getNewRefreshToken(@RequestBody RefreshJwtRequest request) {
        final JwtResponse token = authService.refresh(request.getRefreshToken());
        return ResponseEntity.ok(token);
    }

}

Тут ничего не обычного.

  • Endpoint /api/auth/login принимает JwtRequest, а возвращает JwtResponse с токенами.
  • Endpont /api/auth/token  принимает RefreshJwtRequest c единственным полем refreshToken и возвращает JwtResponse с новым access токеном.
  • И наконец endpoint /api/auth/refresh  принимает RefreshJwtRequest  и возвращает JwtResponse с новыми токенами.
@Getter
@Setter
public class RefreshJwtRequest {

    public String refreshToken;

}

Также для большой безопасности мы защитим /api/auth/refresh и будем принимать на него запросы только с валидным access токеном. Для этого нам нужно создать класс конфигурации для настройки Spting Security.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtFilter jwtFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/api/auth/login", "/api/auth/token").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterAfter(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

Эндпойнт, который выдает токены по логину и паролю, а также тот, что выдает новый access токен по refresh токену, мы оставляем без авторизации. Остальные ендпойнты будут доступны только авторизированным пользователям (строки 17-19).

Также мы добавляем наш фильтр JwtFilter, который и будет осуществлять авторизацию пользователей (строка 21).

Рассмотрим реализацию JwtFilter.

package org.sadtech.example.jwt.server.filter;
... ... ... ... ...
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {

    private static final String AUTHORIZATION = "Authorization";

    private final JwtProvider jwtProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain fc)
            throws IOException, ServletException {
        final String token = getTokenFromRequest((HttpServletRequest) request);
        if (token != null && jwtProvider.validateAccessToken(token)) {
            final Claims claims = jwtProvider.getAccessClaims(token);
            final JwtAuthentication jwtInfoToken = JwtUtils.generate(claims);
            jwtInfoToken.setAuthenticated(true);
            SecurityContextHolder.getContext().setAuthentication(jwtInfoToken);
        }
        fc.doFilter(request, response);
    }

    private String getTokenFromRequest(HttpServletRequest request) {
        final String bearer = request.getHeader(AUTHORIZATION);
        if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }

}

В 15 строке мы достаем из HTTP запроса значение заголовка Authorization. Далее проверяем, что значение этого заголовка не null и токен валидный (строка 16). После этого достаем claims и генерируем JwtAuthentication. Далее сгенерированный объект аутентификации мы помещаем в SecurityContext.

@Getter
@Setter
public class JwtAuthentication implements Authentication {

    private boolean authenticated;
    private String username;
    private String firstName;
    private Set<Role> roles;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() { return roles; }

    @Override
    public Object getCredentials() { return null; }

    @Override
    public Object getDetails() { return null; }

    @Override
    public Object getPrincipal() { return username; }

    @Override
    public boolean isAuthenticated() { return authenticated; }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        this.authenticated = isAuthenticated;
    }

    @Override
    public String getName() { return firstName; }

}

С сервером авторизации все, он готов.

Проверка работы

Вернемся к нашему проверочному контроллеру. Проверять работу сервера авторизации будем через Postman.

Первым делом получаем по логину и паролю access и refresh токены.

После этого берем access токен и вcтавляем его на вкладке авторизации, выбрав типом "Bearer token".

Далее выполняем запрос и получаем заветный результат.

Также попробуем получить сообщение предназначенное только для админов.

Видим 403 ошибку доступа, все работает как задумано. Если подождать 5 минут, то и по эндпойнту /api/hello/user увидим ту же самую ошибку, так как access токен протух.

Чтобы получить новый access токен отправляем запрос на /api/auth/token. В теле запроса указываем наш refresh токен.

Все работает. А теперь взамен текущего refresh токена получим новые access и refresh токены. Для этого вызовем /api/auth/refresh и передадим наш текущий refresh токен в теле запроса. Также не стоит забывать, что это защищенный метод, поэтому во вкладке Authorization вставляем наш новый access токен.

Отлично, мы получили новую пару токенов. А что если попробовать снова вызвать этот же запрос?

Мы получим ошибку, так как этого токена больше нет в сохраненных.

На этом основная часть закончена. Мы реализовали JWT авторизацию. Теперь вы запросто реализуете приложение клиент, если это необходимо. Нужно только оставьть функционал проверки токенов и авторизацию, и убрать функционал по выдаче новых токенов. Для удобства я сделал одно небольшое приложение клиент для демонстрации.

Склонируйте его себе и запустите. Токены выдает наш сервер авторизации, а приложение клиент может их использовать для доступа к своему API.

На этом у меня все.