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

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

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