Spring Security: аутентификация через REST

от автора

Доброго времени суток! Данная статья будет посвящена созданию аутентификации через REST. Но перед самой аутентификацией, необходимо узнать об очень важной составляющей — PasswordEncoder.

PasswordEncoder

PasswordEncoder — это интерфейс Spring Security, у которого всего одна задача: превратить пароль в нечитаемую кашу, которую нельзя восстановить обратно, даже при условии, если кто-то сможет залезть в бд.

А зачем он нужен? Для хэширования пароля, который уже и будет сохраняться в базе данных. Поскольку если пароль будет храниться в обычном виде например «password123», то если хацкеры смогут залезть в базу и украсть данные, то они смогут заполучить аккаунты владельцев этих же аккаунтов, а если хацкеры смогут залезть в базу данных, но при этом пароли будут захэшированы, то они не получат доступ к аккаунтам пользователей.

Мы поняли что надо хранить хэш, но что это такое и как он создаётся?…
Хэш — это результат применения односторонней математической функции, которую невозможно обратить назад. (По умному это криптографический хэш, но будем это называть просто хэш)

Процесс хэширования пароля через bcrypt

Процесс хэширования пароля через bcrypt

*Важное уточнение, что cost — это число которое задаёт уровень сложности. Обычно это число от 4 до 31, которое мы указываем при вызове функции (Пример будет ниже). А в свою очередь раунд — это один проход через определённый набор операций шифрования, который применяется к данным несколько раз подряд.

Как всё это работает?

  1. Соль генерирует случайно (в компьютере нет ничего случайного, но не об этом) генерирует последовательность из 16 байт, которая уникальна для каждого пароля. Это защищает от rainbow-таблиц (даже одинаковые пароли дают разный хэш)

  2. Пароль и соль объединяются и проходят через алгоритм Blowfish который перемешивает соль и пароль.

  3. Cost-фактор задаёт число раундов как 2^cost. Чем выше cost, тем дольше вычисление — это специально замедляет brute-force, когда скорость перебора одного логина постоянна.

  4. Получившаяся строка хранится полностью и содержит несколько частей: версию алгоритма, cost, соль и хэш. Это выглядит как-то так:$2b$10$N9qo8uLOickgx2ZMRZoMy.... При проверке пароля bcrypt достаёт соль и cost из пароля прямо из это строки, проверяет вычисления и сравнивает результат.

Почему лучше использовать именно BCrypt, а не SHA-256:

SHA-256 — это обычная хэш функция с довольно высокой скоростью, и она создана для проверки целостности файлов. Но для паролей это минус, поскольку видеокарта может перебирать афигеть как много SHA-256 хэшей в секунду, через brute-force.
BCrypt — медленный и автоматически подмешивает случайную соль, он специально требует долгих вычислений на каждый хэш, так что брутфорс становится слишком тяжёлым. Даже с мощным железом можно проверить лишь тысячи вариантов в секунду, а не миллиарды. В свою очередь соль позволяет из одинакового пароля создать разные хэши для одинаковых паролей, это значит, что хацкер не сможет просто так сравнить хэши между собой и понять совпадают ли пароли у пользователей, и не сможет использовать заранее просчитанные rainbow tables для быстрого взлома.

Как теперь реализовать PasswordEncoder? А делается это достаточно просто!

@Configurationpublic class PasswordEncoderConfig{  @Bean  public PasswordEncoder passwordEncoder(){    return new BCryptPasswordEncoder(12);  }  }

Данный конфиг лучше вынести в отдельно, для избежания циклических зависимостей.
Данный код создаёт бин кодировщика паролей на основе алгоритма BCrypt который возвращает готовый PasswordEncoder с cost = 12, то есть число раундов хэширования = 2^12. Чем больше cost, тем меленнее брутфорс, но тем не медленнее сам процесс аутентификации. По этому 10-12 можно считать стандартным балансом.

Session + Cookie аутентификация

Если вы знакомы с такими понятиями как сессия и куки, то смело можете пропустить пункт объяснения что это такое.

Сессия (на стороне сервера) — это способ хранения данных пользователя на сервере. При этом клиенту в браузер отправляется только уникальный id сессии, а сам сервер использует этот id как ключ доступа к сохранённой информации.
Если ничего непонятно, то можно привести аналогию с гардеробом в театре. Вы пришли в театр, сняли куртку, сдали в гардероб и получили свой номерок (он и будет уникальным id сессии), пока куртка находится в гардеробе — это и есть сессия.
Cookie (печенька) — это небольшой кусочек текста который выглядит как пара ключ=значение, который сервер просит сохранить у себя и присылать обратно, при каждом последующем запросе к этому же сайту.
Опять таки, если ничего не понятно, то снова приведу пример с этим же гардеробом и театром: когда выдали номерок на котором висит куртка — это cookie, а дальше при любых дальнейших действиях в театре при просьбе предъявления номерка, просто показываете этот номерок (cookie) и нет проблем. Только браузер автоматически при каждом запросе к сайту вставляет cookie с id сессии (номерком куртки в гардеробе).

Как будет работать REST аутентификация:

  1. POST /api/auth/login { “username”: “…”, “password”: “…” } → JSON

  2. Spring аутентифицирует пользователя вручную через AuthenticationManager

  3. SecurityContext сохраняется в HttpSession на сервере

  4. Браузеру отдаётся Set-Cookie: JSESSIONID=abc123 (фронтенд его не читает, браузер отправляет автоматически при следующих fetch с credentials: “include”)

  5. При следующих запросах Spring восстанавливает SecurityContext из сессии

  6. Любая ошибка (401/403) — это JSON-тело вида { “status”: 401, “message”: “…” }, а не HTML-страница и не редирект

Перед тем как начать писать SecurityConfig, нужно создать ApiResponse который будет отдавать ответы пользователю, RestAccessDeniedHandler и RestAuthenticationEntryPoint которые в дальнейшем будут нужны в SecurityConfig в виде JSON-обработчиков ошибок аутентификации и авторизации.

Начнём с более простого, создадим record ApiResponse:

public record ApiResponse(  int status,  String error,  String message){}

Ничего сложного тут нет, в API ответе будет только статус, ошибка и сообщение ошибки, ничего более.

Далее реализуем RestAccessDeniedHandler:

@Componentpublic class  implements AccessDeniedHandler {    private final ObjectMapper objectMapper = new ObjectMapper();    @Override    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {        response.setStatus(HttpStatus.FORBIDDEN.value());        response.setContentType(MediaType.APPLICATION_JSON_VALUE);        ApiError body = new ApiError(403, "Forbidden", accessDeniedException.getMessage());        objectMapper.writeValue(response.getWriter(), body);    }}

Данный компонент будет отвечать за случай, когда пользователь аутентифицирован, но прав не хватает. Например он попытается зайти на админские url или чужой @PreAuthorize из ProfileService (который снова реализуем в дальнейшем). В таком случае будет возвращён JSON с кодом 403 вместо страницы ошибки.
В данном методе в ответ ставим статус 403 через HttpStatus.FORBIDDEN.value(), можно было для корректности стиля использовать HttpServletResponse.SC_FORBIDDEN, но для простоты лучше использовать мой вариант, работать они будут идентично.
Далее ставим формат ответа JSON через MediaType.APPLICATION_JSON_VALUE. После чего заполняем наш reord с телом ответа с помощью response.getWriter() (потока, который пишет прямо в тело HTTP-ответа, который отправляется клиенту), и через objectMapper превращаем тело ответа в JSON.

Затем по аналогии нужно реализовать RestAuthenticationEntryPoint:

@Componentpublic class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {    private final ObjectMapper objectMapper = new ObjectMapper();    @Override    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {        response.setStatus(HttpStatus.UNAUTHORIZED.value());        response.setContentType(MediaType.APPLICATION_JSON_VALUE);        ApiError body = new ApiError(401, "Unauthorized", authException.getMessage());        objectMapper.writeValue(response.getWriter(), body);    }}

Суть работы у него такая же как и у AccessDeniedHandler, но он нужен для случая, когда пользователь не аутентифиицрован, а сама реализация практически идентичная, отличаются только статусы ответов. В этом случае возвращается 401, а в прошлом возвращается 403.

Теперь можем переходить к SecurityConfig:

@Configuration@EnableWebSecurity@RequiredArgsConstructorpublic class SecurityConfig {    private final UserDetailsServiceImpl userDetailsService;    private final PasswordEncoder passwordEncoder;    private final RestAuthenticationEntryPoint authenticationEntryPoint;    private final RestAccessDeniedHandler accessDeniedHandler;    @Bean    public AuthenticationProvider authenticationProvider(){        DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);        provider.setPasswordEncoder(passwordEncoder);        return provider;    }    @Bean    public AuthenticationManager authenticationManager(AuthenticationConfiguration config){        return config.getAuthenticationManager();    }    @Bean    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{        http.authorizeHttpRequests(auth ->                auth.requestMatchers("/api/auth/**").permitAll()                        .requestMatchers("/api/admin/**").hasRole("ADMIN")                        .anyRequest().authenticated())                //Временное отключение защиты от csrf атаки                .csrf(csrf -> csrf.disable())                // Логин обрабатывает SessionAuthController, который принимает и отдаёт JSON'ы (реализация позже)                .sessionManagement(session ->                        session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)                                .sessionFixation().migrateSession()                                .maximumSessions(1)                )                .authenticationProvider(authenticationProvider())                .exceptionHandling(ex ->                        ex.authenticationEntryPoint(authenticationEntryPoint)                                .accessDeniedHandler(accessDeniedHandler));        return http.build();    }}

Начнём разбор кода с аннотации @EnableWebSecurity, она необходима для включения и настройки механизма безопасности на уровне HTTP-запросов. Если коротко, то она включает конфигурацию безопасности, и без неё ничего не будет работать. Остальные аннотации не вижу смысла разбирать, потому что они самые банальные и используются в каждом проекте.

Далее разберём каждый бин по отдельности:

1) AuthenticationProvider — это интерфейс из Spring Security котры который проверяет: «Верны ли предъявленные учётные данные?». У этого интерфейса есть главный метод:

@Nullable Authentication authenticate(Authentication authentication) throws AuthenticationException;

В этот метод поставляются логин и пароль который ввёл пользователь, а метод возвращает либо подтверждённый Authentication, либо выбрасывает исключение BadCredentialsException при условии, если пароль не верный.
DaoAuthenticationProdiver — это по сути одна из готовых реализаций AuthenticatoinProvider, которая встроена в Spring Security. В его конструктор поставляется userDetailsService который говорит «где искать пользователя», а затем через сеттер поставляется passwordEncoder который и будет сравнивать пароли. Далее просто этот

2) AuthenticationManager — это более высокоуровневая штука, которая сама вызывается вручную в AuthService.login(…) когда надо проверить логин+пароль (реализуем ниже). Внутри себя AuthenticationManager перебирает все зарегистрированные AuthenticationProvider и находит подходящий — в нашем случае единственный, DaoAuthenticationProvider, который мы тут и создали.

Теперь по подробнее про бин SecurityFilterChain
Как не трудно догадаться, этот бин — это цепочка фильтров фильтров которую проходит абсолютно каждый HTTP запрос.
HttpSecurity — это объект-конструктор, который Spring Security сам создаёт и передаёт в метод. Он не создаётся руками, а приходит готовым в параметре, а наша задача — вызвать на нём цепочку фильтров. А уже в конце вызвать .builder(), который соберёт все настройки в готовый объект SecurityFilterChain.

authorizeHttpRequests(auth -> ...) данная конструкция отвечает на вопрос «каике права доступа нужны для каждого url». Внутрь мы передаём лямбду, которая принимает объект auth — конфигуратор правил, на котором в дальнейшем вызываются правила по очереди, сверху вниз, и применяется первое совпавшее.

.requestMatchers("/api/auth/**").permitAll() — показывает, что пути /api/auth/** (** — обозначает любую вложенность путей, типа таких путей может быть много) доступны каждому пользователю, и аутентификация не нужна.

.requestMatchers("/api/admin/**").hasRole("ADMIN") — показывает, что по url /api/admin/** может иметь доступ пользователь с ролью ADMIN и никто больше. Но есть важный нюанс, метод hasRole(«ADMIN») под капотом ищет GrandedAuthirity с именем «ROLE_ADMIN» — префикс «ROLE_» добавляется автоматически.

.anyRequest().authenticated() — это заглушка, которая должна быть строго последней, эта конструкция требует аутентификации для каждого url запроса, который мы не описывали выше(подойдёт любая валидная роль). Я серьёзно, это важно, чтобы данная конструкция была самой последней, потому что, если бы она стояла первой, то она перехватывала бы запрос /api/auth/register (к примеру) и тогда для регистрации, ты должен был бы быть аутентифицирован, а это критическая ошибка!

.sessionManagment(session -> ...) — настраивает ка кименно работают HTTP-сессии для нашего приложения.

.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) — создаёт сессию только в том случае, когда она реально понадобилась. Для этого мы позже в SecurityContext положим SecurityContextHolder.createEmptyContext();.
IF_REQUIRED не единственное значение, есть ещё 3: ALWAYS — сессия создаётся всегда (даже если не нужна), NEVER — Spring сам не создаёт, но испольует уже существующую сессию если она есть, STATELESS — сессия не используется вообще (дальше оно будет использоваться для JWT)

.sessionFixation().migrateSession() — это защита от атаки фиксации сессии, то есть хацкер мог заранее дать пользователю нужный хацкеру id сессии (к примеру через RequestParam), и после того как ни о чём не подозревающий пользователь залогинится, хацкер будет импользовать id сессии ползователя, чтобы действовать от имени пользователя.
Вернёмся к примеру с гардеробом, после того как вы придёте в театр и получите свой номерок, у хацкера будет знать какой у вас номерок, и он сможет делать в том же театре всё что угодно от вашего имени.
Так вот данная конструкция как раз защищает от таких ситуаций с помощью .migrateSession() которая говорит, что при успешном логине созать новую сессию с новым id, перенеся туда данные из старой сессии, а старую сессию нужно удалить.

.maximumSessions(1) — эта настройка говорит, что у каждого пользователя есть только однасессия, и если он попробует залогиниться с другого устройства/браузера, то старая сессия будет удалена.

.authenticationProvider(authenticationProvider()) — тут мы внедряем бин, который был описан выше. Это достаточно важно, ведь без этой строчки AuthenticationManager, вызываемый вручную в SessionAuthController.login(...), не знал бы какой механизм проверки испльзовать.

.authenticationEntryPoint(authenticationEntryPoint) — внедряем компонент который также был описан выше. Он вызывается в тех случаях, когда пользователь не аутентифицирован, и вместо редиректа на страницу логина, возвращается JSON с 401 кодом.

.accessDeniedHandler(accessDeniedHandler) — внедряем компонент который опять таки также был описан выше. Он вызывается в тех случаях, когда пользователь аутнетифицирован, но прав ему не хватает, в таком случае возвразается JSON с 403 кодом.

return http.builder(); — финальный вызов, который соберёт все настройки описанные во всех предыдущих методах, и собирает из них готовый неизменяемый объект SecurityFilterChain — именно этот объект используется FilterChainProxy для обработки каждого входящего HTTP-запроса.

SessionAuthController — логин и логаут через JSON

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

public record LoginRequest(        String username,        String password) {}
public record LoginResponse(        String username) {}

Дання REST-замена стандартному formLogin. Контроллер принимает JSON, сам вызывает AuthenticationManager и сам кладёт результат в сессию — именно так делают REST API поверх сессионной аутентификации.

@RestController@RequestMapping("/api/auth")@RequiredArgsConstructorpublic class SessionAuthController {    //Гланый компонент Spring Security, который проверяет логин и пароль    private final AuthenticationManager authenticationManager;    //Отвечает за сохранение информации о пользователе в HTTP-сессии    private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();    @PostMapping("/login")    public ResponseEntity<LoginResponse> login(            @RequestBody LoginRequest request,            HttpServletRequest httpRequest,            HttpServletResponse httpResponse){        //Создаётся объект в котором уже лежат логин и пароль, но пользователь ещё не аутентифицирован        Authentication authRequest = new UsernamePasswordAuthenticationToken(request.username(), request.password());        //Этот метод берёт наш authRequest и начинает искать пользователя         //в UserDetailsServiceImpl (который мы писали ранее)        Authentication authResult = authenticationManager.authenticate(authRequest);        //Создаётся пустой контекст в котором будет храниться информация о пользователе        SecurityContext context =  SecurityContextHolder.createEmptyContext();        //Записываем ползователя в контекст        context.setAuthentication(authResult);        SecurityContextHolder.setContext(context);        // Явное сохранение контекста в сессию        securityContextRepository.saveContext(context, httpRequest, httpResponse);        //Возвращение клиенту JSON с его username        return ResponseEntity.ok(new LoginResponse(authResult.getName()));    }    @PostMapping("/logout")    public ResponseEntity<Void> logout(HttpServletRequest request){        //Получаем сессию из запроса        HttpSession session = request.getSession();                //Проверка на пустоту        if(session != null){            //Если сессия не пуста, то инвалидируем (полностью удаляем)            session.invalidate();        }        //Очищаем информацию о пользователе из памяти текущего запроса        SecurityContextHolder.clearContext();        return ResponseEntity.noContent().build();    }}

Если вы внимательно читали код контроллеа, то наверняка заметили, что SecurityContextHolder.setContext(context); я оставил без комментария, потому что лёгкого и быстрого объяснения нет, так что нужно углубиться по подробнее.

Spring хранит Security Context в специальном месте — SecurityContextHolder. И условно в любом месте приложения (так не надо) можно написать SecurityContextHolder.getContext() (можно добавить .getAuthenticated() ) и получить текущего пользователя, даже если мы находимся в другом классе. Но этого недостаточно, потому что пользователь авторизован, но только в памяти текущего запроса, а при окончании запроса все переменные уничтожатся, context и authRequesr исчезнут… По этому следующим делом вызывается saveContext на репозитории, чтобы Spring взял SecurityContext и сохранил его в HTTP-сессию и информация о пользователе была не только в памяти текущего метода, но и сохранялась между запросами.

Папочная структура проекта с классами

Папочная структура проекта с классами

Далее для тестирования нашего приложения временно в пакете config новый конфигурационный класс DataInitializer который будет при запуске приложения добавлять одного пользователя в базу данных.

@Configuration@RequiredArgsConstructorpublic class DataInitializer {        private final UserRepository userRepository;    private final PasswordEncoder passwordEncoder;        @Bean    public CommandLineRunner initTestUser(){        return args -> {            if(userRepository.existsByUsername("test")){                return;            }            User user = new User();            user.setUsername("test");            user.setPassword(passwordEncoder.encode("test123"));            user.setRoles(Set.of(Role.USER));            System.out.println("\nТестовый пользователь создан\n");        };    }}

После того как мы запустили доке контейнер с базой данных и наше приложение, в консоль должно вывести «Тестовый пользователь создан»
Далее можем зайти в Postman и отправить POST-запрос по url: http://localhost:8080/api/auth/login

*Важное дополнение, в application.properties порт к которому подключается программа на 5433 и добавил один параметр для того чтобы в консоль выводились все sql-запросы, а в docker-compose.yml внешний порт постгреса поменял на 5433, и выглядит это слудующим образом:

services:  db:    image: postgres:latest    environment:      POSTGRES_PASSWORD: postgres      POSTGRES_USER: postgres      POSTGRES_DB: security    ports:      #изменение внешнего порта      - "5433:5432"
spring.application.name=SecurityCourseserver.port=8080spring.datasource.driver-class-name=org.postgresql.Driverspring.datasource.password=${POSTGRES_PASSWORD:postgres}spring.datasource.username=${POSTGRES_USER:postgres}#Изменение в этой строкеspring.datasource.url=jdbc:postgresql://localhost:5433/${POSTGRES_DB:security}spring.jpa.hibernate.ddl-auto=create-dropspring.jpa.show-sql=true

После отправки POST запроса с параметрами username и password (не хэшированный), нам вернётся username пользователя и код 200

Ответ в постмане

Ответ в постмане

Это показывает, что всё работает корректно!
Далее попробуем выйти из залогиненого пользователя. Для этого необходимо отправить POST запрос по url: http://localhost:8080/api/auth/logout и в ответе не будет ничего кроме кода 204

Выход из логина

Выход из логина

На этом можно закончить данную статью. Спасибо всем кто дочитал и узнал для себя что-то новое и полезное 🙂 Как обычно открыт к критике и буду только рад если она будет 🙂

ссылка на оригинал статьи https://habr.com/ru/articles/1056696/