Всех приветствую!

В прошлый раз я не указал важный аспект: на данном этапе проект - это монолит. Однако архитектура закладывается с учетом того, что в будущем систему можно будет разнести на разные серверы.

Хочу поблагодарить пользователей за конструктивную критику в комментариях. В частности, @aleksandy верно подметил использование LocalDateTime вместо Instant. Повторюсь: на этапе прототипирования это осознанный выбор для удобства отладки и прямого контроля данных в БД "глазами", без конвертации часовых поясов в уме. Переход на Instant - это стандарт для продакшена, и он заложен в план развития.

Также коснулись темы equals() и hashCode(). В текущей реализации я использую getClass(). Знаю, что это не учитывает работу Hibernate Proxy (когда вместо реального объекта мы получаем его обертку-пустышку для ленивой загрузки). На текущей "плоской" структуре данных это не критично, но как только мы перейдем к сложным связям, будет рефакторинг этих методов через instanceof, чтобы избежать ошибок сравнения.

Итак, едем дальше

Для авторизации мы будем использовать классическую связку:

Access токен: живет 15 минут, нужен для доступа к эндпоинтам.
Refresh токен: живет 30 дней, используется только для получения новой пары токенов.

Access токен мы не будем хранить в базе, так как он самодостаточен, а вот Refresh токен будем записывать в специальную таблицу, чтобы иметь возможность управлять сессиями пользователя и отзывать их при необходимости.

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "refresh_tokens")
public class RefreshTokenEntity extends UserProfileOwnerManyToOne {

    @Column(name = "refresh_token", nullable = false, columnDefinition = "TEXT")
    private String refreshToken;

    @Column(name = "revoked", nullable = false)
    private boolean revoked;

    @Column(name = "device_id", nullable = false, length = 255)
    private String deviceId;
}

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

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

@Configuration
@Getter
public class JwtConfig {

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

    @Value("${jwt.access-expiration}")
    private long accessExpiration;

    @Value("${jwt.refresh-expiration}")
    private long refreshExpiration;

    public SecretKey getSecretKey() {
        byte[] decodedKey = Base64.getDecoder().decode(secretKey);
        return new SecretKeySpec(decodedKey, "HmacSHA256");
    }
}

Прежде чем переходить к логике валидации, создадим репозиторий. Помимо стандартного поиска, я добавил метод isTokenRevoked. Использование COALESCE в запросе позволяет нам безопасно проверять статус токена: если записи нет в базе - мы по умолчанию считаем такую сессию отозванной.

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, UUID> {

    Optional<RefreshTokenEntity> findByRefreshToken(String refreshToken);

    Optional<RefreshTokenEntity> findByRefreshTokenAndDeviceId(String refreshToken, String deviceId);

    List<RefreshTokenEntity> findByDeviceId(String deviceId);

    @Query("SELECT COALESCE(t.revoked, true) FROM RefreshTokenEntity t WHERE t.refreshToken = :refreshToken")
    boolean isTokenRevoked(@Param("refreshToken") String refreshToken);
}

И, наконец, TokenValidationService это "мозг" нашей системы проверки. Здесь реализовано важное разделение ответственности:

  • Метод isTokenValid выполняет быструю проверку JWT: валидность подписи и срок годности. Это "дешевая" операция, которая не требует обращения к базе данных.

  • Метод isRefreshTokenInDbValid - это наша страховка. Даже если токен "математически" верен, мы проверяем его наличие в БД и флаг revoked. Это позволяет нам мгновенно аннулировать любую сессию пользователя.

  • Метод extractUserUuid достает идентификатор профиля из Subject токена. В дальнейшем это позволит нам автоматически определять, какой пользователь совершает запрос, просто на основе его токена

@Service
@RequiredArgsConstructor
public class TokenValidationService {

    private final RefreshTokenRepository refreshTokenRepository;
    private final JwtConfig jwtConfig;

    private Claims getAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(jwtConfig.getSecretKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public boolean isTokenValid(String token) {
        try {
            getAllClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    public boolean isRefreshTokenInDbValid(String token) {
        return refreshTokenRepository.findByRefreshToken(token)
                .map(t -> !t.isRevoked())
                .orElse(false);
    }

    public String extractUserUuid(String token) {
        return getAllClaims(token).getSubject();
    }
}

Связующее звено между HTTP-запросом и безопасностью это JwtAuthenticationFilter.

Этот компонент перехватывает каждый запрос, извлекает токен из заголовка Authorization и "представляется" системе Spring Security.

Здесь мы обрабатываем исключения токена это истечение срока действия или неверная подпись и возвращаем 401 Unauthorized. Это критически важно для фронтенда, чтобы он понимал: "Access-токен протух, пора использовать Refresh".

Метод shouldNotFilter намеренно пропускает запросы к эндпоинтам авторизации и статике, правда, ее еще нет, чтобы не пытаться валидировать токены там, где пользователь еще только пытается войти в систему.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenValidationService tokenValidationService;

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws ServletException, IOException {

        String accessToken = extractAccessToken(request);

        if (accessToken != null) {
            try {
                String userUuid = tokenValidationService.extractUserUuid(accessToken);

                var authentication = new UsernamePasswordAuthenticationToken(
                        userUuid,
                        null,
                        Collections.emptyList()
                );
                SecurityContextHolder.getContext().setAuthentication(authentication);

            } catch (ExpiredJwtException e) {
                handleException(response, "Access token is expired");
                return;
            } catch (JwtException | IllegalArgumentException e) {
                handleException(response, "Invalid JWT token");
                return;
            }
        }
        filterChain.doFilter(request, response);
    }

    private void handleException(HttpServletResponse response, String message) throws IOException {
        SecurityContextHolder.clearContext();
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"" + message + "\"}");
    }

    private String extractAccessToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    @Override
    protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
        String path = request.getServletPath();
        return path.startsWith("/api/v1/auth/");
    }
}

И финал это SecurityConfig. Здесь всё управление доступом:

  • Вырубаем сессии (STATELESS), так как у нас JWT.

  • Разрешаем всем стучаться в /api/v1/auth/**, чтобы люди могли банально залогиниться.

  • Всё остальное наглухо закрываем авторизацией.

  • Статику (картинки, шрифты) выносим в ignoring, чтобы не прогонять их через фильтры безопасности, серверу, собственно, и так есть чем заняться.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("index.html").permitAll()
                        .requestMatchers("/api/v1/auth/**").permitAll()
                        .anyRequest().authenticated()
                );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOriginPatterns(Arrays.asList("*"));
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(Arrays.asList("*"));
        config.setExposedHeaders(Arrays.asList("Authorization", "Content-Disposition"));
        config.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Bean
    public HttpFirewall allowUrlEncodedHttpFirewall() {
        StrictHttpFirewall firewall = new StrictHttpFirewall();
        firewall.setAllowUrlEncodedPercent(true);
        firewall.setAllowUrlEncodedPeriod(true);
        firewall.setAllowSemicolon(true);
        return firewall;
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> {
            web.httpFirewall(allowUrlEncodedHttpFirewall());
            web.ignoring()
                    .requestMatchers(
                            "/assets/**",
                            "/css/**",
                            "/js/**",
                            "/images/**",
                            "/fonts/**",
                            "/favicon.ico"
                    );
        };
    }
}

TokenService отвечает только за генерацию строк. Это чистая функция: на вход получаем UUID пользователя, на выходе - подписанный JWT.

Здесь нет работы с базой данных, только формирование структуры токена:

  • Access - выставляем время жизни на 15 минут.

  • Refresh - на 30 дней.

@Service
@RequiredArgsConstructor
public class TokenService {

    private final JwtConfig jwtConfig;

     public String generateAccessToken(UserProfileEntity user) {
        return Jwts.builder()
                .setSubject(user.getUuid().toString())
                .setExpiration(new Date(System.currentTimeMillis() + jwtConfig.getAccessExpiration()))
                .signWith(jwtConfig.getSecretKey())
                .compact();
    }

    public String generateRefreshToken(UserProfileEntity user) {
        return Jwts.builder()
                .setSubject(user.getUuid().toString())
                .setExpiration(new Date(System.currentTimeMillis() + jwtConfig.getRefreshExpiration()))
                .signWith(jwtConfig.getSecretKey())
                .compact();
    }
}

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

Всем спасибо за внимание!
ссылка на гит

Комментарии (4)


  1. aleksandy
    06.02.2026 07:09

    TL/DR. Новички, не делайте так никогда. Автор, мягко говоря, некомпетентен в вопросах, о которых рассуждает.

    class JwtConfig

    @ConfigurationProperties.

    Использование COALESCE в запросе позволяет нам безопасно проверять статус токена: если записи нет в базе - мы по умолчанию считаем такую сессию отозванной.

    Вы бы, прежде, чем делать подобные заявления хоть попробовали запросы повыполнять в БД. COALESCE конкретно в данном запросе - ненужная хрень, т.к. поле nullable = false. Если записи, удовлетворяющей условию отбора нет, то будет возвращён пустой набор.

    Как в этом случае поведёт себя spring-data-репозиторий, я, честно, не помню, но по JPA специфицикации getSingleResult() при отсутствующей записи бросит NoResultException. Spring-data, скорее всего, как-то обрабатывает этот момент, но вряд ли на пустом наборе он вернёт что-либо, отличающееся от значения по умолчанию для результирующего типа. В данном случае, boolean, а, стало быть, по умолчанию вернётся false, т.о. метод isTokenRevoked() вернёт абсолютно противоположное ожидаемому значение.

    JwtAuthenticationFilter extends OncePerRequestFilter ... извлекает токен из заголовка Authorization и "представляется" системе Spring Security

    Раз уж затащил этого ~монстра~ Spring Security, то, емнип, там уже всё "из коробки" готово для работы jwt, надо только законфигурить, без какого-либо написания своих компонентов вообще.

    И начните уже писать тесты.


    1. rootCore Автор
      06.02.2026 07:09

      Важное уточнение по коду:

      Некоторые решения в этой части, в прошлой, и в последующих, например, использование @Value вместо @ConfigurationProperties или отсутствие тестов сделаны сознательно для упрощения разработки и наглядности. Сейчас моя цель собрать и зафиксировать рабочий "скелет" системы. Как только основная логика будет готова, я планирую отдельный этап рефакторинга, где буду закрывать этот "технический долг": переводить конфиги на типизированные классы, исправлять мелкие недочеты в JPA-запросах и покрывать всё тестами.

      Про @ConfigurationProperties согласен, это более правильный путь для группировки настроек. В текущем варианте использовал @Value для наглядности в рамках статьи, но для продакшена - однозначно перееду на проперти-классы.

      Про COALESCE и пустой результат: Справедливое замечание. Действительно, если записи нет, Spring Data может вернуть false по умолчанию, что даст ложноположительный результат. Это критический момент, буду пересматривать логику на возвращение Optional или проверку через exists.

      Про фильтр и "коробочные" решения: Spring Security позволяет многое конфигурировать, но при использовании кастомных Refresh-токенов в БД и специфической логики валидации, свой фильтр дает больше прозрачности и гибкости. Хотя всегда есть куда упрощать.

      Сейчас тестировать "чистые" функции генерации строк - это трата времени для прототипа. А вот когда логика обрастет связями, например, сложная ротация токенов или проверка ролей, я сделаю отдельный пост про интеграционное тестирование всего флоу авторизации сразу.


  1. aleksandy
    06.02.2026 07:09

    буду закрывать этот "технический долг"

    Действительно, зачем делать сразу нормально? А так можно ещё пару недель на рефакторинг списать. Код крутится, лавеха мутится.

    свой фильтр дает больше прозрачности и гибкости

    А зачем тогда нужен весь Spring Security?


    1. rootCore Автор
      06.02.2026 07:09

      Про рефакторинг: Можно сразу строить космодром, когда нужна просто взлетная полоса для кукурузника. Логичнее сначала подтвердить жизнеспособность архитектуры, а потом её полировать. Это нормальный процесс разработки, а не "списание времени".

      Насчет "сделать сразу нормально": попытка вылизать каждую строчку до идеала на этапе набросков часто приводит к оверинжинирингу.

      "Сразу правильно может затормозить процесс потому что:

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

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

      Хрупкие тесты: Если покрыть тестами сырой прототип, то при любом изменении логики (которое в начале пути неизбежно) придется переписывать и код, и гору тестов. В итоге работа замедляется вдвое.

      Поэтому сейчас важнее проверить жизнеспособность связей между сервисами. А когда архитектура устаканится, можно навести марафет, что в уже стабильном коде будет в разы быстрее и эффективнее, чем переделывать "идеальный" код по несколько раз.

      Про Spring Security: Он нужен как мощный каркас - управление сессиями, CORS, CSRF, защита эндпоинтов и иерархия доступов. Написать свой фильтр внутри этого каркаса - это не "изобретение велосипеда", а настройка инструмента под свои задачи, например, хранение Refresh в БД. Одно другому не мешает, а дополняет.