JWT-токены (access + refresh)
Цель урока
Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.
Теория · для собеса
1 Что такое JWT ▸
JWT (JSON Web Token) — это подписанный JSON, который клиент отправляет с каждым запросом.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcyMjQwMDAwMCwiZXhwIjoxNzI0MTcyNDAwfQ.3K8...
3 части через точку: header.payload.signature
2 Access vs Refresh токены ▸
| | Access | Refresh |
|---|---|---|
| Срок жизни | 15 мин | 7-30 дней |
| Хранится | Клиент (память / localStorage) | HttpOnly cookie или localStorage |
| Используется | Каждый запрос к API | Только для получения нового access |
| Секрет | Один и тот же (или пара ключей RSA) | Тот же, что у access |
| Если украли | Действует 15 мин | Можно отозвать (на сервере) |
Зачем 2 токена:
- Access короткий → если украли, окно атаки маленькое
- Refresh длинный → пользователю не надо логиниться каждую минуту
- Access хранится в JS (доступен XSS), refresh — в HttpOnly cookie (недоступен JS)
3 Stateless vs Stateful JWT ▸
Stateless (наш выбор): подпись проверяется криптографически, БД не нужна.
Stateful: храним список выданных/отозванных токенов в Redis/БД (для «logout на всех устройствах»).
4 Алгоритмы подписи ▸
- HS256 (HMAC + SHA256) — один секрет, симметричный. Подходит для одного сервиса.
- RS256 (RSA) — пара ключей, асимметричный. Подходит для микросервисов (auth-service подписывает, остальные только проверяют).
---
Практика: улучшаем CRUD
-
1Зависимости
dependencies { implementation("io.jsonwebtoken:jjwt-api:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") } -
2Конфигурация в
application.ymltaskflow: jwt: secret: ${JWT_SECRET:super-long-random-string-change-me-in-production-min-32-chars} access-token-expiration-ms: 900000 # 15 мин refresh-token-expiration-ms: 2592000000 # 30 дней issuer: taskflow -
3
security/JwtTokenProvider.javapackage com.taskflow.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; @Component public class JwtTokenProvider { private final SecretKey signingKey; private final long accessExpiration; private final long refreshExpiration; private final String issuer; public JwtTokenProvider( @Value("${taskflow.jwt.secret}") String secret, @Value("${taskflow.jwt.access-token-expiration-ms}") long accessExpiration, @Value("${taskflow.jwt.refresh-token-expiration-ms}") long refreshExpiration, @Value("${taskflow.jwt.issuer}") String issuer ) { this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.accessExpiration = accessExpiration; this.refreshExpiration = refreshExpiration; this.issuer = issuer; } public String generateAccessToken(String username, String role) { return generateToken(username, role, accessExpiration, "access"); } public String generateRefreshToken(String username) { return generateToken(username, null, refreshExpiration, "refresh"); } private String generateToken(String subject, String role, long expirationMs, String type) { Date now = new Date(); Date expiry = new Date(now.getTime() + expirationMs); var builder = Jwts.builder() .subject(subject) .issuer(issuer) .issuedAt(now) .expiration(expiry) .claim("type", type); if (role != null) builder.claim("role", role); return builder.signWith(signingKey).compact(); } public Claims parseAndValidate(String token) { return Jwts.parser() .verifyWith(signingKey) .requireIssuer(issuer) .build() .parseSignedClaims(token) .getPayload(); } public long getAccessExpiration() { return accessExpiration; } } -
4
security/JwtAuthenticationFilter.javapackage com.taskflow.security; import io.jsonwebtoken.Claims; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final CustomUserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, CustomUserDetailsService userDetailsService) { this.tokenProvider = tokenProvider; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain) throws ServletException, IOException { String token = extractToken(request); if (StringUtils.hasText(token)) { try { Claims claims = tokenProvider.parseAndValidate(token); String username = claims.getSubject(); String type = claims.get("type", String.class); if ("access".equals(type) && SecurityContextHolder.getContext().getAuthentication() == null) { var userDetails = userDetailsService.loadUserByUsername(username); var auth = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); } } catch (Exception ex) { // Invalid token → SecurityContext остаётся пустым → 401 } } chain.doFilter(request, response); } private String extractToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { return header.substring(7); } return null; } } -
5DTO для токенов
package com.taskflow.dto; public record TokenResponse( String accessToken, String refreshToken, String tokenType, long expiresIn // секунды ) {} -
6Обновляем
AuthService— логинpackage com.taskflow.service; import com.taskflow.dto.LoginRequest; import com.taskflow.dto.RegisterRequest; import com.taskflow.dto.TokenResponse; import com.taskflow.dto.UserResponse; import com.taskflow.exception.AuthException; import com.taskflow.exception.BusinessException; import com.taskflow.model.User; import com.taskflow.repository.UserRepository; import com.taskflow.security.JwtTokenProvider; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider tokenProvider; public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtTokenProvider tokenProvider) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.tokenProvider = tokenProvider; } @Transactional public UserResponse register(RegisterRequest req) { // ... как в уроке 11 } @Transactional public TokenResponse login(LoginRequest req) { User user = userRepository.findByUsername(req.username()) .orElseThrow(() -> new AuthException("Invalid username or password")); if (!passwordEncoder.matches(req.password(), user.getPasswordHash())) { throw new AuthException("Invalid username or password"); } String access = tokenProvider.generateAccessToken(user.getUsername(), user.getRole().name()); String refresh = tokenProvider.generateRefreshToken(user.getUsername()); return new TokenResponse( access, refresh, "Bearer", tokenProvider.getAccessExpiration() / 1000 ); } @Transactional public TokenResponse refresh(String refreshToken) { var claims = tokenProvider.parseAndValidate(refreshToken); if (!"refresh".equals(claims.get("type", String.class))) { throw new AuthException("Not a refresh token"); } String username = claims.getSubject(); User user = userRepository.findByUsername(username) .orElseThrow(() -> new AuthException("User not found")); return new TokenResponse( tokenProvider.generateAccessToken(user.getUsername(), user.getRole().name()), tokenProvider.generateRefreshToken(user.getUsername()), "Bearer", tokenProvider.getAccessExpiration() / 1000 ); } } -
7
exception/AuthException.javapackage com.taskflow.exception; public class AuthException extends RuntimeException { public AuthException(String message) { super(message); } } -
8Обновляем
SecurityConfig@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception { http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // ❌ Убираем .httpBasic() — теперь у нас JWT .formLogin(f -> f.disable()) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }Добавь обработчик
AuthExceptionвGlobalExceptionHandler:@ExceptionHandler(AuthException.class) public ResponseEntityhandleAuth(AuthException ex, WebRequest req) { ErrorResponse body = new ErrorResponse(401, "Unauthorized", ex.getMessage(), /*path*/, Instant.now(), List.of()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body); } -
9Обновляем
AuthController@PostMapping("/login") public TokenResponse login(@Valid @RequestBody LoginRequest req) { return authService.login(req); } @PostMapping("/refresh") public TokenResponse refresh(@RequestBody Mapbody) { return authService.refresh(body.get("refreshToken")); } -
10Тест
# Регистрация $ curl -X POST http://localhost:8080/api/auth/register \ -H "Content-Type: application/json" \ -d '{"username":"alice","email":"alice@example.com","password":"password123"}' # Логин → получаем токены $ TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"password123"}' | jq -r '.accessToken') # Защищённый endpoint с токеном → 200 $ curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/tasks HTTP/1.1 200 # Без токена → 401 $ curl -i http://localhost:8080/api/tasks HTTP/1.1 401 # Невалидный токен → 401 $ curl -i -H "Authorization: Bearer invalid.token.here" http://localhost:8080/api/tasks HTTP/1.1 401---
Зачем это на собесе
После урока ты должен уметь ответить на:
Authorization: Bearer — стандарт RFC 6750, любой HTTP-клиент понимает.5 вопросов на углубление
Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.
Разбор внутри: простой JWT в приложении и более сложные OAuth/OIDC сценарии.
Разбор внутри: неподписанный токен и исторические уязвимости парсеров.
Разбор внутри: ротация секрета, инвалидирование refresh-токенов и последствия.
Разбор внутри: короткая жизнь access и безопасное обновление через refresh.
Разбор внутри: blacklist, jti и компромисс между stateless и отзывом токена.
Готов идти дальше?
Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.
🚀 Перейти к Уроку 13Сначала пройди все секции и выбери хотя бы 1 вопрос
⬅️ Назад к Уроку 11