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

от автора

Создание 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *