Всех приветствую!
В прошлый раз я не указал важный аспект: на данном этапе проект - это монолит. Однако архитектура закладывается с учетом того, что в будущем систему можно будет разнести на разные серверы.
Хочу поблагодарить пользователей за конструктивную критику в комментариях. В частности, @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(); } }
Всю логику с записью в БД и ротацией токенов выносим в отдельный сервис. Так мы разделяем криптографию и бизнес-процессы: этот класс "печет" токены, а кто и как их хранит - ему неважно
Всем спасибо за внимание!
ссылка на гит
