Создание form login с помощью Spring Security 6
В Интернете легко можно найти различные руководства по организации авторизации пользователей посредством формы при помощи Spring Security. Однако, в шестой версии разработчики переработали фреймворк, и старые подходы больше не работают. В результате, чтобы добиться работающего результата, мне пришлось потратить изрядное количество времени на изучение вопроса. Чтобы сократить для вас, уважаемые читатели, этот путь, я и решил написать данную статью. Если вы торопитесь — переходите сразу к разделу, посвященному цепочке фильтров безопасности. Посмотреть проект целиком можно на гитхабе по ссылке.
Подготовка
Зависимости
Для начала создадим файл с зависимостями, необходимыми для проекта. Будем использовать одну из последних версий Spring — 3.4.4. Нам понадобятся:
-
spring-boot-starter-web — cтартер для разработки веб-приложения на основе Spring Boot, обеспечивающий работу веб-приложения.
-
spring-boot-starter-security — собственно, секьюрити-фреймворк, предоставляющий инструменты для аутентификации, авторизации и других функций безопасности.
-
spring-boot-starter-thymeleaf — шаблонизатор, предоставляющий возможность использовать динамический контент на веб-страницах, встраивая выражения и директивы прямо в HTML-код.
-
thymeleaf-extras-springsecurity6 — приятное дополнение, модуль интеграции для Spring Security 6.x в рамках платформы Thymeleaf. Помогает интегрировать два предыдущих модуля вместе, предоставляя возможность использовать для веб-страниц контент, предоставляемый Spring Security. Без этой зависимости можно обойтись, мы используем ее для демонстрации некоторых приятных новых возможностей.
-
spring-boot-starter-data-jpa — набор предварительно настроенных зависимостей для интеграции с JPA (Java Persistence API) и ORM (Object-Relational Mapping). Наш маленький проект будет использовать базу данных, как и в реальных системах, и эта зависимость обеспечивает создание необходимой схемы данных в БД и последующую работу с ней.
-
h2 — облегченная база данных Java с открытым исходным кодом. Будем использовать ее для демонстрации схемы работы, в реальном проекте эта зависимость должна быть заменена на драйвер для вашей базы данных.
-
lombok — библиотека для сокращения кода. Используем ее для автоматической генерации геттеров и сеттеров на основе аннотаций, избавляя проект от лишних нагромождений рутинного кода. Разумеется, авторизацию можно организовать и без этой зависимости, просто придется тем или иным способом добавить в ваши сущности некоторое количество геттеров и сеттеров.
На этом с зависимостями покончено, полный файл pom можно посмотреть по ссылке.
Файл application.properties
Файл application.properties хранит конфигурацию в приложениях Spring Boot в виде пар «ключ — значение». Эти свойства используются для настройки различных аспектов приложения, таких как порт сервера, соединение с базой данных, конфигурация логирования и другие. У нас будет заданно совсем немного параметров. Во-первых, это название приложения:
spring.application.name=example
Во-вторых, конфигурация базы данных, с которой мы будем работать, в нашем случае это H2:
spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password=password
И в-третьих, настройка hibernate, которая позволяет автоматически генерировать, обновлять или проверять схему на основе сущностей JPA. В этом примере она говорит спрингу каждый раз создавать схему данных на основании наших сущностей:
spring.jpa.hibernate.ddl-auto=create
В рабочем проекте ее значение нужно будет поменять на одно из следующих значений:
-
update. Обновляет схему базы данных на основе изменений в сопоставлениях сущностей. Добавляет новые таблицы и столбцы, а также модифицирует существующие.
-
validate. Проверяет существующую схему классов сущностей, при несоответствиях выдаёт ошибку.
-
none. Не выполняет автоматическое управление схемой.
Классы
Сущности
Нам понадобится сущность Пользователь, который будет авторизоваться на нашем ресурсе, и сущность Роль, которая будет задавать доступные пользователю действия. Класс Роли будет совсем простой — у него будет всего два поля, id и название роли:
@Entity @Table(name="roles") @Data public class MyRole { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "roleid") private int roleId; @Column(name = "title") private String title; }
Класс Пользователя ненамного сложнее. У него будет id, имя пользователя, пароль и набор ролей.
@Entity() @Table(name="users") @Data public class MyUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name="userid") private int userId; private String name; private String password; @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name= "user_role", joinColumns= @JoinColumn(name= "users", referencedColumnName= "userid"), inverseJoinColumns= @JoinColumn(name= "roles", referencedColumnName= "roleid")) private Set roles = new HashSet<>(); public void addRole(MyRole role) { roles.add(role); } public void removeRole(MyRole role) { roles.remove(role); } }
Пользователь связан с ролями, роли (в нашей реализации) не знают, какой пользователь ими обладает. Соответственно, используем однонаправленную связь «многие ко многим».
Репозитории
Для хранения наших сущностей в базе данных воспользуемся мощным инструментом, предоставляемым нам Spring — репозиториями JPA, что позволит нам обойтись минимумом кода Репозиторий пользователей будет выглядеть вот так:
public interface MyUserRepository extends JpaRepository { MyUser findByName(String username); }
Репозиторий ролей вот так:
public interface MyRoleRepository extends JpaRepository { MyRole findByTitle(String title); }
Описание того, как это работает, вы, при желании, отыщете без труда, так что не будем здесь на этом останавливаться.
Создание пользователей
Чтобы авторизоваться в системе, в ней тем или иным путем должны создаваться пользователи. Мы создадим небольшой вспомогательный класс, создающий в нашей БД пользователя user с ролью USER и администратора admin, соответственно, с ролью ADMIN. Разумеется, как имена пользователей, так и названия ролей могут быть произвольными. Назовем класс DbInit, добавим в него необходимые зависимости, внедряемые через конструктор, укажем аннотации логирования и компонента:
@Component @Log4j2 public class DbInit { private final MyRoleRepository myRoleRepository; private final MyUserRepository myUserRepository; // Обеспечивает шифрование паролей пользователей перед сохранением в БД private final PasswordEncoder passwordEncoder; @Autowiredd public DbInit(MyRoleRepository myRoleRepository, MyUserRepository myUserRepository, PasswordEncoder passwordEncoder) { this.myRoleRepository = myRoleRepository; this.myUserRepository = myUserRepository; this.passwordEncoder = passwordEncoder; }
Теперь добавим слушатель, который будет реагировать на событие ContextRefreshedEvent, которое публикуется при инициализации или обновлении контекста приложения, вызывая соответствующий метод для создания ролей и пользователей.
@EventListener public void onApplicationEvent(ContextRefreshedEvent event) { createDefaultUsers(); }
Сам метод выглядит так:
private void createDefaultUsers() { // Создаем роли пользователя и админа MyRole adminRole = createRole("ADMIN"); MyRole userRole = createRole("USER"); // Создаем пользователей с соответствующими ролями createUser("admin", adminRole); createUser("user", userRole); }
К нему прилагается пара вспомогательных приватных методов для создания пользователей:
private void createUser(String userName, MyRole role) { log.info("Creating user {}", userName); MyUser user = myUserRepository.findByName(userName); // Если пользователь с заданным именем отсутствует в БД - создаем его и сохраняем if (Objects.isNull(user)) { user = new MyUser(); user.setName(userName); user.setPassword(passwordEncoder.encode(userName)); // шифруем пароль user.addRole(role); myUserRepository.save(user); } }
и ролей:
private MyRole createRole(String title) { log.info("Creating role {}", title); MyRole role = myRoleRepository.findByTitle(title); // Если роль с заданным именем отсутствует в БД - создаем такую роль и сохраняем ее if (Objects.isNull(role)) { role=new MyRole(); role.setTitle(title); } myRoleRepository.save(role); return role; }
Подобный вариант создания пользователей по умолчанию имеет разумные альтернативы в виде запуска соответствующего SQL-скрипта, использования возможностей системы контроля версий liquibase и т.п.
Контроллеры
Теперь напишем контроллеры, которые будут обрабатывать запросы пользователей. Пусть у нас будет три эндпоинта: доступная для всех страница логина:
@Controller public class WebController { /** * Доступная для всех страница логина * @return login.html, хранящийся в папке templates */ @GetMapping("/login") public String handleLoginPage() { return "login"; }
Основная страница, доступная только зарегистрированным пользователям, которая у нас будет выдавать имя текущего пользователя и его и его набор разрешений (authorities):
@GetMapping("/") public String handleMainPage(Model model) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); String userName = auth.getName(); String message = "Ho do you do, mister " + userName + "? " + "Your authorities: " + auth.getAuthorities(); model.addAttribute("message", message); return "index"; }
Третьей будет страница администрирования, доступная исключительно пользователям с ролью ADMIN.
@GetMapping("/admin") public String handleAdminPage(Model model) { String message = "Hello, master " + SecurityContextHolder.getContext().getAuthentication().getName(); model.addAttribute("message", message); return "admin"; } Реализация UserDetailsService
Пришло время перейти к собственно Spring Security. Напишем нашу реализацию UserDetailsService.
UserDetailsService в Spring Security — это интерфейс, который предоставляет механизм для загрузки информации о пользователе из базы данных или другого хранилища, чтобы Spring Security мог выполнить аутентификацию. Он играет ключевую роль в процессе аутентификации, поскольку позволяет Spring Security получить необходимые данные о пользователе (такие как имя пользователя, пароль, роли) для проверки его учетных данных.
Для работы класса нам понадобиться ранее реализованный репозиторий пользователей, внедрим его через конструктор.
@Service @Log4j2 public class MyUserDetailsService implements UserDetailsService { private final MyUserRepository myUserRepository; @Autowired public MyUserDetailsService(MyUserRepository myUserRepository) { this.myUserRepository = myUserRepository; }
И реализуем метод loadUserByUsername, который должен возвращать экземпляр UserDetails. UserDetails — это основной интерфейс, представляющий информацию о пользователе, необходимую для аутентификации и авторизации. Он содержит геттеры для основных данных, таких как имя пользователя, пароль, полномочия (права) и другие атрибуты, влияющие на аутентификацию и авторизацию.
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { log.info("User Details Service searching for a user: {}", username); MyUser user = myUserRepository.findByName(username); if (Objects.nonNull(user)) { return new MyUserDetails(user); } else { throw new UsernameNotFoundException("user not found"); } } }
Собственно, на этом с UserDetailsService мы закончили. Теперь реализуем интерфейс UserDetails, который мы использовали в данном классе.
Реализация UserDetails
UserDetails — это интерфейс Spring Security, предоставляющий основную информацию о пользователе. Он служит мостом между пользовательской моделью данных и внутренними механизмами Spring Security. Основные функции UserDetails:
-
Аутентификация. Содержит информацию, необходимую для аутентификации пользователя, такую как имя пользователя и пароль.
-
Авторизация. Интерфейс предоставляет методы для получения ролей и прав доступа пользователя, что используется при авторизации.
-
Управление состоянием аккаунта. UserDetails содержит методы для проверки состояния аккаунта (активен, заблокирован, истёк срок действия и т.д.).
Создадим класс MyUserDetails, реализующий этот важный интерфейс, добавив для наглядность логирование при создании экземпляра данного класса:
@Log4j2 public class MyUserDetails implements UserDetails { private final MyUser user; public MyUserDetails(MyUser user) { log.info("UserDetails created for a user {}", user.getName()); this.user = user; }
Теперь последовательно реализуем необходимые методы. Начнем с getAuthorities(), который возвращает все полномочия, которые есть у пользователя. Эти полномочия описывают привилегии пользователя (например, чтение, запись, обновление и т.д.) или действия, которые он может выполнять. Метод возвращает результат в виде коллекции, отсортированной по естественному ключу. В нашем случае эта коллекция будет содержать всего одну роль, присвоенную пользователю:
@Override public Collection getAuthorities() { log.info("User Details providing grants for a user: {}", user.getName()); Set authorities = new HashSet<>(); // Помещаем в коллекцию объекты SimpleGrantedAuthority, созданные на основе // каждой из назначенной // пользователю роли, добавляя префикс "ROLE_" для корректной работы // механизмов Spring Security. // При желании, префикс по умолчанию можно изменить, задав свой // в настройках Spring Security. log.info("User's roles: {}", user.getRoles()); user.getRoles().forEach(role -> authorities.add( new SimpleGrantedAuthority("ROLE_" + role.getTitle()))); for (GrantedAuthority authority:authorities) { System.out.println("Authorities: " + authority.getAuthority()); } return authorities; }
Далее пара методов для получения имени пользователя и его пароля:
@Override public String getPassword() { log.info("User Details providing password for a user: {}", user.getName()); return user.getPassword(); } @Override public String getUsername() { log.info("User Details providing username: {}", user.getName()); return user.getName(); }
И несколько методов, ответственных за работу блокировок и сроков действия разрешений и пользовательских аккаунтов, функционал которых в нашем демонстрационном проекте не задействован, поэтому они просто возвращают true: @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; }
Наша реализация UserDetails готова. Пора переходить к самой интересной части — цепочке фильтров безопасности (SecurityFilterChain).
Цепочка фильтров безопасности
SecurityFilterChain в Spring Security — это цепочка фильтров безопасности, которая определяет порядок обработки запросов в приложении Spring Security. Эта цепочка используется FilterChainProxy для определения, какие фильтры Spring Security необходимо применить к конкретному запросу. SecurityFilterChain можно настроить с помощью конфигурации Spring Security, например, с помощью HttpSecurity. Создадим класс SecurityConfig, предоставляющий необходимые бины. Во-первых, PasswordEncoder, обеспечивающий шифрование паролей пользователей:
@Configuration public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
И, собственно, SecurityFilterChain:
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() )
Рассмотрим формируемую нами цепочку фильтров подробнее.
В начале мы описываем правила обработки запросов. Первым шагом отключаем защиту от CSRF-атак. В реальных проектах так делать не рекомендуется, в данном примере мы сделали это для простоты, поскольку «из коробки» данная защита нарушает работу form login, требуя дополнительных настроек.
Следующим шагом, мы требуем, чтобы у пользователей, отправляющих запросы к разделу администрирования (/admin) была роль ADMIN. И последним шагом мы требуем, чтобы все остальные запросы поступали только от авторизованных пользователей.
Теперь настроим логин:
.formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/process-login") .defaultSuccessUrl("/", true) .failureUrl("/login?error=true") .permitAll())
Укажем, адрес страницы с формой для входа /login. Затем, укажем по какому адресу будут приниматься запросы входа (в нашем примере — /process-login). Эти запросы обрабатываются силами SpringSecurity, в нашем контроллере этого эндпоинта нет. Следующим шагом указываем страницу, на которую пользователь переадресуется при успешном входе (в нашем случае, это будет главная страница), и при ошибке (/login?error=true) и открываем доступ к этой странице для всех.
И последний шаг — настройка выхода из системы:
.logout(form -> form .logoutUrl("/logout") .logoutSuccessUrl("/login?logout=true") .invalidateHttpSession(true) .deleteCookies("JSESSIONID") .permitAll()); return http.build(); } }
В целом, все выглядит аналогично предыдущему шагу — прописывается адрес, по которому Spring Security обрабатывает запросы на выход и адрес, куда переходить в случае успешного выхода. Дополнительно при указываем, что при выходе завершается сеанс пользователя и удаляется cookie с именем JSESSIONID.
После всех вышеперечисленных шагов выполняем метод http.build и возвращаем его результат.
Все необходимые java-классы реализованы, для запуска приложения осталось только написать код web-страничек.
Немного HTML
Сделаем несколько простейших страничек, чтобы продемонстрировать работу созданного нами бэкенда, в целях простоты не заморачиваясь со стилизацией.
Страница входа
Начнем со страницы входа, через которую пользователь будет заходить в систему и куда будет автоматически перебрасывать неавторизованных пользователей. В папке templates проекта создаем файл login.html. Зададим пространство имен для thymeleaf и название страницы:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
Теперь с помощью thymeleaf зададим сообщение об ошибке, которое будет отображаться при вводе неправильной пары имя пользователя/пароль:
<body>
<h1>Please Log In</h1>
<div th:if="${param.error}">
Invalid username and password.
</div>
И сообщение, отображаемое при выходе из системы:
<div th:if="${param.logout}">
You have been logged out.
</div>
Осталось добавить форму для входа
<h1>My login page</h1>
<form method="post" th:action="@{/process-login}">
<div>
<input name="username" placeholder="Username" type="text"/>
</div>
<div>
<input name="password" placeholder="Password" type="password"/>
</div>
<input type="submit" value="Log in"/>
</form>
</body>
</html>
Страница готова.
Главная страница
Теперь в той же папке templates создадим основную страницу нашего сайта, доступную только авторизованным пользователям. В той же папке templates создадим файл index.html. Зададим в нем пространства имен и название страницы
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Main page</title>
</head>
Добавим ссылку для выхода из системы (logout), видимое всем приветствие и динамически формируемый текст, в котором будет показываться имя текущего пользователя и список его грантов
<body>
<a href="/logout">Logout</a>
<h1>Welcome!</h1>
<div th:text="${message}"></div>
Теперь с помощью магии, предоставляемой нам ранее добавленной библиотекой thymeleaf-extras-springsecurity6, добавим два сообщения, одно из которых будут видеть только пользователи, а другое — админы:
<div sec:authorize="hasRole('USER')">Этот текст виден только пользователю с ролью USER.</div>
<div sec:authorize="hasRole('ADMIN')">Этот текст виден только пользователю с ролью ADMIN.</div>
И в конце еще одну ссылочку на страницу, доступную только администраторам:
<a href="/admin">Admin page here</a>
</body>
</html>
Готово!
Страница администрирования
Поскольку наша цель продемонстрировать разграничение доступа пользователей с разным ролями к разным разделам сайта, мы не будем заморачиваться наполнением данной страницы, ограничившись выводом приветствия для администратора.
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Admin page</title>
</head>
<body>
<h1>
<span th:text="${message}"></span>
</h1>
</body>
</html>
Запускаем наш проект, пытаемся зайти на страничку, набрав в браузере http://localhost:8080/. Нас перекидывает на страницу выхода:

Для начала попробуем зайти под обычным пользователем, вводим данные нашего пользователя по умолчанию (имя пользователя user, пароль также user), и попадаем на главную страницу нашего проекта, где нам выдает наше имя пользователи и роль (ROLE_USER), а так же отображает текст, доступный только для пользователей с этой ролью.

Если мы попытаемся, нажав на соответствующую ссылку внизу страницы, перейти в админский раздел, не обладая соответствующими полномочиями, получим ошибку 403 (доступ запрещен):

Вернемся обратно и выйдем из системы, щелкнув по ссылке logout. Нас перебросит обратно на страницу входа, отобразив сообщение об успешном выходе:

Теперь войдем под администратором, введя имя пользователя и пароль admin. Основная страница теперь выглядит несколько иначе, скрыв текст для обычных пользователей, но отобразив текст для владельцев роли админа:

Если мы попытаемся перейти на страницу администрирования по ссылке, то увидим, что эта страница теперь стала для нас доступна:

В итоге мы получили сайт с основными функциями безопасности — аутентификацией пользователя с помощью доступной всем посетителям формы входа, разграничением доступа к разным страницам в зависимости от ролей и отображением разного содержимого для пользователей с разными разрешениями в рамках одной и той же страницы.
Полностью проект можно посмотреть на гитхабе по этой ссылке.
ссылка на оригинал статьи https://habr.com/ru/articles/916672/
Добавить комментарий