УРОК 12 / 30 0%

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. 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. 2
    Конфигурация в application.yml
    
    taskflow:
      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. 3
    security/JwtTokenProvider.java
    
    package 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. 4
    security/JwtAuthenticationFilter.java
    
    package 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;
        }
    }
    
  5. 5
    DTO для токенов
    
    package com.taskflow.dto;
    
    public record TokenResponse(
        String accessToken,
        String refreshToken,
        String tokenType,
        long expiresIn  // секунды
    ) {}
    
  6. 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. 7
    exception/AuthException.java
    
    package com.taskflow.exception;
    
    public class AuthException extends RuntimeException {
        public AuthException(String message) { super(message); }
    }
    
  8. 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 ResponseEntity handleAuth(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. 9
    Обновляем AuthController
    
    @PostMapping("/login")
    public TokenResponse login(@Valid @RequestBody LoginRequest req) {
        return authService.login(req);
    }
    
    @PostMapping("/refresh")
    public TokenResponse refresh(@RequestBody Map body) {
        return authService.refresh(body.get("refreshToken"));
    }
    
  10. 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
    

    ---

🎯

Зачем это на собесе

После урока ты должен уметь ответить на:

Stateless API — никакой сессии на сервере, JWT самодостаточен. Горизонтальное масштабирование «бесплатно».
Микросервисы — один auth-service подписал токен → все остальные сервисы его проверяют тем же ключом.
Короткий access + длинный refresh — компромисс между UX и безопасностью.
JWT в Authorization: Bearer — стандарт RFC 6750, любой HTTP-клиент понимает.

5 вопросов на углубление

Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.

1
JJWT vs Nimbus JOSE JWT — какую библиотеку выбрать?

Разбор внутри: простой JWT в приложении и более сложные OAuth/OIDC сценарии.

2
Почему alg=none в JWT опасен?

Разбор внутри: неподписанный токен и исторические уязвимости парсеров.

3
Что делать, если JWT secret утёк?

Разбор внутри: ротация секрета, инвалидирование refresh-токенов и последствия.

4
Access token vs refresh token — зачем два токена?

Разбор внутри: короткая жизнь access и безопасное обновление через refresh.

5
Как сделать logout для stateless JWT?

Разбор внутри: blacklist, jti и компромисс между stateless и отзывом токена.

Готов идти дальше?

Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.

🚀 Перейти к Уроку 13

Сначала пройди все секции и выбери хотя бы 1 вопрос

⬅️ Назад к Уроку 11
🎉
Новый тир
Новый тир достигнут!