Spring Security: работа с JWT токенами

от автора

Всех приветствую! Как вы могли понять из названия, данная статья будет посвящена работе с JWT токенами. И хочется сделать небольшое отступление от темы. Собственно, недавно мои статьи выложили в один телеграм канал про бэкенд, и в подписи к моей статье про аутентификацию через RES, указали что там никаких сессий и куки, а только jwt, сказать что я был удивлён, значит ничего не сказать. Потому что было не много грустновато, что перед тем как запостить статью, они их не читают, но да ладно, JWT будет посвящена эта статья, так что нагоняем =)

Что же такое это наше JWT и с чем его едят?

JWT (Json web token) — это просто строка, которая состоит из 3х частей: 1 — header (алгоритм), 2 — payload (даныне) и 3 — signatere(подпись).

Разберём каждую часть по подробнее потому что пока непонятно, что за алгоритм? что за данные? что за подпись?

  • header (или алгоритм) — это json который закодирован на базе Base64Url (модификация стандартного кодирования Base64, которая оптимизирована для безопасного использования в url-адресах и именах файлов), он описывает алгоритм подписи (например HS256 или RS256) и тип токена (обычно jwt).
    Если его раскодировать, то мы получим json в виде:
    {
    «alg» : «HS256»,
    «typ» : «JWT»
    }
    Он нужен, чтобы получатель понимал, каким алгоритмом нужно проверять подпись.

  • Payload (или данные) — это тоже json который закодирован на базе Base64Url, только он содержит внутри себя claims — утверждения о пользователе или сессии.
    Если мы его раскодируем, то получим что-то такое:
    {
    «sub»: «user1»,
    «role»: «admin»,
    «exp»: 1752480000
    }
    * Но важно отметить, что payload не шифруется, а только кодируется — это значит, что любой может его декодировать через обычную кодировку Base64 и прочитать содержимое. По этому туда нельзя класть пароли и/или секретные данные!

  • Signature (или подпись) — это то, что делает наш токен защищённым от подделки. Это достигается путём склеивания payload и header через точку и подпиской с помощью секретного ключа, который знает только сервер.
    Выглядит это следующим образом:
    HMACSHA256(
    base64UrlEncode(header) + «.» + base64UrlEncode(payload), secret_key
    )
    HMAC-SHA256 — это криптографический код аутентификационных сообщений, который объединяет хэш-функцию SHA-265 с секретным ключом. И он гарантирует целостность и подлинность данных, защищая от подделки при передаче по открытым каналам.

    То есть сначала сервер создаёт токен: формирует header и payload, кодирует их, подписывает секретным ключом — подучает подпись. Затем токен отправляется клиенту (обычно после логина). После этого клиент отправляет токен в каждом запросе (обычно в заголовке Authorization: Bearer <токен>). После получения токена сервером, сервер заново вычисляет подпись исходя из header и payload с тем же самым секретным ключом и сравнивает с подписью которая была в токене который пришёл от клиента.

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

Сравнение session и jwt

Мы теперь знаем и о сессиях, и о jwt. Теперь ради интереса можно их сравнить.
1. Сессии и куки — хранятся на стороне сервера, хотя в то же время jwt хранится на клиенте
2. Масштабировать сессии и куки гораздо тяжелее, нежели jwt, потому что нужен общий Redis
3. Но отозвать сессию и куки можно мгновенно, когда для отзыва jwt нужно подождать определённый промежуток времени, через который он сам станет не валидным.
4. Сессии и куки довольно уязвимы к CSRF атаке, но jwt к ней полностью не уязвим.
5. Сессии и куки лучше использовать для монолитных систем и MPA (Multi-Page Application), а jwt лучше использовать в микросервисах и SPA (Single Page Application)

Генерация секретного ключа

Теперь для создания нашего уникального секретного ключа, нужно запустить код буквально из 3х строк. И результат работы сохранить в application.properties:

//Генерация секретного ключа для алгоритма HMAC-SHA256SecretKey key = Jwts.SIG.HS256.key().build();//Превращение ключа в строку, чтобы можно было сохранить в application.propertiesString base64 = Encoders.BASE64.encode(key.getEncoded());System.out.println(base64);

Этот код можно выполнить в самом основном классе нашего приложения, который помечен аннотацией @SpringBootApplication. В моём случае это класс SecurityCourseApplication.
После запуска приложения (докер тоже должен быть запущен) консоль нам покажет следующее:

получение секретного ключа

получение секретного ключа

В самой нижней строке в консоли мы получили секретный ключ, который теперь нужно сохранить в application.properties и добавить пару новых настроек:

#Сам секретный ключjwt-secret=fdLwz0v0J3UQDrb+DJl2+618BLj24xPq+ZZkU95uUsQ=#Время через короче jwt токен истечёт (в секундах)jwt-expiration=3600000#Обновление истечения срока действия (в секундах)jwt-refresh-expiration=604800000

Ну и теперь тот код из 3х строк для получения секретного ключа можно убрать из-за ненадобности.

Теперь мы можем перейти к java-code для создания и валидации токенов.

JwtService — создание и валидация токенов

Первым делом в пакете sevice, нужно создать новый сервис JwtService который будут использовать фильтр и конфиг.

@Servicepublic class JwtService {    @Value("${jwt-secret}")    private String secretKey;    @Value("${jwt-expiration}")    private long jwtExpiration;    //Внутренние методы    private SecretKey getSigningKey(){        //Превращение секретного ключа в объект SecretKey, которым        //jjwt (java json web token) подписывает и проверяет токены        byte[] keyBytes = Decoders.BASE64.decode(secretKey);        //Возвращаем ключ для HMAC-SHA256 подписи        return Keys.hmacShaKeyFor(keyBytes);    }    //Разбирает токен, проверяет его подпись и достаёт из него все claims (payload)    //Если подпись не совпадает или формат токена не правильный - выбрасывает исключение    //ЭТО ЕДИНСТВЕННОЕ МЕСТО ГДЕ ПРОИСХОДИТ КРИПТОГРАФИЧЕСКАЯ ПРОВЕРКА ТОКЕНА    private Claims extractAllClaims(String token){        return Jwts.parser()                .verifyWith(getSigningKey()) //проверяем секретный ключ                .build()                .parseSignedClaims(token) // !парсим токен и проверяем подпись!                .getPayload(); // получаем payload    }    //Проверяет: истёк ли токен    private boolean isTokenExpired(String token){        //extractAllClaims(token).getExpiration() - возвращает Date, извлечённый из claim'а        // exp токена. (Время когда токен должен стать невалидным)        //.before(new Date()) - возвращает true, если вызывающий объект раньше, чем new Date()        // Если проще, то - «Дата истечения токена раньше, чем сейчас?» если да, значит срок уже прошёл, токен просрочен.        return extractAllClaims(token).getExpiration().before(new Date());    }    //Публичные методы    //Достаёт username из claim'а токена (claim "sub" -> subject)    //Используется фильтром, чтобы понять, за кого выдавать себя запросу, ещё до проверки токена.    public String extractUsername(String token){        return extractAllClaims(token).getSubject();    }    //Генерирует jwt токен подписанный секретным ключом сервера    //В payload кладутся: subject, время создания, время истечения    //и список ролей - это позволит фильтру восстановить права пользователя без обращения к бд.    //Принимает extractClaims - доп произвольные данные для payload    //UserDetails - пользователь для которого создаётся токен    public String generateToken(Map<String, Objects> extractClaims, UserDetails userDetails){        return Jwts.builder()                //Добавление в payload кастомных claims                .claims(extractClaims)                //Установка claim "sub" - что-то вроде идентификатора пользователя                .subject(userDetails.getUsername())                //Установка "iat" - время создания токена                .issuedAt(new Date())                //Установка "exp"- время истечения токена                .expiration(new Date(System.currentTimeMillis() + jwtExpiration))                //Добавляем кастомный claim "roles" со списком прав пользователя                .claim("roles", userDetails.getAuthorities().stream()                        //Достаём строковое название для каждой роли, например: "ROLE_USER"                        .map(GrantedAuthority::getAuthority)                        //собираем все роли в обычный List<String>                        .collect(Collectors.toList()))                //Подписываем токен секретным ключом                .signWith(getSigningKey())                //Собираем всё в финальную строку                .compact();    }    //Генерирует новый jwt токен, без дополнительных claims    //По сути обёртка над методом выше    public String generateToken(UserDetails userDetails){        return generateToken(new HashMap<>(), userDetails);    }    //Проверяем что токен принадлежит именно этому пользователю и срок не истёк    public boolean isTokenValid(String token, UserDetails userDetails){        final String username = extractUsername(token); //получаем username из userDetails        //Проверяем пользователя и токен на истечения срока        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);    }}

Мы создали сервис который будет проверять токен, но теперь нужно реализовать фильтр, который токен будет проходить, и в целом это делается тоже не особо сложно)

JwtAuthenticationFilter

JwtAuthenticationFilter создаётся после JwtServise, потому что он его использует. Фильтр читает токен из заголовка Authorization: Bearer <токен>, валидирует и кладёт аутентификацию в SecurityContext.
Теперь в папке config создадим новый пакет: filter и уже в ней будем реализовывать JwtAuthenticationFilter.

@Component@RequiredArgsConstructorpublic class JwtAuthenticationFilter extends OncePerRequestFilter {    private final JwtService jwtService;    private final UserDetailsServiceImpl userDetailsService;    @Override    protected void doFilterInternal(HttpServletRequest request,                                    HttpServletResponse response,                                    FilterChain filterChain) throws ServletException, IOException{        //Достаём заголовок Authorization        final String authHeader = request.getHeader("Authorization");        //Пропускаем дальше без аутентификации        if(authHeader == null || !authHeader.startsWith("Bearer ")){            filterChain.doFilter(request, response);            return;        }        final String jwt = authHeader.substring(7); // убираем "Bearer "        try{            //Достаём username из токена            final String username = jwtService.extractUsername(jwt);            //Проверяем, что пользователь ещё не аутентифицирован            if(username != null && SecurityContextHolder.getContext().getAuthentication() == null){                //Загружаем пользователя из бд                UserDetails userDetails = userDetailsService.loadUserByUsername(username);                //Проверяем токен на валидность (подпись, срок, совпадение юзера)                if(jwtService.isTokenValid(jwt, userDetails)){                    //Создаём объект аутентификации для Spring Security                    UsernamePasswordAuthenticationToken authToken =                            new UsernamePasswordAuthenticationToken(                                    userDetails,                                    null,                                    userDetails.getAuthorities()                            );                    //Добавляем детали запроса по типу (IP, session id, и тд)                    authToken.setDetails(                            new WebAuthenticationDetailsSource().buildDetails(request)                    );                    //Сохраняем аутентификацию в контекст                    SecurityContextHolder.getContext().setAuthentication(authToken);                }            }        } catch (JwtException e){            //Невалидный токен - не аутентифицирован            // ExceptionTranslationFilter вернёт 401 если эндпоинт защищён            System.out.println("Токен не валидный, ошибка: " + e.getMessage());        }        //Передаём запрос дальше по цепочке        filterChain.doFilter(request, response);    }}

Этот класс должен наследоваться от OncePerRequestFilter, потому что это базовый класс из Spring, который гарантирует однократное выполнение аутентификационной логики за запрос, что является стандартом для всех проектов, которые используют Spring Security.
Теперь для финальной настройки jwt, необходимо немного поменять наш SecurityConfig, а именно метод filterChain:

@Configuration@EnableWebSecurity@RequiredArgsConstructorpublic class SecurityConfig {    private final UserDetailsServiceImpl userDetailsService;    private final PasswordEncoder passwordEncoder;    private final JwtAuthenticationFilter jwtAuthenticationFilter;    @Bean    public AuthenticationProvider authenticationProvider(){        DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);        provider.setPasswordEncoder(passwordEncoder);        return provider;    }    @Bean    public AuthenticationManager authenticationManager(AuthenticationConfiguration config){        return config.getAuthenticationManager();    }    @Bean    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{        http.csrf(                //Отключение защиты от csrf атаки (для тестирования в будущем)                csrf -> csrf.disable())                .authorizeHttpRequests(auth ->                        auth.requestMatchers("/api/auth/**").permitAll()                                .requestMatchers("/api/admin").hasRole("ADMIN")                                .anyRequest().authenticated())                //Без сессий каждый запрос аутентифицируется по токену                .sessionManagement(session ->                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))                .authenticationProvider(authenticationProvider())                //Наш фильтр встаёт перед стандартным фильтром логина                .addFilterBefore(                        jwtAuthenticationFilter,                        UsernamePasswordAuthenticationFilter.class                );        return http.build();    }}

Теперь можно заняться регистрацией и логином. Но перед этим нужно создать пару эндпоинтов и исключение. И хочется малость уточнить, что ProblemDetails — это объект ответа об ошибке, который обычно строится в @RestControllerAdvice, а не внутри самого сервиса. Если сервис будет форматировать HTTP-ответы, то это нарушит принцип единственности и ответственности из SOLID. Так что правильнее будет создать пару своих кастомных эндпоинтов в пакете util.

public record RegisterRequest(String username, String password) {}public record AuthResponse(String accessToken) {}

Теперь создадим исключение в котором будем использовать ProblemDetail и который будет обрабатываться в @RestControllerAdvice:

public class UsernameTakenException extends ErrorResponseException {    private static ProblemDetail asProblem(String username){        ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.CONFLICT);        problemDetail.setTitle("Username taken");        problemDetail.setDetail("Пользователь с именем: '%s' уже существует".formatted(username));        problemDetail.setProperty("username", username);        return problemDetail;    }    public UsernameTakenException(String username){        super(HttpStatus.CONFLICT, asProblem(username), null);    }}

Ну и наконец-то можно заняться сервисом и контроллером, я нас всех поздравляю!

AuhtService и AuthController

@Service@RequiredArgsConstructorpublic class AuthService {    private final UserRepository userRepository;    private final PasswordEncoder passwordEncoder;    private final JwtService jwtService;    private final AuthenticationManager authenticationManager;    //Регистрация пользователя    public AuthResponse register(RegisterRequest request){        //Проверка уникальность его username        if(userRepository.existsByUsername(request.username())){            throw new UsernameTakenException(request.username());        }        //Создание самого пользователя        User user = new User();        user.setUsername(request.username());        user.setPassword(passwordEncoder.encode(request.password()));        user.setRoles(Set.of(Role.USER));        //Сохранение пользователя в бд        userRepository.save(user);        //Возвращаем DTO с accessToken'ом для нового пользователя        return new AuthResponse(jwtService.generateToken(user));    }    //Аутентификация пользователя    public AuthResponse login(LoginRequest request){        //Аутентифицирует пользователя по логину и паролю и выдаёт новый JWT.        //Делегирует саму проверку пароля AuthenticationManager — так мы переиспользуем        //ту же цепочку (DaoAuthenticationProvider + PasswordEncoder), что и формы логина.        //Выбросит BadCredentialsException если логин/пароль неверны        authenticationManager.authenticate(                new UsernamePasswordAuthenticationToken(                        request.username(),                        request.password()                )        );        //Пока не будем выкидывать никаких кастомных исключений, если пользователь не найден        //Представим, что у нас всё хорошо        // ну или просто выкинет throw new NoSuchElementException("No value present); по дефолту        User user = userRepository.findByUsername(request.username()).orElseThrow();        //Возвращаем DTO с accessToken'ом для аутентифицированного пользователя        return new AuthResponse(jwtService.generateToken(user));    }}

В целом я надеюсь в комментариях по коду расписано всё подробно и что за что отвечает. Думаю можем сразу переходить к AuthController =) Но нам нужно удалить наш прошлый SessionAuthController, потому что тут мы перешли на JWT и сессии нам не нужны и у нас для них нет настроек.

@RestController@RequestMapping("/api/auth")@RequiredArgsConstructorpublic class AuthController {    private final AuthService authService;        @PostMapping("/register")    public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request){        return ResponseEntity.ok(authService.register(request));    }        @PostMapping("/login")    public ResponseEntity<AuthResponse> login (@RequestBody LoginRequest request){        return ResponseEntity.ok(authService.login(request));    }}

Теперь всё готово к тестированию! Ниже будут представлены скриншоты с тестами работы нашей программ

Успешная регистрация пользователя

Успешная регистрация пользователя
Успешная аутентификация

Успешная аутентификация

Мы увидели что регистрация и аутентификация проходит без проблем и возвращается код 200 и вместе с accessToken. Но что будет если попробовать сломать аутентификацию?

Попытка зайти на аккаунт с неправильным паролем

Попытка зайти на аккаунт с неправильным паролем

Как мы увидели выше, нам вернулся код 403, и это правильное поведение которое описано ещё в прошлой статье. Но теперь попробуем зарегистрироваться уже с имеющимся username, и мы увидим довольно интересное поведение

Попытка регистрации с занятым username

Попытка регистрации с занятым username

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

Правильный код ответа

Правильный код ответа

Но возникает вопрос, почему так происходит? И ответ довольно простой. Spring пытается отдать 409, но для этого внутри делается запрос на страницу /error. Этот запрос тоже проверяется цепочкой фильтров — а эта страница не разрешена. В итоге безопасность блокирует запрос и возвращает 403 вместо настоящего 409.

Правильное поведение программы

Правильное поведение программы

На скриншоте выше я добавил параметр .requestMatchers("/error").permitAll() чтобы Sptring мог беспрепятственно ходить на url: /error.

Думаю на этом можно закончить данную статью. И хочется немного рассказать о дальнейших планах. Парочка следующих статей будут дополнять данную, потому что есть темы в которые нужно погрузиться по глубже, например в RefreshToken, права доступа, Метод-уровневая безопасность и т.д. Так что это не последняя статья на тему JWT.
В очередной раз благодарен каждому кто дочитал статью, и надеюсь на то, что она была понятной для вас. Критика как обычно приветствуется и будет учитываться в будущем =)

ссылка на оригинал статьи https://habr.com/ru/articles/1059086/