JWT-аутентификация при помощи Spring Boot 3 и Spring Security 6

Введение

Если не любите теорию, переходите сразу сюда.

Переход от базовых приложений к более сложным требует использования Spring Security для обеспечения безопасности. Новая версия, Spring Security 6, изменяет некоторые базовые реализации, а русскоязычных материалов на эту тему очень мало. В этой статье мы рассмотрим JWT-аутентификацию и авторизацию с помощью Spring Boot 3 и Spring Security 6, чтобы помочь начинающем разработчикам разобраться и начать пользоваться базовым функционалом этой библиотеки. Цель статьи — показать, как использовать JWT-аутентификацию с API-интерфейсами. Будет разобрано как базовое использование, так и ролевая модель. Статья подойдёт как новичкам, так и продвинутым разработчик для быстрой пошаговой интеграции пользователей в свои проекты.

Теория

Про это уже рассказывали много раз, поэтому тут только краткая выжимка. JWT или Json Web Token — открытый формат для создания токенов доступа, который является самодостаточным, т.е. содержит в себе всю необходимую информацию для проверки своей подлинности и содержимого без обращения к каким-либо внешним источникам. Токен состоит из 3-х частей:

  • Заголовок — хранит тип токена и алгоритм шифрования

  • Полезная нагрузка — данные пользователя, разрешения и тд (может быть всё что угодно)

  • Подпись — обеспечивает целостность данных, путём проверки, что токен не был изменён после создания

Наглядно устройство токена представлено на рисунке. Подробнее про JWT.

Структура JWT

Структура JWT

Не маловажным является то, что полезную нагрузку из токена в таком представлении может расшифровать каждый кто его увидит, пользуясь любым jwt encoder-ом, например jwt.io. Про шифрование и скрытие токенов в этой статье не будет.

Регистрация

При регистрации пользователя происходят следующие шаги:

  1. Пользователь обращается к сервису с запросом на регистрацию

  2. Переданные данные валидируются и на их основе создаётся объект пользователя, пароль шифруется при помощи PasswordEncoder

  3. Данные пользователя сохраняются в базу данных при помощи jpa-репозитория

  4. JwtService генерирует токен, который возвращается клиенту

Вход в аккаунт

Процесс входа не сильно отличается.

  1. Пользователь обращается с запросом на вход

  2. Создаётся экземпляр объекта UsernamePasswordAuthenticationTokenи при помощи AuthenticationManager происходят все необходимые проверки

  3. Если всё произошло успешно — будет возвращён токен, если нет, ошибка 403

Практика

Для демонстрации и проверки работоспособности приложения используется база данных H2.

Зависимости

Зависимости проекта


    
        org.springframework.boot
        spring-boot-starter-web
    

    
    
        org.springframework.boot
        spring-boot-starter-data-jpa
    
    
        com.h2database
        h2
        runtime
    

    
    
        org.springframework.boot
        spring-boot-starter-validation
    
    
        org.apache.commons
        commons-lang3
    
    
        org.springdoc
        springdoc-openapi-starter-webmvc-ui
        2.2.0
    

    
    
        org.springframework.boot
        spring-boot-starter-security
    
    
        io.jsonwebtoken
        jjwt-api
    
    
        io.jsonwebtoken
        jjwt-impl
    
    
        io.jsonwebtoken
        jjwt-jackson
    

    
    
        org.springframework.boot
        spring-boot-starter-test
        test
    
    
        org.springframework.security
        spring-security-test
        test
    



    
        
            io.jsonwebtoken
            jjwt-api
            0.12.3
        
        
            io.jsonwebtoken
            jjwt-impl
            0.12.3
        
        
            io.jsonwebtoken
            jjwt-jackson
            0.12.3
        
    

Подключение к базе данных

spring:
  application:
    name: security-security
  datasource:
    url: jdbc:h2:mem:security-security
    driverClassName: org.h2.Driver
    username: root
    password: root
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true
token:
  signing:
    key: 53A73E5F1C4E0A2D3B5F2D784E6A1B423D6F247D1F6E5C3A596D635A75327855

Не стоит забывать, что хранить ключ в pom файле не безопасно, если этот ключ попадёт в руки другого программиста, он сможет подделывать токены доступа, тем самым безопасность вашего приложения будет нарушена, будьте с этим очень аккуратны.

Создание моделей

Для работы со Spring Security нужно имплементировать интерфейс UserDetails — в нём инкапсулированы основные данные о пользователе, необходимые для процесса аутентификации и авторизации в Spring Security.

@Entity
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "users")
public class User implements UserDetails {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_id_seq")
    @SequenceGenerator(name = "user_id_seq", sequenceName = "user_id_seq", allocationSize = 1)
    private Long id;

    @Column(name = "username", unique = true, nullable = false)
    private String username;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "email", unique = true, nullable = false)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(name = "role", nullable = false)
    private Role role;

    @Override
    public Collection getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role.name()));
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
public enum Role {
    ROLE_USER,
    ROLE_ADMIN
}

В стандартном репозитории пропишем 3 дополнительных метода, для проверки уникальности данных перед регистрацией.

@Repository
public interface UserRepository extends JpaRepository {
    Optional findByUsername(String username);
    boolean existsByUsername(String username);
    boolean existsByEmail(String email);
}

Далее необходимо создать DTO для регистрации, входа и передачи токена пользователю.

Регистрация пользователя

@Data
@Schema(description = "Запрос на регистрацию")
public class SignUpRequest {

    @Schema(description = "Имя пользователя", example = "Jon")
    @Size(min = 5, max = 50, message = "Имя пользователя должно содержать от 5 до 50 символов")
    @NotBlank(message = "Имя пользователя не может быть пустыми")
    private String username;

    @Schema(description = "Адрес электронной почты", example = "jondoe@gmail.com")
    @Size(min = 5, max = 255, message = "Адрес электронной почты должен содержать от 5 до 255 символов")
    @NotBlank(message = "Адрес электронной почты не может быть пустыми")
    @Email(message = "Email адрес должен быть в формате user@example.com")
    private String email;

    @Schema(description = "Пароль", example = "my_1secret1_password")
    @Size(max = 255, message = "Длина пароля должна быть не более 255 символов")
    private String password;
}

Авторизация пользователя

@Data
@Schema(description = "Запрос на аутентификацию")
public class SignInRequest {

    @Schema(description = "Имя пользователя", example = "Jon")
    @Size(min = 5, max = 50, message = "Имя пользователя должно содержать от 5 до 50 символов")
    @NotBlank(message = "Имя пользователя не может быть пустыми")
    private String username;

    @Schema(description = "Пароль", example = "my_1secret1_password")
    @Size(min = 8, max = 255, message = "Длина пароля должна быть от 8 до 255 символов")
    @NotBlank(message = "Пароль не может быть пустыми")
    private String password;
}

Ответ с токеном доступа

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Ответ c токеном доступа")
public class JwtAuthenticationResponse {
    @Schema(description = "Токен доступа", example = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTYyMjUwNj...")
    private String token;
}

Сервисы

Для начала реализуем сервис для работы с jwt, все методы описаны комментариями.

@Service
public class JwtService {
    @Value("${token.signing.key}")
    private String jwtSigningKey;

    /**
     * Извлечение имени пользователя из токена
     *
     * @param token токен
     * @return имя пользователя
     */
    public String extractUserName(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    /**
     * Генерация токена
     *
     * @param userDetails данные пользователя
     * @return токен
     */
    public String generateToken(UserDetails userDetails) {
        Map claims = new HashMap<>();
        if (userDetails instanceof User customUserDetails) {
            claims.put("id", customUserDetails.getId());
            claims.put("email", customUserDetails.getEmail());
            claims.put("role", customUserDetails.getRole());
        }
        return generateToken(claims, userDetails);
    }

    /**
     * Проверка токена на валидность
     *
     * @param token       токен
     * @param userDetails данные пользователя
     * @return true, если токен валиден
     */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String userName = extractUserName(token);
        return (userName.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    /**
     * Извлечение данных из токена
     *
     * @param token           токен
     * @param claimsResolvers функция извлечения данных
     * @param              тип данных
     * @return данные
     */
    private  T extractClaim(String token, Function claimsResolvers) {
        final Claims claims = extractAllClaims(token);
        return claimsResolvers.apply(claims);
    }

    /**
     * Генерация токена
     *
     * @param extraClaims дополнительные данные
     * @param userDetails данные пользователя
     * @return токен
     */
    private String generateToken(Map extraClaims, UserDetails userDetails) {
        return Jwts.builder().setClaims(extraClaims).setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 100000 * 60 * 24))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256).compact();
    }

    /**
     * Проверка токена на просроченность
     *
     * @param token токен
     * @return true, если токен просрочен
     */
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    /**
     * Извлечение даты истечения токена
     *
     * @param token токен
     * @return дата истечения
     */
    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    /**
     * Извлечение всех данных из токена
     *
     * @param token токен
     * @return данные
     */
    private Claims extractAllClaims(String token) {
        return Jwts.parser().setSigningKey(getSigningKey()).build().parseClaimsJws(token)
                .getBody();
    }

    /**
     * Получение ключа для подписи токена
     *
     * @return ключ
     */
    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSigningKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Сервис работы с пользователями

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository repository;

    /**
     * Сохранение пользователя
     *
     * @return сохраненный пользователь
     */
    public User save(User user) {
        return repository.save(user);
    }


    /**
     * Создание пользователя
     *
     * @return созданный пользователь
     */
    public User create(User user) {
        if (repository.existsByUsername(user.getUsername())) {
            // Заменить на свои исключения
            throw new RuntimeException("Пользователь с таким именем уже существует");
        }

        if (repository.existsByEmail(user.getEmail())) {
            throw new RuntimeException("Пользователь с таким email уже существует");
        }

        return save(user);
    }

    /**
     * Получение пользователя по имени пользователя
     *
     * @return пользователь
     */
    public User getByUsername(String username) {
        return repository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("Пользователь не найден"));

    }

    /**
     * Получение пользователя по имени пользователя
     * 

* Нужен для Spring Security * * @return пользователь */ public UserDetailsService userDetailsService() { return this::getByUsername; } /** * Получение текущего пользователя * * @return текущий пользователь */ public User getCurrentUser() { // Получение имени пользователя из контекста Spring Security var username = SecurityContextHolder.getContext().getAuthentication().getName(); return getByUsername(username); } /** * Выдача прав администратора текущему пользователю *

* Нужен для демонстрации */ @Deprecated public void getAdmin() { var user = getCurrentUser(); user.setRole(Role.ROLE_ADMIN); save(user); } }

Сервис авторизации

Бины PasswordEncoder и AuthenticationManager будут созданы позже.

@Service
@RequiredArgsConstructor
public class AuthenticationService {
    private final UserService userService;
    private final JwtService jwtService;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManager authenticationManager;

    /**
     * Регистрация пользователя
     *
     * @param request данные пользователя
     * @return токен
     */
    public JwtAuthenticationResponse signUp(SignUpRequest request) {

        var user = User.builder()
                .username(request.getUsername())
                .email(request.getEmail())
                .password(passwordEncoder.encode(request.getPassword()))
                .role(Role.ROLE_USER)
                .build();

        userService.create(user);

        var jwt = jwtService.generateToken(user);
        return new JwtAuthenticationResponse(jwt);
    }

    /**
     * Аутентификация пользователя
     *
     * @param request данные пользователя
     * @return токен
     */
    public JwtAuthenticationResponse signIn(SignInRequest request) {
        authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
                request.getUsername(),
                request.getPassword()
        ));

        var user = userService
                .userDetailsService()
                .loadUserByUsername(request.getUsername());

        var jwt = jwtService.generateToken(user);
        return new JwtAuthenticationResponse(jwt);
    }
}

Кастомный фильтр

Фильтр наследует OncePerRequestFilter, что гарантирует единоразовый вызов фильтра для одного запроса.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    public static final String BEARER_PREFIX = "Bearer ";
    public static final String HEADER_NAME = "Authorization";
    private final JwtService jwtService;
    private final UserService userService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {

        // Получаем токен из заголовка
        var authHeader = request.getHeader(HEADER_NAME);
        if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWith(authHeader, BEARER_PREFIX)) {
            filterChain.doFilter(request, response);
            return;
        }

        // Обрезаем префикс и получаем имя пользователя из токена
        var jwt = authHeader.substring(BEARER_PREFIX.length());
        var username = jwtService.extractUserName(jwt);

        if (StringUtils.isNotEmpty(username) && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userService
                    .userDetailsService()
                    .loadUserByUsername(username);

            // Если токен валиден, то аутентифицируем пользователя
            if (jwtService.isTokenValid(jwt, userDetails)) {
                SecurityContext context = SecurityContextHolder.createEmptyContext();

                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                );

                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                context.setAuthentication(authToken);
                SecurityContextHolder.setContext(context);
            }
        }
        filterChain.doFilter(request, response);
    }
}

Конфигурация Spring Security

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserService userService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                // Своего рода отключение CORS (разрешение запросов со всех доменов)
                .cors(cors -> cors.configurationSource(request -> {
                    var corsConfiguration = new CorsConfiguration();
                    corsConfiguration.setAllowedOriginPatterns(List.of("*"));
                    corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
                    corsConfiguration.setAllowedHeaders(List.of("*"));
                    corsConfiguration.setAllowCredentials(true);
                    return corsConfiguration;
                }))
                // Настройка доступа к конечным точкам
                .authorizeHttpRequests(request -> request
                        // Можно указать конкретный путь, * - 1 уровень вложенности, ** - любое количество уровней вложенности
                        .requestMatchers("/auth/**").permitAll()
                        .requestMatchers("/swagger-ui/**", "/swagger-resources/*", "/v3/api-docs/**").permitAll()
                        .requestMatchers("/endpoint", "/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS))
                .authenticationProvider(authenticationProvider())
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

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

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userService.userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

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

Стоит обратить внимание на ограничение энпоинтов,

  • permitAll — Эндпоинт доступен всем пользователям, и авторизованным и нет

  • authenticated — Только авторизованные пользователи

  • hasRole — Пользователь должен иметь конкретную роль, и, соответственно быть авторизованным

  • hasAnyRole — Должен иметь одну из перечисленных ролей (не представлено в коде)

На самом деле, если вас не специфичная система безопасности в проекте, умение настраивать только authorizeHttpRequests хватит для успешной разработки приложения, до тех пор, пока вы не станете Middle разработчиком. Но если необходимо разобраться подробнее, про это можно почитать тут.

Контроллеры

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Tag(name = "Аутентификация")
public class AuthController {
    private final AuthenticationService authenticationService;

    @Operation(summary = "Регистрация пользователя")
    @PostMapping("/sign-up")
    public JwtAuthenticationResponse signUp(@RequestBody @Valid SignUpRequest request) {
        return authenticationService.signUp(request);
    }

    @Operation(summary = "Авторизация пользователя")
    @PostMapping("/sign-in")
    public JwtAuthenticationResponse signIn(@RequestBody @Valid SignInRequest request) {
        return authenticationService.signIn(request);
    }
}

Далее представлен код контроллеров созданных для демонстрации.

@RestController
@RequestMapping("/example")
@RequiredArgsConstructor
@Tag(name = "Аутентификация")
public class ExampleController {
    private final UserService service;

    @GetMapping
    @Operation(summary = "Доступен только авторизованным пользователям")
    public String example() {
        return "Hello, world!";
    }

    @GetMapping("/admin")
    @Operation(summary = "Доступен только авторизованным пользователям с ролью ADMIN")
    @PreAuthorize("hasRole('ADMIN')")
    public String exampleAdmin() {
        return "Hello, admin!";
    }

    @GetMapping("/get-admin")
    @Operation(summary = "Получить роль ADMIN (для демонстрации)")
    public void getAdmin() {
        service.getAdmin();
    }
}

Тестирование

Все конечные точки можно просмотреть в документации Swagger, для этого переходим на http://localhost:8080/swagger-ui/index.html. Если всё сделано правильно, должен появится следующий интерфейс.

Интерфейс Swagger

Интерфейс Swagger

Для тестирования будем использовать Postman.

Для начала убедимся, что без авторизации нет возможности обратится к защищённому эндпоинту, должен вернуться статус 403, а ответ быть пустым.

Обращение к закрытому эндпоинту

Обращение к закрытому эндпоинту

Для того, что бы авторизоваться, нужно передать соответствующие данные в теле запроса, предварительно выбрав тип тела, в нашем случае JSON.

Регистрация пользователь

Регистрация пользователь

Если всё прошло успешно, приложение вернёт токен, если произошли какие-либо ошибки (ошибка валидации или занятое имя пользователя) вернётся статус 403. Для того, что бы отображать текст ошибок, нужно подключить библиотеку ControllerAdvice (можно дополнить Zalando Problem).

Давайте сразу проверим что хранится внутри токена, для этого зайдём на jwt.io и попробует декодировать токен.

Содержание токена

Содержание токена

Так же, указав свой секретный ключ, есть возможность проверить валидность токена.

Полученный токен необходимо передавать в заголовках к каждому запросу, это можно сделать вручную, но Postman предоставляет простой интерфейс для аутентификации во время запросов, нам лишь необходимо выбрать типа и указать необходимые данные.

Настроить авторизацию можно на всё коллекцию, на папку и на конкретный запрос, для этого необходимо найти вкладку Authorization и выбрать необходимый тип аутентификации, в нашем случае Bearer Token.

Выбор типа аутентификации

Выбор типа аутентификации

В появившееся поле вводим токен.

Указываем токен

Указываем токен

После этого Postman автоматически добавить нужный заголовок (Authorization) с нужным содержанием, но как было сказано ранее, это так же можно сделать вручную.

Заголовок с токеном

Заголовок с токеном

После того как токен введён, повторим запрос.

Запрос к защищённому ресурсу

Запрос к защищённому ресурсу

На этот раз запрос прошёл и вернул строку «Hello, World!», что говорит о том, что система безопасности работает.

По аналогии, при авторизации необходимо указать username и пароль, если они верные, вернётся токен, а эндпоинт, требующий роль администратора не будет доступен до тех пор, пока пользователь не будет иметь соответсвующую роль.

Бонус

Postman предоставляет множество функций, которые упрощают тестирование приложений, одна из таких — переменные и тесты, используя их можно сильно сэкономить время.

Для начала нажмём на коллекцию и выберем вкладку Variables, после чего создадим 2 переменные:

Должно получится следующим образом.

Настройка переменных

Настройка переменных

Переменные можно использовать в любом поле ввода Postman, они имеют следующий синтаксис {{varname}}. Нам необходимо указать переменную с токеном в качестве токена аутентификации для всей коллекции.

Переменная как токен аутентификации

Переменная как токен аутентификации

Если всё сделано верно, текст выделится другим цветом и при наведении отобразится информация о переменной.

Далее вернёмся к запросу регистрации, первым делом в URL заменим домен на переменную — {{url}}/auth/sign-up, тем самым сократив запись и, если в будущем потребуется протестировать приложение на другом домене, это можно будет сделать путём замены значения переменной, а не исправлением каждого запроса.

Вторым шагом необходимо внутри запроса перейти во вкладку Tests и вставить следующий код.

pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});

pm.test("Set token to variable", () => {
    var responseJson = pm.response.json();
    pm.collectionVariables.set("token", responseJson.token);
})

Данный код создаёт 2 теста, первый проверяет что статус ответа 200, второй же получает из ответа токен и устанавливает его в переменную. Теперь, при выполнении запроса у вас появится новая вкладка Tests Result и если он показывает 2/2, значит оба теста прошли и токен установился в переменную.

Прохождение тестов

Прохождение тестов

Тесты так же необходимо продублировать в запрос авторизации.

Теперь при регистрации и авторизации токен будет автоматически вставлять в переменную, что избавлять вас от рутинной работы. Так же в запросах регистрации и авторизации лучше установить Authorization на No Auth, что бы просроченные токены не мешали авторизации и регистрации.

Исходный код можно найти тут — https://github.com/MinusD/SpringSecurity.

Заключение

В результате получилось полноценное приложение с регистрацией, авторизацией и ролевой моделью. Если у вас есть предложение по улучшению кода или текста, жду вас в комментариях или в issues на GitHub.

© Habrahabr.ru