Создание 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. Основная страница теперь выглядит несколько иначе, скрыв текст для обычных пользователей, но отобразив текст для владельцев роли админа:

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

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