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

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

· 15 минуты на чтение
Реализация JWT в Spring Boot

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

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

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

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

  • Запрос с логином и паролем. Клиент (чаще всего это фронтенд) отправляет запрос с объектом, содержащим логин и пароль.
  • Генерация токенов. Если введённый пароль корректен, сервер генерирует access- и refresh-токены и возвращает их клиенту.
  • Использование access-токена. Клиент использует access-токен для взаимодействия с API.
  • Обновление access-токена. Через пять минут, когда срок действия access-токена истекает, клиент отправляет refresh-токен и получает новый access-токен. Этот процесс повторяется до тех пор, пока не истечёт срок действия refresh-токена.
  • Продление refresh-токена. Refresh-токен выдаётся на 30 дней. Примерно на 25-29 день клиент отправляет запрос с действительными access- и refresh-токенами и получает новую пару токенов.

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

Спонсор поста

Используемые версии

Java: 17
SpringBoot: 2.7.0
jsonwebtoken: 0.11.5
jaxb-api: 2.3.1

История изменений

21.06.2022: Актуализировал версию спрингбута и перевел проект на 17 Java. Удалил все устаревшие классы и методы, заменив их на аналогичные современные варианты. Детализировал некоторые моменты в статье.

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

😺
Проект на GitHub: jwt-server-spring

Добавим в pom.xml необходимые зависимости для генерации JWT-токенов.

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.1</version>
</dependency>

Актуальные версии в Maven Central: jjwt-api, jjwt-impl, jjwt-jackson, jaxb-api

Создание API и ролей

Сначала создадим простой API с двумя ролями пользователей и двумя эндпойнтами.

  • /api/hello/user – будет выводить приветственное сообщение для пользователей с ролью "USER"
  • /api/hello/admin – будет выводить приветственное сообщение для пользователей с ролью "ADMIN".
package dev.struchkov.example.jwt.server.controller; import lombok.RequiredArgsConstructor; import org.sadtech.example.jwt.server.domain.JwtAuthentication; import org.sadtech.example.jwt.server.service.AuthService; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
@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 dev.struchkov.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 dev.struchkov.example.jwt.server.domain; import lombok.RequiredArgsConstructor; import org.springframework.security.core.GrantedAuthority;
@RequiredArgsConstructor public enum Role implements GrantedAuthority { ADMIN("ADMIN"), USER("USER"); private final String vale; @Override public String getAuthority() { return vale; } }

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

package dev.struchkov.example.jwt.server.service;
import lombok.NonNull; import lombok.RequiredArgsConstructor; import dev.struchkov.example.jwt.server.domain.Role; import dev.struchkov.example.jwt.server.domain.User; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.List; import java.util.Optional;
@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-токена. Он содержит поля login и password.

package dev.struchkov.example.jwt.server.domain;

@Setter
@Getter
public class JwtRequest {

    private String login;
    private String password;

}

Создадим еще один объект JwtResponse. Этот класс представляет ответ, возвращаемый пользователю, и содержит access- и refresh-токены:

package dev.struchkov.example.jwt.server.domain;

@Getter
@AllArgsConstructor
public class JwtResponse {

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

}

Компонент JwtProvider

Создадим компонент JwtProvider, отвечающий за генерацию и валидацию access- и refresh-токенов.

package dev.struchkov.example.jwt.server.service;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SignatureException; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import dev.struchkov.example.jwt.server.domain.User; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.security.Key; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Date;
@Slf4j @Component public class JwtProvider { private final SecretKey jwtAccessSecret; private final SecretKey jwtRefreshSecret; public JwtProvider( @Value("${jwt.secret.access}") String jwtAccessSecret, @Value("${jwt.secret.refresh}") String jwtRefreshSecret ) { this.jwtAccessSecret = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtAccessSecret)); this.jwtRefreshSecret = Keys.hmacShaKeyFor(Decoders.BASE64.decode(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); return Jwts.builder() .setSubject(user.getLogin()) .setExpiration(accessExpiration) .signWith(jwtAccessSecret) .claim("roles", user.getRoles()) .claim("firstName", user.getFirstName()) .compact(); } 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); return Jwts.builder() .setSubject(user.getLogin()) .setExpiration(refreshExpiration) .signWith(jwtRefreshSecret) .compact(); } public boolean validateAccessToken(@NonNull String accessToken) { return validateToken(accessToken, jwtAccessSecret); } public boolean validateRefreshToken(@NonNull String refreshToken) { return validateToken(refreshToken, jwtRefreshSecret); } private boolean validateToken(@NonNull String token, @NonNull Key secret) { try { Jwts.parserBuilder() .setSigningKey(secret) .build() .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 Key secret) { return Jwts.parserBuilder() .setSigningKey(secret) .build() .parseClaimsJws(token) .getBody(); } }

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

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

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

Используя аннотацию @Value, Spring автоматически подставляет значения из файла application.properties. Укажите ключи в формате Base64 в этом файле:

jwt.secret.access=qBTmv4oXFFR2GwjexDJ4t6fsIUIUhhXqlktXjXdkcyygs8nPVEwMfo29VDRRepYDVV5IkIxBMzr7OEHXEHd37w==
jwt.secret.refresh=zL1HB3Pch05Avfynovxrf/kpF9O2m4NCWKJUjEp27s9J2jEG3ifiKCGylaZ8fDeoONSTJP/wAzKawB8F9rOMNg==

Как получить ключи?

Cамым надежным способом является использование метода Keys.secretKeyFor(), который генерирует безопасные ключи. Однако метод возвращает объект SecretKey, который нужно преобразовать в текстовую строку для записи в application.properties. Это можно сделать, вызвав SecretKey.getEncoded() и преобразовав массив байт в Base64. Класс GenerateKeys выполнит эту операцию, и, запустив его, вы получите два ключа.

В конструкторе JwtProvider происходит обратная операция: Base64 преобразуется обратно в массив байт, после чего с помощью Keys.hmacShaKeyFor() создается объект SecretKey.

Генерация и валидация токенов

Метод generateAccessToken принимает объект пользователя и генерирует для него access-токен. Строки 17–19 задают время жизни токена (в данном случае — 5 минут). Библиотека для генерации токенов работает с Date, поэтому время нужно конвертировать в этот формат из LocalDateTime.

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

Метод generateRefreshToken работает аналогично, но без claims и с более длительным сроком действия.

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

Валидация пароля и создание AuthService

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

package dev.struchkov.example.jwt.server.service;
import io.jsonwebtoken.Claims; import lombok.NonNull; import lombok.RequiredArgsConstructor; import dev.struchkov.example.jwt.server.domain.JwtAuthentication; import dev.struchkov.example.jwt.server.domain.JwtRequest; import dev.struchkov.example.jwt.server.domain.JwtResponse; import dev.struchkov.example.jwt.server.domain.User; import dev.struchkov.example.jwt.server.exception.AuthException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map;
@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 с токенами.

Метод getAccessToken принимает refresh-токен и возвращает новый access-токен. Он выполняет следующие шаги:

  • Проверяет, что присланный refresh-токен является валидным.
  • Получает из claims логин пользователя.
  • Находит сохранённый refresh-токен в refreshStorage и сверяет его с токеном, присланным пользователем.
  • Если токены совпадают, находит объект User, передаёт его в JwtProvider и генерирует новый access-токен. Refresh-токен при этом не обновляется.
Зачем сохранять токены?

Хотя сохранение refresh-токенов не является обязательным, оно повышает безопасность системы:

  • В случае компрометации секретного ключа для генерации refresh-токенов злоумышленник не сможет создать валидный токен, так как ему нужно будет знать точное время его создания. Без хранилища refresh-токенов злоумышленник мог бы сгенерировать токен для любого пользователя и получить по нему access-токены.
  • Если у пользователя есть несколько клиентов API (например, сайт и мобильное приложение), для каждого нужно будет хранить отдельный refresh-токен.
  • Хранение токенов позволяет отзывать доступ у забаненных пользователей. Если пользователь заблокирован, можно удалить его refresh-токен из хранилища, что заблокирует возможность генерировать новые access-токены до истечения срока действия refresh-токена.
Спонсор поста 3

Создание контроллера AuthController

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

package dev.struchkov.example.jwt.server.controller;
import dev.struchkov.example.jwt.server.domain.JwtResponse; import dev.struchkov.example.jwt.server.domain.RefreshJwtRequest; import dev.struchkov.example.jwt.server.service.AuthService; import lombok.RequiredArgsConstructor; import dev.struchkov.example.jwt.server.domain.JwtRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
@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); } }

Контроллер AuthController обрабатывает три основных эндпойнта:

  • POST /api/auth/login: принимает JwtRequest и возвращает JwtResponse с токенами access и refresh.
  • POST /api/auth/token: принимает RefreshJwtRequest с полем refreshToken и возвращает JwtResponse с новым access-токеном.
  • POST /api/auth/refresh: принимает RefreshJwtRequest и возвращает JwtResponse с новыми access и refresh токенами.
@Getter
@Setter
public class RefreshJwtRequest {

    public String refreshToken;

}

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

package dev.struchkov.example.jwt.server.config;

import dev.struchkov.example.jwt.server.filter.JwtFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

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

    private final JwtFilter jwtFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests(
                        authz -> authz
                                .antMatchers("/api/auth/login", "/api/auth/token").permitAll()
                                .anyRequest().authenticated()
                                .and()
                                .addFilterAfter(jwtFilter, UsernamePasswordAuthenticationFilter.class)
                ).build();
    }

}

Эндпойнты для получения токенов (/api/auth/login и /api/auth/token) останутся общедоступными. Остальные эндпойнты будут доступны только аутентифицированным пользователям (см. строки 31–32).

Кроме того, подключим фильтр JwtFilter, который будет проверять и аутентифицировать пользователей по токенам (строка 34). Ниже приведена реализация JwtFilter.

package dev.struchkov.example.jwt.server.filter;
import dev.struchkov.example.jwt.server.service.JwtProvider; import dev.struchkov.example.jwt.server.service.JwtUtils; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import dev.struchkov.example.jwt.server.domain.JwtAuthentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.GenericFilterBean; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import java.io.IOException;
@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; } }

В JwtFilter происходит следующее:

  1. Извлекается значение заголовка Authorization из HTTP-запроса.
  2. Проверяется, что токен не равен null и что он является валидным.
  3. Если токен прошел проверку, извлекаются его claims, и на их основе создается объект аутентификации JwtAuthentication с помощью метода JwtUtils.generate.
  4. Сформированный объект JwtAuthentication помещается в SecurityContext для дальнейшего использования в приложении.

Метод JwtUtils.generate используется для создания объекта JwtAuthentication из токена. Этот объект содержит информацию об аутентификации пользователя, включая его роли и имя.

package dev.struchkov.example.jwt.server.service;

import io.jsonwebtoken.Claims;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import dev.struchkov.example.jwt.server.domain.JwtAuthentication;
import dev.struchkov.example.jwt.server.domain.Role;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class JwtUtils {

    public static JwtAuthentication generate(Claims claims) {
        final JwtAuthentication jwtInfoToken = new JwtAuthentication();
        jwtInfoToken.setRoles(getRoles(claims));
        jwtInfoToken.setFirstName(claims.get("firstName", String.class));
        jwtInfoToken.setUsername(claims.getSubject());
        return jwtInfoToken;
    }

    private static Set<Role> getRoles(Claims claims) {
        final List<String> roles = claims.get("roles", List.class);
        return roles.stream()
                .map(Role::valueOf)
                .collect(Collectors.toSet());
    }

}

Метод generate выполняет следующие действия:

  1. Создает новый объект JwtAuthentication.
  2. Заполняет его ролями пользователя с помощью метода getRoles, который извлекает список ролей из claims, преобразует их в перечисления Role и сохраняет в объекте JwtAuthentication.
  3. Извлекает из claims имя пользователя (firstName) и добавляет его в JwtAuthentication.
  4. Устанавливает username, используя claims.getSubject().

Метод getRoles извлекает роли из claims и преобразует их в множество объектов Role.

Класс JwtAuthentication и его назначение

Класс JwtAuthentication — это специальный объект, реализующий интерфейс Authentication из Spring Security. Он используется для хранения информации о текущей аутентификации пользователя и передачи этих данных в контекст безопасности приложения SecurityContext.

Реализуя интерфейс Authentication, JwtAuthentication становится совместимым с SecurityContext, что позволяет Spring Security автоматически проверять, аутентифицирован ли пользователь и имеет ли он соответствующие права для доступа к защищенным ресурсам.

package dev.struchkov.example.jwt.server.domain;
import lombok.Getter; import lombok.Setter; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import java.util.Collection; import java.util.Set;
@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; } }
  1. Свойства JwtAuthentication:
    • authenticated: флаг, указывающий, аутентифицирован ли пользователь.
    • username: имя пользователя (обычно логин), которое служит уникальным идентификатором.
    • firstName: имя пользователя, которое может использоваться в интерфейсе для персонализации.
    • roles: набор ролей пользователя (в данном примере — Set<Role>), который определяет права доступа пользователя в системе.
  2. Методы интерфейса Authentication:
    • getAuthorities(): возвращает коллекцию прав доступа (GrantedAuthority), которые соответствуют ролям пользователя. Эти права можно использовать для ограничения доступа к защищенным ресурсам.
    • getPrincipal(): возвращает идентификатор пользователя, в данном случае — username.
    • isAuthenticated() и setAuthenticated(boolean): проверяют и задают статус аутентификации пользователя. Метод setAuthenticated применяется при установке объекта аутентификации в контексте безопасности.
    • getName(): возвращает отображаемое имя пользователя, которое в данном случае соответствует firstName.
  3. Неиспользуемые методы:
    • getCredentials() и getDetails() возвращают null, так как они не нужны для работы с JWT. В JWT-аутентификации нет пароля на каждом запросе, а используется токен, который уже содержит всю необходимую информацию.

С сервером аутентификации все, он полностью готов.

Проверка работы аутентификационного сервера

Для проверки работы сервера аутентификации будем использовать Postman.

⛷️
Коллекция запросов для Postman находится в папке проекта — resources.

Выполните запрос на получение токенов, отправив логин и пароль. В ответе вы получите access- и refresh-токены.

Скопируйте access-токен из полученного ответа и вставьте его во вкладке Authorization в Postman, выбрав тип "Bearer token".

Теперь выполните запрос к защищенному эндпойнту. Если токен действителен и у пользователя есть соответствующие права, сервер вернет запрашиваемые данные.

Для проверки прав доступа отправьте запрос к эндпойнту, предназначенному только для администраторов. При отсутствии прав доступа сервер вернет ошибку 403.

Через 5 минут access-токен истечет. Если отправить запрос к обычному защищенному эндпойнту после истечения срока, сервер также вернет ошибку 403.

Чтобы получить новый access-токен, отправьте запрос на /api/auth/token, указав в теле запроса refresh-токен. Сервер вернет новый access-токен.

Отправьте запрос на /api/auth/refresh, указав текущий refresh-токен в теле запроса. Так как этот эндпойнт защищен, не забудьте вставить access-токен во вкладке Authorization. Сервер вернет новую пару токенов.

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

На этом настройка и проверка завершены. Мы реализовали JWT-аутентификацию, где токены выдаются и обновляются аутентификационным сервером.

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

Резюмирую

В этой статье мы реализовали аутентификацию в Spring Boot-сервисе с использованием JWT. Вы узнали, как настраивать сервер для выдачи токенов, а также как валидировать токены в других сервисах. Такой подход, позволяющий разграничить функции выдачи и валидации токенов, полезен в микросервисной архитектуре, так как уменьшает количество запросов к серверу аутентификации.

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇