Доброго времени суток! Данная статья будет посвящена созданию аутентификации через REST. Но перед самой аутентификацией, необходимо узнать об очень важной составляющей — PasswordEncoder.
PasswordEncoder
PasswordEncoder — это интерфейс Spring Security, у которого всего одна задача: превратить пароль в нечитаемую кашу, которую нельзя восстановить обратно, даже при условии, если кто-то сможет залезть в бд.
А зачем он нужен? Для хэширования пароля, который уже и будет сохраняться в базе данных. Поскольку если пароль будет храниться в обычном виде например «password123», то если хацкеры смогут залезть в базу и украсть данные, то они смогут заполучить аккаунты владельцев этих же аккаунтов, а если хацкеры смогут залезть в базу данных, но при этом пароли будут захэшированы, то они не получат доступ к аккаунтам пользователей.
Мы поняли что надо хранить хэш, но что это такое и как он создаётся?…
Хэш — это результат применения односторонней математической функции, которую невозможно обратить назад. (По умному это криптографический хэш, но будем это называть просто хэш)
*Важное уточнение, что cost — это число которое задаёт уровень сложности. Обычно это число от 4 до 31, которое мы указываем при вызове функции (Пример будет ниже). А в свою очередь раунд — это один проход через определённый набор операций шифрования, который применяется к данным несколько раз подряд.
Как всё это работает?
-
Соль генерирует случайно (в компьютере нет ничего случайного, но не об этом) генерирует последовательность из 16 байт, которая уникальна для каждого пароля. Это защищает от rainbow-таблиц (даже одинаковые пароли дают разный хэш)
-
Пароль и соль объединяются и проходят через алгоритм Blowfish который перемешивает соль и пароль.
-
Cost-фактор задаёт число
раундовкак 2^cost. Чем выше cost, тем дольше вычисление — это специально замедляет brute-force, когда скорость перебора одного логина постоянна. -
Получившаяся строка хранится полностью и содержит несколько частей: версию алгоритма, 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 аутентификация:
-
POST /api/auth/login { “username”: “…”, “password”: “…” } → JSON
-
Spring аутентифицирует пользователя вручную через AuthenticationManager
-
SecurityContext сохраняется в HttpSession на сервере
-
Браузеру отдаётся Set-Cookie: JSESSIONID=abc123 (фронтенд его не читает, браузер отправляет автоматически при следующих fetch с credentials: “include”)
-
При следующих запросах Spring восстанавливает SecurityContext из сессии
-
Любая ошибка (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/