Spring BootSeniorCoding

Как реализовать JWT-аутентификацию в Spring Boot?

Реализуется через jjwt: JwtService генерирует/валидирует токен, OncePerRequestFilter извлекает его из заголовка Authorization и выставляет SecurityContext, SecurityFilterChain добавляет фильтр перед UsernamePasswordAuthenticationFilter с STATELESS-политикой.

Зависимости

Добавьте в pom.xml (Spring Boot 3.x):

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.12.5</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.12.5</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.12.5</version>
  <scope>runtime</scope>
</dependency>

Генерация и валидация токена

@Service
public class JwtService {
    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration-ms:3600000}")
    private long expirationMs;

    private SecretKey signingKey() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
    }

    public String generateToken(UserDetails user) {
        return Jwts.builder()
            .subject(user.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expirationMs))
            .signWith(signingKey())
            .compact();
    }

    public String extractUsername(String token) {
        return Jwts.parser()
            .verifyWith(signingKey())
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .getSubject();
    }

    public boolean isTokenValid(String token, UserDetails user) {
        return extractUsername(token).equals(user.getUsername())
            && !isExpired(token);
    }

    private boolean isExpired(String token) {
        return Jwts.parser()
            .verifyWith(signingKey())
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .getExpiration()
            .before(new Date());
    }
}

OncePerRequestFilter

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain)
            throws ServletException, IOException {
        String header = req.getHeader(HttpHeaders.AUTHORIZATION);
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(req, res);
            return;
        }
        String token = header.substring(7);
        String username = jwtService.extractUsername(token);
        if (username != null
                && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails user = userDetailsService.loadUserByUsername(username);
            if (jwtService.isTokenValid(token, user)) {
                var auth = new UsernamePasswordAuthenticationToken(
                    user, null, user.getAuthorities());
                auth.setDetails(new WebAuthenticationDetailsSource()
                    .buildDetails(req));
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        chain.doFilter(req, res);
    }
}

SecurityFilterChain

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s ->
                s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter,
                UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    public AuthenticationManager authManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Подводные камни

  • Хранить jwt.secret в application.properties в репозитории нельзя — используйте env-переменные или Vault.
  • Алгоритм HS256 с коротким секретом уязвим к брутфорсу; рекомендуется RS256 (пара ключей) в production.
  • JWT без revocation: при logout или смене пароля старый токен остаётся валидным до exp — нужен blocklist в Redis.
  • SecurityContextHolder использует ThreadLocal — в реактивном стеке (WebFlux) нужен ReactiveSecurityContextHolder.
  • Не проверять exp вручную: jjwt выбрасывает ExpiredJwtException при парсинге — достаточно поймать его в фильтре.
  • Access-токен должен быть коротким (15–60 мин); refresh-токен — длинным (7–30 дней), хранить в httpOnly cookie.
  • Не передавать sensitive данные (пароль, номер карты) в payload — JWT только base64-encoded, не зашифрован.

Common mistakes

  • Путать термин «jwt authentication» с соседним механизмом Spring Boot.
  • Не называть границу lifecycle, transaction, thread или request для «jwt authentication».
  • Игнорировать production-эффекты «jwt authentication»: latency, SQL shape, memory, security или observability.

What the interviewer is testing

  • Попросить объяснить механизм «jwt authentication» на минимальном примере.
  • Проверить, видит ли кандидат failure mode и диагностику для «jwt authentication».
  • Уточнить, какие настройки или API меняют «jwt authentication» в реальном сервисе.

Sources

Related topics