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

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

Хочу поблагодарить пользователей за конструктивную критику в комментариях. В частности, @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();
    }
}

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

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