Защита токенов с помощью AES-CBC: просто о важном в Java

Салимжанов Р.Д.

Введение: Зачем это нужно?

Токены — это цифровые ключи, которые подтверждают вашу личность в приложениях. Как и настоящие ключи, их нужно защищать. Представьте, что злоумышленник украл ваш токен — он получит полный доступ к аккаунту.

AES (Advanced Encryption Standard) — это симметричный алгоритм шифрования, который использует один и тот же ключ для шифрования и дешифрования данных. Он работает с блоками фиксированного размера (128 бит) и поддерживает ключи длиной 128, 192 или 256 бит.

CBC (Cipher Block Chaining) — это режим работы AES, который добавляет дополнительную защиту. Каждый блок данных перед шифрованием "смешивается" с результатом шифрования предыдущего блока. Это делает шифр более устойчивым к атакам.

Зачем шифровать токены?

  • Защита от перехвата в куках
  • Предотвращение подделки токенов
  • Соблюдение стандартов безопасности (например, GDPR)

Внедряем шифрование в приложение

Рассмотрим, как интегрировать шифрование токенов с использованием AES в режиме CBC в ваше Spring Boot приложение.

1. Создаем "шифровальщик" (AesEncryptionUtil)

Этот класс — наш цифровой сейф:

Сначала создадим класс AesEncryptionUtil, который будет содержать методы для шифрования и дешифрования данных с использованием AES/CBC/PKCS5Padding.

package com.example.demo.security.utils; import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; public class AesEncryptionUtil { private static final String AES_ALGORITHM = "AES/CBC/PKCS5Padding"; private static final int IV_SIZE = 16; public static String encrypt(String data, String secretKey) throws Exception { if (secretKey == null || secretKey.length() < 16) { throw new IllegalArgumentException("AES_SECRET_KEY must be at least 16 characters long."); } // Генерация случайного IV byte[] iv = new byte[IV_SIZE]; SecureRandom random = new SecureRandom(); random.nextBytes(iv); IvParameterSpec ivSpec = new IvParameterSpec(iv); // Создание ключа SecretKey key = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); // Логирование процесса шифрования System.out.println("Encrypting data with AES key: " + secretKey); // Шифрование Cipher cipher = Cipher.getInstance(AES_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec); byte[] encryptedBytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); // Объединение IV и зашифрованных данных byte[] combined = new byte[iv.length + encryptedBytes.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encryptedBytes, 0, combined, iv.length, encryptedBytes.length); // Логирование результата System.out.println("Encryption successful. Encrypted data size: " + combined.length); return Base64.getEncoder().encodeToString(combined); } public static String decrypt(String encryptedData, String secretKey) throws Exception { if (secretKey == null || secretKey.length() < 16) { throw new IllegalArgumentException("AES_SECRET_KEY must be at least 16 characters long."); } // Декодирование Base64 byte[] combined = Base64.getDecoder().decode(encryptedData); // Извлечение IV и зашифрованных данных byte[] iv = new byte[IV_SIZE]; byte[] encryptedBytes = new byte[combined.length - IV_SIZE]; System.arraycopy(combined, 0, iv, 0, IV_SIZE); System.arraycopy(combined, IV_SIZE, encryptedBytes, 0, encryptedBytes.length); // Создание ключа SecretKey key = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); // Дешифрование Cipher cipher = Cipher.getInstance(AES_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv)); byte[] decryptedBytes = cipher.doFinal(encryptedBytes); // Логирование результата дешифрования System.out.println("Decryption successful."); return new String(decryptedBytes, StandardCharsets.UTF_8); } }

Представь, что у тебя есть секретное письмо, которое нужно спрятать в сейф.

Класс AesEncryptionUtil — это твой робот-шифровальщик, который умеет делать две вещи:

Запираем письмо в сейф (шифрование)

Шаг 1: Проверяем ключ

Робот говорит: «Дайте мне секретный ключ длиной минимум 16 символов!»

Вместо того чтобы передавать ключ в метод, получаем его из переменной окружения AES_SECRET_KEY

Про файл .env я рассказывал в одной из своих статей

Защита токенов с помощью AES-CBC: просто о важном в Java

Шаг 2: Генерируем «соль» (IV)

Робот берет 16 случайных кубиков (IV — Initialization Vector).

Это как посыпать письмо солью перед тем, как его запечатать — так сейф будет уникальным.

Шаг 3: Создаем замок (ключ)

Робот превращает ваш ключ в специальный «замок» для сейфа.

Шаг 4: Шифруем

Письмо (токен) нарезается на кусочки, смешивается с «солью» и запирается в сейф.

Робот использует алгоритм AES-CBC — это как сложный пазл, где каждый кусочек зависит от предыдущего.

Шаг 5: Упаковываем

Сейф (IV + зашифрованные данные) кодируется в Base64 — это как перевод текста на язык роботов, чтобы его можно было безопасно передать.

Для расшифровки процесса мы выполняем обратные шаги: декодируем Base64, извлекаем IV и зашифрованные данные, и затем расшифровываем сообщение с использованием того же секретного ключа.

2. Настраиваем безопасность (SecurityConfig)

Представьте, что у вас есть здание с разными зонами доступа:

  • Главный вход открыт для всех.
  • Вход в комнату администратора (/admin/**) только для пользователей с ролью ROLE_ADMIN.
  • Вход в пользовательскую зону (/user/**) только для пользователей с ROLE_USER.
  • Везде нужны пропуска (аутентификация).

Это и делает SecurityFilterChain — управляет доступом пользователей к ресурсам, проверяя их права.

package com.example.demo.config; import com.example.demo.security.CustomOAuth2UserService; import com.example.demo.security.utils.AesEncryptionUtil; import jakarta.servlet.http.Cookie; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService) { this.customOAuth2UserService = customOAuth2UserService; System.out.println("SecurityConfig initialized"); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/", "/index").permitAll() .requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN") .requestMatchers("/user/**").hasAuthority("ROLE_USER") .anyRequest().authenticated() ) .oauth2Login(oauth2Login -> oauth2Login .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint .oidcUserService(customOAuth2UserService) ) .successHandler((request, response, authentication) -> { // Логирование токена перед шифрованием String token = authentication.getCredentials().toString(); System.out.println("Token received: " + token); String encryptedToken = null; try { // Логирование секретного ключа String secretKey = System.getenv("AES_SECRET_KEY"); if (secretKey == null) { System.out.println("AES_SECRET_KEY is not set in environment variables!"); } else { System.out.println("AES_SECRET_KEY found: " + secretKey); } encryptedToken = AesEncryptionUtil.encrypt(token, secretKey); } catch (Exception e) { System.out.println("Encryption failed: " + e.getMessage()); throw new RuntimeException("Encryption failed", e); } // Логирование зашифрованного токена System.out.println("Encrypted token: " + encryptedToken); Cookie cookie = new Cookie("encrypted_token", encryptedToken); cookie.setHttpOnly(true); cookie.setSecure(true); // Только для HTTPS cookie.setPath("/"); cookie.setMaxAge(7 * 24 * 60 * 60); // 7 дней response.addCookie(cookie); // Логирование успешного завершения System.out.println("Encrypted token set in cookie."); response.sendRedirect("/"); }) ) .logout(logout -> logout .deleteCookies("encrypted_token") .invalidateHttpSession(true) .clearAuthentication(true) .logoutSuccessUrl("/") ) .exceptionHandling(exception -> exception .accessDeniedPage("/access-denied") ); return http.build(); } }

Шаг 1: Получаем токен

После успешного входа Spring передает нам токен аутентификации:

String token = authentication.getCredentials().toString();

Шаг 2: Получаем секретный ключ

Шифровать будем с помощью специального ключа. Мы берем ключ из переменной окружения AES_SECRET_KEY.

String secretKey = System.getenv("AES_SECRET_KEY");

if (secretKey == null) {

System.out.println("AES_SECRET_KEY is not set in environment variables!");

}

Шаг 3: Шифруем токен

Теперь даже если кто-то украдет cookie, он не сможет расшифровать токен без секретного ключа.

String encryptedToken = AesEncryptionUtil.encrypt(token, secretKey);

System.out.println("Encrypted token: " + encryptedToken);

Шаг 4: Сохраняем в cookie

Мы создаем cookie с зашифрованным токеном, которая:

1)HttpOnly—браузер не может прочитать её через JavaScript (защита от XSS).

2)Secure—отправляется только через HTTPS.

3)MaxAge—живёт 7 дней.

Cookie cookie = new Cookie("encrypted_token", encryptedToken);

cookie.setHttpOnly(true);

cookie.setSecure(true); // Только HTTPS

cookie.setPath("/");

cookie.setMaxAge(7 * 24 * 60 * 60); // 7 дней

response.addCookie(cookie);

System.out.println("Encrypted token set in cookie.");

После этого происходит редирект на главную страницу:

response.sendRedirect("/");

Проверка

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

Защита токенов с помощью AES-CBC: просто о важном в Java

Давайте разберем каждую строку:

Token received:

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

AES_SECRET_KEY found: 1234567890abcdef

  • Здесь сообщается, что был найден секретный ключ для шифрования AES. В данном случае ключ имеет значение 1234567890abcdef. Обратите внимание, что использование простых и предсказуемых ключей (как в этом примере) не является безопасной практикой.

Encrypting data with AES key: 1234567890abcdef

  • Эта строка указывает на то, что данные (в данном случае токен) шифруются с использованием найденного ключа AES.

Encryption successful. Encrypted data size: 32

  • Сообщение подтверждает, что шифрование прошло успешно, и указывает размер зашифрованных данных (в байтах). В данном случае размер составляет 32 байта.

Encrypted token: 4bJ7ok9Vo+/NUwPoxqfBLlev33ne3Gxulfib9szmL4M=

  • Это зашифрованный токен, представленный в виде строки, закодированной в Base64. Эта строка может быть использована для передачи или хранения зашифрованного токена.

Encrypted token set in cookie.

  • В этой строке сообщается, что зашифрованный токен был установлен в cookie. Это означает, что зашифрованные данные будут храниться в cookie браузера пользователя, что позволяет использовать их для аутентификации или авторизации при последующих запросах.

Вывод: Это действительно необходимо?

Почему стоит потерпеть сложности?

  1. Защита от атак: Даже при утечке куки злоумышленник не получит настоящий токен
  2. Соблюдение стандартов: Требуется по PCI DSS, HIPAA и другим регуляторам
  3. Доверие пользователей: Ваши клиенты уверены в безопасности

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

СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ

1) Java AES Encryption and Decryption// [электронный ресурс]. URL: https://www.baeldung.com/java-aes-encryption-decryption (дата обращения 08.02.2025).

2) AES шифрование и Android клиент // [электронный ресурс]. URL: https://habr.com/ru/companies/rambler_and_co/articles/279835/ (дата обращения 09.02.2025).

3) аутентификация при помощи Spring Boot // [электронный ресурс]. URL: https://habr.com/ru/articles/784508/ (дата обращения 08.02.2025).

1
Начать дискуссию