Как стать автором
Обновить

Создание form login с помощью Spring Security 6

Уровень сложностиПростой
Время на прочтение17 мин
Количество просмотров1.3K

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

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

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

Теги:
Хабы:
+3
Комментарии2

Публикации

Работа

Java разработчик
176 вакансий

Ближайшие события