Encodera

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.

Оглавление

Вход в API по токену JWT

Сейчас клиент API отправляет имя и пароль в каждом запросе. Мобильному приложению пришлось бы хранить пароль пользователя, а это плохо. Обычно делают иначе: пароль отправляют один раз, а взамен получают токен — строку, которую дальше прикладывают к запросам в заголовке Authorization: Bearer .... Самый распространённый формат таких токенов — JWT (JSON Web Token).

Что внутри JWT

Токен состоит из трёх частей через точку: заголовок, данные и подпись. Данные — обычный JSON в кодировке Base64: кто пользователь, какие у него роли, до какого времени токен действует. Подпись сервер вычисляет секретным ключом. Подделать токен без ключа нельзя, а проверить его можно без базы и без сессии — достаточно ключа.

В Spring Security проверкой токенов занимается модуль OAuth2 Resource Server:

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>

Ключ и бины

Секретный ключ для алгоритма HS256 должен быть не короче 32 байт. Положим его в настройки, а заодно скажем, где в токене роли:

# src/main/resources/application.yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          authorities-claim-name: roles
          authority-prefix: ""
 
blog:
  jwt:
    secret: changeme-changeme-changeme-changeme

На сервере ключ задают переменной окружения BLOG_JWT_SECRET. В SecurityConfig добавим ключ, кодировщик для выдачи токенов, декодер для проверки и AuthenticationManager для проверки пароля:

// src/main/java/com/example/blog/SecurityConfig.java
@Bean
public SecretKey jwtKey(@Value("${blog.jwt.secret}") String secret) {
    return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
 
@Bean
public JwtEncoder jwtEncoder(SecretKey jwtKey) {
    return NimbusJwtEncoder.withSecretKey(jwtKey).build();
}
 
@Bean
public JwtDecoder jwtDecoder(SecretKey jwtKey) {
    return NimbusJwtDecoder.withSecretKey(jwtKey).build();
}
 
@Bean
public AuthenticationManager authenticationManager(UserDetailsService users, PasswordEncoder encoder) {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
    provider.setPasswordEncoder(encoder);
    return new ProviderManager(provider);
}

AuthenticationManager проверяет имя и пароль через наш BlogUserDetailsService — так же, как это делала форма входа.

Выдача токена

// src/main/java/com/example/blog/TokenService.java
@Service
public class TokenService {
 
    private static final Duration LIFETIME = Duration.ofHours(1);
 
    private final JwtEncoder encoder;
    private final Clock clock;
 
    public TokenService(JwtEncoder encoder, Clock clock) {
        this.encoder = encoder;
        this.clock = clock;
    }
 
    public TokenResponse issue(Authentication user) {
        Instant now = Instant.now(clock);
        List<String> roles = user.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .filter(name -> name.startsWith("ROLE_"))
                .toList();
 
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("blog")
                .subject(user.getName())
                .issuedAt(now)
                .expiresAt(now.plus(LIFETIME))
                .claim("roles", roles)
                .build();
        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
 
        String token = encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
        return new TokenResponse(token, LIFETIME.toSeconds());
    }
}

subject — имя пользователя, roles — его роли вроде ROLE_USER, expiresAt — срок жизни: через час токен перестанет приниматься. Записи для запроса и ответа простые:

// src/main/java/com/example/blog/LoginRequest.java
public record LoginRequest(@NotBlank String username, @NotBlank String password) {
}
// src/main/java/com/example/blog/TokenResponse.java
public record TokenResponse(String token, long expiresIn) {
}
// src/main/java/com/example/blog/AuthController.java
@RestController
@RequestMapping("/api/auth")
public class AuthController {
 
    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService;
 
    public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }
 
    @PostMapping("/login")
    public TokenResponse login(@Valid @RequestBody LoginRequest request) {
        Authentication user = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
        return tokenService.issue(user);
    }
}

При неверном пароле authenticate бросает AuthenticationException. Превратим его в ответ 401 в ApiExceptionHandler:

// src/main/java/com/example/blog/ApiExceptionHandler.java
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail handleLogin(AuthenticationException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Неверное имя или пароль");
}

Цепочка API принимает токены

В apiSecurity строку httpBasic(...) заменяем на проверку токена и открываем адрес входа:

// src/main/java/com/example/blog/SecurityConfig.java
.authorizeHttpRequests(auth -> auth
        .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
        .requestMatchers(HttpMethod.POST, "/api/users", "/api/auth/login").permitAll()
        .requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasRole("ADMIN")
        .anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))

Spring Security достаёт токен из заголовка, проверяет подпись и срок, а роли берёт из roles — это мы указали в настройках. Пользователь теперь не UserDetails, поэтому в контроллерах вместо @AuthenticationPrincipal UserDetails user пишем Principal principal и principal.getName(). Это работает и с токеном, и с формой входа.

Проверка

curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" \
  -d '{"username":"anna","password":"changeme"}'
{"token":"eyJraWQiOiJOWkFjMWJIMzRCWGFu...","expiresIn":3600}

Скопируйте токен в переменную оболочки TOKEN=eyJraWQi... и создайте статью. Файл post.json с текстом {"title":"Привет, JWT","body":"Статья по токену"} сохранён в UTF-8, как советовал урок о теле запроса:

curl -X POST http://localhost:8080/api/posts -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d @post.json
{"id":1,"title":"Привет, JWT","body":"Статья по токену","author":"anna","createdAt":"2026-10-04T10:49:22.976947400Z"}

Средняя часть токена раскодируется командой echo $TOKEN | cut -d. -f2 | base64 -d и покажет {"iss":"blog","sub":"anna","exp":1791114549,"iat":1791110949,"roles":["ROLE_USER"]}. Остальные случаи:

ЗапросОтвет
Без заголовка Authorization401, WWW-Authenticate: Bearer
Неверный пароль при входе401, "detail":"Неверное имя или пароль"
Испорченный токен401, error="invalid_token", Signed JWT rejected: Invalid signature
DELETE с токеном Анны (роль USER)403, error="insufficient_scope"
DELETE с токеном администратора204

Пароль через -u anna:changeme API больше не принимает — только токен.

Частая ошибка

Короткий секрет, например changeme. Приложение не запустится:

Factory method 'jwtEncoder' threw exception with message: com.nimbusds.jose.KeyLengthException: The secret length must be at least 256 bits

256 бит — это 32 байта. Для настоящего сервера сгенерируйте случайный ключ, например командой openssl rand -base64 32, и передайте его через BLOG_JWT_SECRET, а не храните в Git.

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.