Skip to main content

Авторизация

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

Этот проект - продолжение проекта из предыдущей главы. Если у вас его нет, можете скачать исходники предыдущего проекта здесь.

Дамп базы данных лежит в корне. Дампом называется бэкап базы данных.

cmd

Скачать исходники можно здесь. Дамп базы данных dump.sql лежит в корне.

Spring Security

Чтобы прописать авторизацию, нам понадобится дополнительная библиотека Spring Boot Security. Также пригодится библиотека тегов.

Для этого добавьте зависимости

    <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

в файл pom.xml

cmd

Теперь откройте вкладку Maven

cmd

И нажмите кнопку обновления

cmd

Модель

Для авторизации нам понадобится концепция пользователей и ролей. Чтобы разграничить права доступа к тем или иным страницам, запросам, необходимо создать роли и связать возможности доступа именно с ролью, а потом пользователю просто присваивается набор ролей, в соответствии с котором уже определяются права доступа для конкретного пользователя.

Роли

Для работы с ролями, необходимо реализовать в классе интерфейс GrantedAuthority. Создадим для этого класс Role.

cmd

package com.example.demo.entities;

import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;

import javax.persistence.*;

/**
* Класс ролей
*/
@Entity
@Table(name = "role_table")
@Data
@NoArgsConstructor
public class Role implements GrantedAuthority {
/**
* id
*/
@Id
private Long id;
/**
* Имя роли
*/
private String name;

/**
* Строковое представление полномочий роли
*
* @return строковое представление
*/
@Override
public String getAuthority() {
return getName();
}
}

Создадим теперь таблицу ролей, первое поле - id, не забудьте проставить все галочки

cmd

Теперь добавим второе поле названия и нажмём Execute

cmd

Теперь добавим две записи ролей с помощью кнопки с плюсом +

cmd

Теперь жмём на зелёную стрелочку, после обработки все изменения запишутся в базу данных и не будут отображаться зелёным

cmd

Пользователи

Теперь создадим сущность пользователя. Для этого добавим класс User в пакет entities.

cmd

Он должен расширять интерфейс UserDetails. Пропишем сам класс

package com.example.demo.entities;

import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.Collection;
import java.util.Set;

/**
* Класс пользователя
*/
@Entity
@Table(name = "user_table")
@Data
@NoArgsConstructor
public class User implements UserDetails {
/**
* Id
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* Роли пользователя
*/
@ManyToMany(fetch = FetchType.EAGER)
private Set<Role> roles;
/**
* Имя пользователя
*/
private String username;
/**
* Пароль
*/
private String password;

/**
* Получить роли пользователя
*
* @return список ролей
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return getRoles();
}

/**
* Проверка, не истёк ли срок действия пользователя
*
* @return флаг, не истёк ли срок действия пользователя
*/
@Override
public boolean isAccountNonExpired() {
return true;
}

/**
* Проверка, не заблокирован ли пользователь
*
* @return флаг, не заблокирован ли пользователь
*/
@Override
public boolean isAccountNonLocked() {
return true;
}

/**
* Проверка, не истёк ли срок действия учётных данных
*
* @return флаг, не истёк ли срок действия учётных данных
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}

/**
* Проверка, не заблокирован ли пользователь
*
* @return флаг, не заблокирован ли пользователь
*/
@Override
public boolean isEnabled() {
return true;
}
}

Роли задаются с помощью множества. Если поле представляет собой ту или иную коллекцию, то для занесения его в базу данных, необходимо указать, что это поле в базе должно быть представлено отношением Многие ко многим. Такое отношение говорит о том, что в поле модели может храниться набор объектов.

В скобках указывается стратегия заполнения коллекции. Самая распространённая - FetchType.EAGER

    /**
* Роли пользователя
*/
@ManyToMany(fetch = FetchType.EAGER)
private Set<Role> roles;

Для корректного связывания, необходимо также добавить хранить множество пользователей в поле класса ролей, поэтому добавим в класс Role поле

    /**
* Пользователи
*/
@Transient
@ManyToMany(mappedBy = "roles")
private Set<User> users;

Аннотация @Transient говорит о том, что это поле не нужно использовать при работе с базой данных (нет соответствующей колонки).

Правда, у ролей, т.к. это ответная часть, не указывается список удаления, а указывается, с каким полем коллекция будет связана.

Теперь создадим таблицу пользователей

cmd

и таблицу связывания ролей

cmd

Её название составляется из названия таблицы с полем и названия поля, которое мы указали в ответной части.

Контроллер

Теперь пропишем контроллер.

Репозитории

Для начала пропишем репозитории доступа к новым сущностям. Для этого добавим в пакет repository интерфейсы UserRepository и RoleRepository.

cmd

Теперь пропишем сами интерфейсы:

package com.example.demo.repository;

import com.example.demo.entities.User;
import org.springframework.data.jpa.repository.JpaRepository;

/**
* Репозиторий пользователей
*/
public interface UserRepository extends JpaRepository<User, Long> {
/**
* Поиск пользователя по имени
*
* @param username имя
* @return пользователь
*/
User findByUsername(String username);
}

Сервисы

Теперь пропишем сервис для работы с пользователем. Для этого в пакете service добавим сервис пользователя UserService

cmd

package com.example.demo.repository;
package com.example.demo.service;

import com.example.demo.entities.Role;
import com.example.demo.entities.User;
import com.example.demo.repository.RoleRepository;
import com.example.demo.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;
import java.util.Optional;

/**
* Сервис для работы с пользователями
*/
@Service
public class UserService implements UserDetailsService {
/**
* Репозиторий пользователей
*/
private final UserRepository userRepository;
/**
* Репозиторий паролей
*/
private final RoleRepository roleRepository;
/**
* Объект для работы с паролями
*/
private final BCryptPasswordEncoder bCryptPasswordEncoder;

/**
* Конструктор сервиса задач
*
* @param userRepository - репозиторий пользователей
* @param roleRepository - репозиторий паролей
*/
@Autowired
public UserService(UserRepository userRepository, RoleRepository roleRepository) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.bCryptPasswordEncoder = new BCryptPasswordEncoder();
}

/**
* Получить пользователя по его имени
*
* @param username - имя
* @return пользователь
* @throws UsernameNotFoundException - исключение, если пользователь не найден
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);

if (user == null) {
throw new UsernameNotFoundException("User not found");
}

return user;
}

/**
* Получить пользователя по его id
*
* @param userId - id
* @return пользователь с заданным id
*/
public User findUserById(Long userId) {
Optional<User> userFromDb = userRepository.findById(userId);
return userFromDb.orElse(new User());
}

/**
* Получить всех пользователей
*
* @return список пользователей
*/
public List<User> allUsers() {
return userRepository.findAll();
}

/**
* Сохранить пользователя
*
* @param user - пользователь
* @return флаг, получилось ли сохранить
*/
public boolean saveUser(User user) {
User userFromDB = userRepository.findByUsername(user.getUsername());

if (userFromDB != null) {
return false;
}

user.setRoles(Collections.singleton(new Role(1L, "ROLE_USER")));
user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
userRepository.save(user);
return true;
}

/**
* Удалить пользователя
*
* @param userId - id пользователя
* @return флаг, получилось ли удалить
*/
public boolean deleteUser(Long userId) {
if (userRepository.findById(userId).isPresent()) {
userRepository.deleteById(userId);
return true;
}
return false;
}

}

Регистрация

В первую очередь нам необходимо создать форму регистрации

cmd

package com.example.demo.form;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* Форма добавления задачи
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RegisterForm {
/**
* Имя пользователя
*/
private String username;
/**
* Пароль
*/
private String password;
/**
* Подтверждение пароля
*/
private String passwordConfirm;
/**
* Код регистрации
*/
private String code;
}

Поле код нам понадобится потом для регистрации пользователя в качестве администратора.

Теперь нам нужно прописать контроллер авторизации. Для этого в пакет controller добавим класс AuthController

cmd

Он будет обрабатывать пути, начинающиеся на /auth

package com.example.demo.controller;

import com.example.demo.entities.User;
import com.example.demo.form.RegisterForm;
import com.example.demo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/auth")
public class AuthController {
/**
* Сообщение на главной странице
*/
private static final String REGISTER_PAGE_MESSAGE = "Введите данные";
/**
* Заголовок главной страницы
*/
private static final String REGISTER_PAGE_TITLE = "Регистрация";
/**
* Сервис для работы с пользователями
*/
private final UserService userService;

/**
* Конструктор контроллера авторизации
*
* @param userService - сервис для работы с пользователями
*/
@Autowired
public AuthController(UserService userService) {
this.userService = userService;
}

/**
* Страница регистрации
*
* @param model - модель
* @return путь к шаблону
*/
@GetMapping("/register")
public String registration(Model model) {
// задаём форму
model.addAttribute("registerForm", new RegisterForm());
// задаём сообщение
model.addAttribute("message", REGISTER_PAGE_MESSAGE);
// задаём заголовок
model.addAttribute("title", REGISTER_PAGE_TITLE);
return "auth/register";
}

/**
* POST-запрос регистрации
*
* @param registerForm - форма регистрации
* @param bindingResult - результат связывания
* @param model - модель
* @return путь к шаблону
*/
@PostMapping("/register")
public String addUser(@ModelAttribute("registerForm") RegisterForm registerForm, BindingResult bindingResult, Model model) {
// если получены ошибки при связывании
if (bindingResult.hasErrors()) {
return "auth/register";
}
// если пароли не совпадают
if (!registerForm.getPassword().equals(registerForm.getPasswordConfirm())) {
model.addAttribute("errorMessage", "Пароли не совпадают");
return "auth/register";
}
// создаём пользователя
User user = new User();
user.setPassword(registerForm.getPassword());
user.setUsername(registerForm.getUsername());
// если не получилось создать пользователя в БД
if (!userService.saveUser(user)) {
model.addAttribute("errorMessage", "Пользователь с таким именем уже существует");
return "auth/register";
}

return "redirect:/";
}

}

Теперь нам нужно добавить настройки безопасности. Для этого создадим пакет config, а в нём класс WebSecurityConfig

cmd

package com.example.demo.config;

import com.example.demo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

/**
* Настройки веб-безопасности
*/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* Сервис для работы с пользователями
*/
private final UserService userService;
/**
* Объект для работы с паролями
*/
private final BCryptPasswordEncoder bCryptPasswordEncoder;


/**
* Конструктор сервиса пользователей
*
* @param userService - сервис для работы с пользователями
*/
@Autowired
public WebSecurityConfig(UserService userService) {
this.userService = userService;
this.bCryptPasswordEncoder = new BCryptPasswordEncoder();
}

/**
* Задать настройки безопасности
* @param httpSecurity - объект веб-безопасности
* @throws Exception - исклбючение
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// не использовать csrf-токен
.csrf()
.disable()
// позволяет ограничивать запросы
.authorizeRequests()
// Доступ только для не зарегистрированных пользователей
.antMatchers("/auth/register").not().fullyAuthenticated()
// Доступ разрешен всем
.antMatchers("/", "/js/**", "/css/**", "/img/**").permitAll()
// Все остальные страницы требуют аутентификации
.anyRequest().authenticated();
}

/**
* Глобальные настройки
*
* @param auth объект для настройки
* @throws Exception - исключение
*/
@Autowired
protected void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(bCryptPasswordEncoder);
}
}

Пока что мы оставим только главную страницу, ресурсы и страницу регистрации.

Для самой страницы будем использовать пример от бутстрапа

В образце от бутстрапа используется картинка, скачать её можно отсюда

Чтобы её использовать, необходимо в папке static создать папку img и сохранить картинку туда.

cmd

Теперь добавим в файл style.css новые стили

.form-signin {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
}

.form-signin .form-floating:focus-within {
z-index: 2;
}

.form-signin input[type="text"] {
margin-bottom: 1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}

.form-signin input[type="password"] {
margin-bottom: 1px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}

.form-signin button[type="submit"] {
margin-top: 10px;
}

.sign-in-body{
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
background-color: #f5f5f5;
}

Пропишем саму страницу регистрации. Для этого в папке шаблонов templates создадим папку auth, а в ней шаблон register.html.

cmd

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>

<body class="text-center sign-in-body well">

<main class="form-signin">
<well class="form-signin">
<form th:action="@{/auth/register}"
th:object="${registerForm}" method="POST">
<img class="mb-4" th:src="@{/img/bootstrap-logo.svg}" alt="" width="72" height="57">
<h1 th:utext="${title}">..!..</h1>
<p class="lead" th:utext="${message}">...!...</p>

<div class="form-floating">
<input type="text" class="form-control" id="floatingInput" placeholder="username"
th:field="*{username}">
<label for="floatingInput">Логин</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="floatingPassword" placeholder="qwerty"
th:field="*{password}">
<label for="floatingPassword">Пароль</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="floatingPasswordRep" placeholder="qwerty"
th:field="*{passwordConfirm}">
<label for="floatingPasswordRep">Повторите пароль</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="registerCode" placeholder="qwe123"
th:field="*{code}">
<label for="registerCode">Код регистрации*</label>
</div>

<button class="w-100 btn btn-lg btn-primary" type="submit">Зарегистрироваться</button>
<p class="mt-5 text-muted">*Если Вы не знаете код регистрации, оставьте
это поле пустым</p>
<p class="mt-5 mb-3 text-muted">© 2022</p>
</form>
<div th:if="${errorMessage}" th:utext="${errorMessage}" class="alert alert-danger" role="alert">
...!...
</div>
</well>
</main>

<div th:replace="fragments/footer :: footer"></div>

</body>
</html>

Перезапустите сервер и перейдите по адресу http://localhost:8080/auth/register

cmd

Введите данные и нажмите кнопку Зарегистрироваться. Если всё ок, то вас перенаправит на главную страницу.

Теперь откройте вкладку базы данных в Idea, а в ней выберите таблицу user_table.

cmd

В неё добавился пользователь. Обратите внимание: пароль не хранится впрямую, хранится его хэш. Именно для работы с паролями, как с хэшами, мы использовали BCryptPasswordEncoder.

Теперь проверим, что при регистрации роли тоже задаются правильно. Для этого откроем таблицу user_table_roles

cmd

Всё окей: пользователь, которого мы создали, имеет id, равный четырём. Правда, у вас id может отличаться. Главное, чтобы user_id в таблице связывания ролей user_table_roles был такой же.

Вход

Механизм авторизации в спринг прописан уже за нас. Достаточно просто добавить в контроллер авторизации AuthController метод

    /**
* Метод входа
*
* @return путь к шаблону
*/
@RequestMapping("/login")
public String login() {
return "auth/login";
}

Теперь необходимо добавить настройки в файл настроек безопасности WebSecurityConfig

    /**
* Задать настройки безопасности
*
* @param httpSecurity - объект веб-безопасности
* @throws Exception - исклбючение
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// не использовать csrf-токен
.csrf()
.disable()
// позволяет ограничивать запросы
.authorizeRequests()
//Доступ только для не зарегистрированных пользователей
.antMatchers("/auth/register").not().fullyAuthenticated()
//Доступ только для пользователей с ролью Администратор
.antMatchers("/admin/**","/taskList", "/tasks/**").hasRole("ADMIN")
//Доступ только для пользователей с ролью Пользователь
.antMatchers("/taskList","/task/**").hasRole("USER")
//Доступ разрешен всем
.antMatchers("/", "/js/**", "/css/**", "/img/**").permitAll()
//Все остальные страницы требуют аутентификации
.anyRequest().authenticated()
.and()
//Настройка для входа в систему
.formLogin()
// страница регистрации
.loginPage("/auth/login")
//Перенаправление на главную страницу после успешного входа
.defaultSuccessUrl("/taskList")
.permitAll()
.and()
// настройка выхода
.logout()
.permitAll()
.logoutSuccessUrl("/");
}

Теперь пропишем шаблон страницы входа

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>
<body class="text-center sign-in-body well">

<main class="form-signin">
<well class="form-signin">
<form th:action="@{/auth/login}" method="post">
<img class="mb-4" th:src="@{/img/bootstrap-logo.svg}" alt="" width="72" height="57">
<h1 th:utext="${title}">..!..</h1>
<p class="lead" th:utext="${message}">...!...</p>
<div class="form-floating">
<input class="form-control" type="text" id="username" name="username"
autofocus="autofocus"
placeholder="username"/>
<label for="username">Логин</label>
</div>
<div class="form-floating">
<input class="form-control" type="password" id="password" name="password"
placeholder="password"/>
<label for="password">Пароль</label>
</div>
<button class="w-100 btn btn-lg btn-primary" id="submit" type="submit">Войти</button>
<p class="mt-5 mb-3 text-muted">© 2022</p>
</form>
<div th:if="${errorMessage}" th:utext="${errorMessage}" class="alert alert-danger" role="alert">
...!...
</div>
<p th:if="${loginError}" class="error">Неправильно введён пользователь или пароль</p>
</well>
</main>

<div th:replace="fragments/footer :: footer"></div>

</body>
</html>

Т.к. этот шаблон используется спрингом в полуавтоматическом режиме, то id и name аргументы тегов управления формой надо задавать именно так, как я задал.

Перезапустите сервер и попробуйте открыть список задач http://localhost:8080/taskList

Т.к. эта страница доступна только авторизованным пользователям, то вас автоматически перебросит на страницу входа

cmd

Введите логин и пароль пользователя, которого вы зарегистрировали, и нажмите "Войти".

Если всё окей, вас перебросит на главную страницу. Теперь, если снова попробовать открыть список задач, то всё заработает, как раньше.

cmd

Это означает, что вы авторизованы. Ведь в настройках безопасности мы установили, что эта страница должна быть доступна только авторизованным пользователям.

Представление

Выход уже работает, достаточно просто перейти по адресу http://localhost:8080/logout. Если теперь вы попробуете снова открыть список задач, вас перебросит на страницу входа.

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

Чтобы выводить блок в зависимости от того, авторизован пользователь или нет, надо в оборачивающий его тег добавить аргумент sec:authorize="isAuthenticated()" - тогда блок будет выводиться только для авторизованных пользователей, sec:authorize="isAnonymous()" - только для анонимных.

Перепишем меню главной страницы

<nav class="nav nav-masthead justify-content-center float-md-end">
<a class="nav-link active" aria-current="page" href="#">Главная</a>
<a class="nav-link" th:href="@{/taskList}">Список задач</a>
<a class="nav-link" href="#">Контакты</a>
<a sec:authorize="isAnonymous()" class="nav-link" href="/auth/login">Войти</a>
<a sec:authorize="isAnonymous()" class="nav-link" href="/auth/register">Зарегистрироваться</a>
<a sec:authorize="isAuthenticated()" class="nav-link" href="/logout">Выйти</a>
</nav>

Теперь не авторизованные пользователи будут видеть главную страницу так:

cmd

А не авторизованные так:

cmd

Страница добавления списка доступна только зарегистрированным пользователям. Чтобы протестировать разное представление на шаблоне обычной страницы, добавим страницу контактов. Для этого в веб-контроллер WebController необходимо добавить метод

    /**
* Главная страница доступна по адресам `/` и `/index`,
*
* @param model - модель
* @return - возвращает путь к шаблону
*/
@GetMapping(value = {"/contact"})
public String contact(Model model) {
// задаём сообщение
model.addAttribute("message", CONTACT_PAGE_MESSAGE);
// задаём заголовок
model.addAttribute("title", CONTACT_PAGE_TITLE);
// возвращаем шаблон главной страницы
return "contact";
}

Также не забудьте прописать константы

    /**
* Заголовок на странице списка
*/
private static final String CONTACT_PAGE_TITLE = "Контакты";
/**
* Заголовок на странице списка
*/
private static final String CONTACT_PAGE_MESSAGE = "Эта страница - заглушка";

Помимо этого, необходимо добавить эту страницу в список разрешённых для всех. Для этого просто добавьте её адрес в список общедоступных в классе настроек безопасности WebSecurityConfig

    ...
//Доступ разрешен всем
.antMatchers("/", "/js/**", "/css/**", "/img/**", "/contact").permitAll()
...

Теперь поменяем ссылку в меню на главной странице

    ...
<a class="nav-link" href="/contact">Контакты</a>
...

Создадим папку pages в папке шаблонов, а в ней страницу контактов, которая будет просто копией taskEdit.html, просто без формы и с выделенным пунктом меню. Также перенесём туда все страницы работы с задачами.

cmd

Важно

Не забудьте поменять пути к шаблонам в контроллере WebController у соответствующих методов.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>
<body class="well d-flex flex-column min-vh-100">

<header class="p-3 bg-dark text-white">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none">
<svg class="bi me-2" width="40" height="32" role="img" aria-label="Bootstrap">
<use xlink:href="#bootstrap"></use>
</svg>
</a>

<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a href="/" class="nav-link px-2 text-white">Главная</a></li>
<li><a href="/taskList" class="nav-link px-2 text-white">Список задач</a></li>
<li><a href="/contact" class="nav-link px-2 text-secondary">Контакты</a></li>
</ul>

<button type="button" class="btn btn-outline-light me-2">Вход</button>
<button type="button" class="btn btn-warning">Регистрация</button>
</div>
</div>
</header>

<main>
<section class="py-5 text-center container">
<div class="row py-lg-5">
<div class="col-lg-6 col-md-8 mx-auto">
<h1 th:utext="${title}">..!..</h1>
<p class="lead text-muted" th:utext="${message}">..!..</p>
</div>
</div>
</section>
<div th:if="${errorMessage}" th:utext="${errorMessage}" class="alert alert-danger" role="alert">
...!...
</div>
</main>


<footer class="bg-dark text-center text-white mt-auto">
<!-- Grid container -->
<div class="container p-4 pb-0">
<!-- Section: CTA -->
<section class="">
<p class="d-flex justify-content-center align-items-center">
<span class="me-3">Это демонстрационный сайт </span>
<button type="button" class="btn btn-outline-light btn-rounded">
Регистрация
</button>
</p>
</section>
<!-- Section: CTA -->
</div>
<!-- Grid container -->

<!-- Copyright -->
<div class="text-center p-3" style="background-color: rgba(0, 0, 0, 0.2);">
© 2022 Copyright:
<a class="text-white" href="https://ege.buran.rest/docs/project/web/view">Буран</a>
</div>
<!-- Copyright -->

<div th:replace="fragments/footer :: footer"></div>
</footer>


</body>
</html>

cmd

Страница загрузится как надо. Правда, во всех трёх шаблонах contact.html, taskEdit.html и taskList.html абсолютно одинаковый код подвала. Поэтому напишем новый фрагмент подвала неглавной страницы и сделаем импорт его в каждом из рассматриваемых шаблонов.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<footer th:fragment="footer" class="bg-dark text-center text-white mt-auto">

<!-- Grid container -->
<div class="container p-4 pb-0">
<!-- Section: CTA -->
<section class="">
<p class="d-flex justify-content-center align-items-center">
<span class="me-3">Это демонстрационный сайт </span>
<button type="button" class="btn btn-outline-light btn-rounded">
Регистрация
</button>
</p>
</section>
<!-- Section: CTA -->
</div>
<!-- Grid container -->

<!-- Copyright -->
<div class="text-center p-3" style="background-color: rgba(0, 0, 0, 0.2);">
© 2022 Copyright:
<a class="text-white" href="https://ege.buran.rest/docs/project/web/view">Буран</a>
</div>
<!-- Copyright -->

<div th:replace="fragments/footer :: footer"></div>
</footer>
</html>

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

Помимо кнопки выхода я добавил вывод имени пользователя, если он авторизован.

cmd

Пользовательские списки

Сейчас каждый авторизованный пользователь видит все задача, да и сами задачи не связаны с конкретным пользователем. Перепишем логику работы сайта так, чтобы пользователь мог видеть только свои задачи, а при добавлении не нужно было бы указывать имя пользователя, ведь пользователь авторизован, его имя мы можем получить автоматически.

Модель

Когда мы прописывали отношения пользователей и ролей, мы использовали связь "Многие ко многим"(ManyToMany). Это позволяло связать любого пользователя с любым набором ролей, но пришлось создать дополнительную таблицу связывания.

У отношений задач и пользователей другая суть: у каждой задачи указывается ровно один пользователь. Тогда нам не нужна дополнительная таблица, достаточно просто хранить вместо имени пользователя его id. Такое отношение называется один ко многим (OneToMany)

Колонки таблицы, конечно можно изменить вручную, но это сложно. Проще просто удалить её и создать новую с новым набором колонок(полей).

Чтобы удалить таблицу, нужно кликнуть по ней правой кнопкой мыши и выбрать пункт Drop.

cmd

Нам надо удалить таблицу tasks_table

cmd

В новом окне нажмите OK

Создадим таблицу заново

cmd

Теперь заменим поле автора в классе задач Task

    ...
/**
* Автор
*/
@ManyToOne
@JoinColumn(name="user_id", nullable=false)
private User author;
...

Также необходимо прописать ответную часть в классе User:

    /**
* Задачи пользователя
*/
@Transient
@OneToMany(mappedBy="author", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Tasks> tasks;

Контроллер

Дл я начала уберём из формы TaskForm поле пользователя, ведь теперь он будет определяться автоматически.

package com.example.demo.form;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* Форма добавления задачи
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TaskForm {
/**
* Заголовок
*/
private String title;
/**
* Текст
*/
private String text;
}

Перепишем метод, отвечающий за отображение страницы списка задач в WebController

    /**
* Cтраница списка задач доступна по адресу `taskList`,
*
* @param model - модель
* @param authentication - данные аутентификации
* @return - возвращает путь к шаблону
*/
@GetMapping(value = {"/taskList"})
public String tasksList(Model model, Authentication authentication) {
// получаем пользователя
User currentUser = (User) authentication.getPrincipal();

// если он существует
if (currentUser != null) {
// читаем все задачи пользователя
final List<Tasks> tasks = tasksService.readAll().stream().filter(
t -> t.getAuthor().getId().equals(currentUser.getId())
).toList();
// добавляем множество его задач
model.addAttribute("tasks", tasks);
} else
// иначе добавляем пустое множество
model.addAttribute("tasks", new HashSet<>());
// задаём сообщение
model.addAttribute("message", LIST_PAGE_MESSAGE);
// задаём заголовок
model.addAttribute("title", LIST_PAGE_TITLE);
// добавляем фому
TaskForm taskForm = new TaskForm();
model.addAttribute("taskForm", taskForm);
return "pages/taskList";
}

Чтобы получить пользователя, выполняющего запрос, в аргументы метода необходимо добавить объект Authentication. В нём передаётся вся информация о аутентификации.

Чтобы получить из него пользователя, используйте команду

        // получаем пользователя
User currentUser = (User) authentication.getPrincipal();
о правильной работе с БД

Для извлечения всех записей здесь используется неоптимальный код. Правильнее было бы прописать напрямую запрос базе данных, чтобы она выдала нам только задачи, имеющие заданный id пользователя, но прямые запросы к PostgreSQL мы здесь разбирать не будем, да и количество задач незначительно.

В такой же логике перепишем остальные методы:

    /**
* Изменить задачу
*
* @param model - модель
* @param taskForm - форма с задачей
* @return - возвращает путь к шаблону
*/
@PostMapping(value = {"/task/{id}"})
public String saveTask(Model model, @ModelAttribute("taskForm") TaskForm taskForm,
@PathVariable(name = "id") int id, Authentication authentication) {
// получаем пользователя
User currentUser = (User) authentication.getPrincipal();
String text = taskForm.getText();
String title = taskForm.getTitle();

// если все элементы формы получены и непустые
if (currentUser != null && text != null && text.length() > 0 && title != null && title.length() > 0) {
// создаём новую задачу
Tasks task = new Tasks();
task.setAuthor(currentUser);
task.setText(text);
task.setTitle(title);
// добавляем задачу в БД
tasksService.update(task, id);
// переходим к списку задач
return "redirect:/taskList";
}

model.addAttribute("errorMessage", "Форма заполнена некорректно");
return "redirect:/taskList";
}

/**
* Добавить задачу
*
* @param model - модель
* @param taskForm - форма с новой задачей
* @return - возвращает путь к шаблону
*/
@PostMapping(value = {"/addTask"})
public String savePerson(Model model, @ModelAttribute("taskForm") TaskForm taskForm, Authentication authentication) {
// получаем пользователя
User currentUser = (User) authentication.getPrincipal();
// получаем значения из формы
String text = taskForm.getText();
String title = taskForm.getTitle();

// если все элементы формы получены и непустые
if (currentUser != null && text != null && text.length() > 0 && title != null && title.length() > 0) {
// создаём новую задачу
Tasks task = new Tasks();
task.setAuthor(currentUser);
task.setText(text);
task.setTitle(title);
// добавляем задачу в БД
tasksService.create(task);
// переходим к списку задач
return "redirect:/taskList";
}

model.addAttribute("errorMessage", "Форма заполнена некорректно");
return "redirect:/taskList";
}

Представление

Теперь удалим поле автора у форм на страницах taskEdit.html и taskList.html

cmd

Также в шаблоне списка задач я поменял вывод информации об авторе

    <table class="table table-striped">
<thead>
<tr>
<th>Заголовок</th>
<th>Текст</th>
<th>Автор</th>
<th>Удаление</th>
<th>Редактирование</th>
</tr>
</thead>
<tr th:each="task : ${tasks}">
<td th:utext="${task.title}">...</td></a>
<td th:utext="${task.text}">...</td>
<td th:utext="${task.author.username}">...</td>
<td>
<button class="btn btn-danger btn-delete" th:id="${task.id}">Удалить</button>
</td>
<td>
<a th:href="@{/task/{id}(id=${task.id})}">
<button class="btn btn-info">Редактировать</button>
</a>
</td>
</tr>
</table>

Перезапустите сервер и добавьте задачу

cmd

Всё работает как надо

cmd

Я зарегистрировал второго пользователя u2, и добавил от его имени две задачи

cmd

В списке пользователь u4 видит только свои задачи

Администратор

Теперь нам осталось написать логику работы администратора. Чтобы пользователь стал администратором, он должен будет при регистрации ввести специальный код, после этого ему в списке задач должны будут доступны все, а не только пользовательские.

Для начала добавьте запись с кодом регистрации администратора в application.properties в папке resources

cmd

Для начала нам нужно переписать метод сохранения пользователей так, чтобы указывать не только пользователя, но и роль.

    /**
* Сохранить пользователя
*
* @param user - пользователь
* @param role - роль
* @return флаг, получилось ли сохранить
*/
public boolean saveUser(User user, Role role) {
User userFromDB = userRepository.findByUsername(user.getUsername());

if (userFromDB != null) {
return false;
}

user.setRoles(Collections.singleton(role));
user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
userRepository.save(user);
return true;
}

Теперь добавим поле в контроллере авторизации

    /**
* Код регистрации администратора
*/
@Value("${admin.registerCode}")
private String adminRegisterCode;

И немного перепишем метод регистрации

    /**
* POST-запрос регистрации
*
* @param registerForm - форма регистрации
* @param bindingResult - результат связывания
* @param model - модель
* @return путь к шаблону
*/
@PostMapping("/register")
public String addUser(@ModelAttribute("registerForm") RegisterForm registerForm, BindingResult bindingResult, Model model) {
// если получены ошибки при связывании
if (bindingResult.hasErrors()) {
return "auth/register";
}
// если пароли не совпадают
if (!registerForm.getPassword().equals(registerForm.getPasswordConfirm())) {
model.addAttribute("errorMessage", "Пароли не совпадают");
return "auth/register";
}
// создаём пользователя
User user = new User();
user.setPassword(registerForm.getPassword());
user.setUsername(registerForm.getUsername());
// определяем роль пользователя
Role role;
// если код регистрации администратора введён верно
if (adminRegisterCode.equals(registerForm.getCode()))
role = new Role(1L, "ROLE_ADMIN");
else
role = new Role(2L, "ROLE_USER");
// если не получилось создать пользователя в БД
if (!userService.saveUser(user, role)) {
model.addAttribute("errorMessage", "Пользователь с таким именем уже существует");
return "auth/register";
}

return "redirect:/";
}

Теперь немного изменим настройки безопасности WebSecurityConfig так, чтобы список задач был доступен всем авторизированным пользователям

    //Доступ только для всех авторизованных пользователей
.antMatchers("/taskList", "/task/**").authenticated()

Чтобы отображать все задачи, если пользователь - администратор, нужно немного переписать метод формирования списка в WebController

     /**
* Cтраница списка задач доступна по адресу `taskList`,
*
* @param model - модель
* @param authentication - данные аутентификации
* @return - возвращает путь к шаблону
*/
@GetMapping(value = {"/taskList"})
public String tasksList(Model model, Authentication authentication) {
// получаем пользователя
User currentUser = (User) authentication.getPrincipal();

// если он существует
if (currentUser != null) {
List<Tasks> tasks = null;
// перебираем роли
for (Role role : currentUser.getRoles())
// если роль - администратор
if (role.getName().equals("ROLE_ADMIN"))
// отправляем все задачи
tasks = tasksService.readAll();
// если задачи не заполнены
if (tasks == null)
// заполняем задачами конкретного пользователя
tasks = tasksService.readAll().stream().filter(
t -> t.getAuthor().getId().equals(currentUser.getId())
).toList();
// добавляем множество его задач
model.addAttribute("tasks", tasks);
} else
// иначе добавляем пустое множество
model.addAttribute("tasks", new HashSet<>());
// задаём сообщение
model.addAttribute("message", LIST_PAGE_MESSAGE);
// задаём заголовок
model.addAttribute("title", LIST_PAGE_TITLE);
// добавляем фому
TaskForm taskForm = new TaskForm();
model.addAttribute("taskForm", taskForm);
return "pages/taskList";
}

Перезапустите сервер и создаёте пользователя с правами администратора. Теперь если зайти через него на страницу списка, в нём отобразятся все задачи

cmd

У первого пользователя всё ещё отображается только его задача. Это значит, что логика ролей пользователя работает так, как мы планировали.

cmd

REST

Мы полностью прописали защиту данных при работе через браузер, но у нас остались открытыми запросы к REST контроллеру. Чтобы управлять доступом к REST запросам, используется принцип токена.

Первым запросом по логину и паролю клиент должен получить его уникальный токен, а потом в каждом запросе передавать его.

Для начала добавьте в pom.xml зависимость

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

и пересоберите проект maven. Эта зависимость позволит формировать токены.

Теперь пропишем репозиторий токенов. Для этого в папке репозиториев создадим новый класс JwtTokenRepository

cmd

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

Но для серьёзных проектов необходимо создавать ключ для каждого пользователя отдельно.

Контроллер

package com.example.demo.repository;

import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Getter;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.stereotype.Repository;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.Objects;
import java.util.UUID;

import static org.springframework.http.HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS;

/**
* Репозиторий токенов
*/
@Repository
public class JwtTokenRepository implements CsrfTokenRepository {
/**
* секретный код шифрования
*/
@Getter
private final String secret;

/**
* Конструктор
*/
public JwtTokenRepository() {
this.secret = "springrest";
}

/**
* Функция-генератор токена
*
* @param httpServletRequest - данные запроса
* @return CSRF токен
*/
@Override
public CsrfToken generateToken(HttpServletRequest httpServletRequest) {
// получаем случайную строку с id
String id = UUID.randomUUID().toString().replace("-", "");
// определяем время жизни токена: он будет актуален 30 минут
Date now = new Date();
Date exp = Date.from(LocalDateTime.now().plusMinutes(30)
.atZone(ZoneId.systemDefault()).toInstant());

// рассчитываем токен
String token = "";
try {
token = Jwts.builder()
.setId(id)
// время создания
.setIssuedAt(now)
.setNotBefore(now)
// дата сгорания
.setExpiration(exp)
// шифрование
.signWith(SignatureAlgorithm.HS256, secret)
// сборка
.compact();
} catch (JwtException e) {
e.printStackTrace();
}
// возвращаем новый токен
return new DefaultCsrfToken("x-csrf-token", "_csrf", token);
}

/**
* Сохранение токена
*
* @param csrfToken - токен
* @param request - запрос
* @param response - ответ
*/
@Override
public void saveToken(CsrfToken csrfToken, HttpServletRequest request, HttpServletResponse response) {
// если токен задан
if (Objects.nonNull(csrfToken)) {
// если в заголовке ответа не содержится нужный заголовок
if (!response.getHeaderNames().contains(ACCESS_CONTROL_EXPOSE_HEADERS))
// добавляем его
response.addHeader(ACCESS_CONTROL_EXPOSE_HEADERS, csrfToken.getHeaderName());
// если в заголовке ответа содержится имя токена
if (response.getHeaderNames().contains(csrfToken.getHeaderName()))
// задаём значение токена
response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
else
// иначе просто добавляем
response.addHeader(csrfToken.getHeaderName(), csrfToken.getToken());
}
}

/**
* Загрузить токен
*
* @param request - запрос
* @return токен
*/
@Override
public CsrfToken loadToken(HttpServletRequest request) {
return (CsrfToken) request.getAttribute(CsrfToken.class.getName());
}

/**
* Очистить токен
*
* @param response - ответ
*/
public void clearToken(HttpServletResponse response) {
if (response.getHeaderNames().contains("x-csrf-token"))
response.setHeader("x-csrf-token", "");
}
}

Чтобы защитить REST-запросы, нам нужен будет фильтр, а он в свою очередь требует обработчика исключений.

Роль обработчика исключений у нас будет играть класс GlobalExceptionHandler

cmd

package com.example.demo.controller;

import com.example.demo.repository.JwtTokenRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
import org.springframework.security.web.csrf.MissingCsrfTokenException;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import javax.naming.AuthenticationException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
* Обработчик исключений
*/
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
/**
* Репозиторий токенов
*/
private final JwtTokenRepository tokenRepository;

/**
* Конструктор обработчика исключений
*
* @param tokenRepository - репозиторий токенов
*/
@Autowired
public GlobalExceptionHandler(JwtTokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
}

/**
* Обработчик исключений
*
* @param ex - исключение
* @param request - запрос
* @param response - ответ
* @return структура с информацией об ошибке
*/
@ExceptionHandler({AuthenticationException.class, MissingCsrfTokenException.class, InvalidCsrfTokenException.class, SessionAuthenticationException.class})
public ErrorInfo handleAuthenticationException(RuntimeException ex, HttpServletRequest request, HttpServletResponse response) {
this.tokenRepository.clearToken(response);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return new ErrorInfo(UrlUtils.buildFullRequestUrl(request), "error.authorization");
}

/**
* Структура для обработки исключений
*/
public record ErrorInfo(String url, String info) {
}
}

В класс REST-контроллера добавим метод обработки авторизации

    /**
* Авторизация для REST-запросов
*
* @return пользователя
*/
@PostMapping(path = "/login", produces = MediaType.APPLICATION_JSON_VALUE)
public @ResponseBody
User getAuthUser() {
// получаем объект авторизации
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// если его нет, возвращаем null
if (auth == null) {
return null;
}
// получаем ссылку на пользователя из запроса
Object principal = auth.getPrincipal();
// если объект является экземпляром класса User, сохраняем его
User user = (principal instanceof User) ? (User) principal : null;
// возвращаем пользователя
return Objects.nonNull(user) ? (User) this.userService.loadUserByUsername(user.getUsername()) : null;
}

Также в REST контроллер необходимо добавить поле

    /**
* Сервис задач
*/
private final UserService userService;

И прописать связывание через конструктор

    /**
* Конструктор контроллера задач
*
* @param tasksService сервис задач
*/
@Autowired
public TasksController(TasksService tasksService, UserService userService) {
this.tasksService = tasksService;
this.userService = userService;
}

Раньше удаление задач выполнялось через REST-запрос, но теперь для его формирования необходимо использовать токен, проще дописать второй метод в веб-контроллере и вызывать его через форму с POST запросом.

Поэтому добавим в контроллер WebController метод удаления задачи по POST-запросу

    /**
* Удалить задачу
*
* @param id - id задачи
* @param authentication - данные аутентификации
* @return - ответ на REST запрос
*/
@PostMapping(value = "/deleteTask/{id}")
public String delete(@PathVariable(name = "id") int id, Authentication authentication) {
// получаем пользователя
User currentUser = (User) authentication.getPrincipal();
// если id автора задачи и пользователя совпадают и получилось удалить задачу
if (tasksService.read(id).getAuthor().getId().equals(currentUser.getId()) && tasksService.delete(id))
log.info("Задача с id=" + id + " удалена");
// переходим к списку задач
return "redirect:/taskList";
}

За счёт двойного амперсанта(укороченного И) && команда на удаление будет запускаться только если id пользователя и id автора задач совпадают

Теперь пропишем фильтр запросов. Для этого в пакете config создадим класс JwtCsrfFilter

cmd

package com.example.demo.config;

import com.example.demo.repository.JwtTokenRepository;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
import org.springframework.security.web.csrf.MissingCsrfTokenException;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.HandlerExceptionResolver;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
* Фильтр запросов для поддержки работы токенов
*/
public class JwtCsrfFilter extends OncePerRequestFilter {
/**
* Репозиторий токенов
*/
private final CsrfTokenRepository tokenRepository;
/**
* Обработчик исключений
*/
private final HandlerExceptionResolver resolver;

/**
* Конструктор фильтра
*
* @param tokenRepository - репозиторий токенов
* @param resolver - обработчик исключений
*/
public JwtCsrfFilter(CsrfTokenRepository tokenRepository, HandlerExceptionResolver resolver) {
this.tokenRepository = tokenRepository;
this.resolver = resolver;
}

/**
* Фильтрация запросов
*
* @param request - запрос
* @param response - ответ
* @param filterChain - объект фильтра
* @throws ServletException - исключения сервера
* @throws IOException - исключения ввода/вывода
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// задаём запросу объект ответа
request.setAttribute(HttpServletResponse.class.getName(), response);
// получаем токен
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
// если токен утерян
boolean missingToken = csrfToken == null;
if (missingToken) {
// получаем новый
csrfToken = this.tokenRepository.generateToken(request);
// сохраняем
this.tokenRepository.saveToken(csrfToken, request, response);
}
// сохраняем токен в запросе
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);

// пропускаем дальше все запросы, которые начинаются с `/tasks/`, если только это не запрос на регистрацию
if (!request.getServletPath().startsWith("/tasks/") || request.getServletPath().equals("/tasks/login")) {
try {
// фильтруем запрос
filterChain.doFilter(request, response);
} catch (Exception e) {
// обрабатываем исключения
resolver.resolveException(request, response, null, new MissingCsrfTokenException(csrfToken.getToken()));
}
} else {
// получаем текущий токен
String actualToken = request.getHeader(csrfToken.getHeaderName());
// если токен утерян
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
try {
//пробуем получить его
if (!StringUtils.isEmpty(actualToken)) {
// по секретному ключу расшифровываем токен
Jwts.parser()
.setSigningKey(((JwtTokenRepository) tokenRepository).getSecret())
.parseClaimsJws(actualToken);
// выполняем фильтрацию
filterChain.doFilter(request, response);
} else
// обрабатываем исключение
resolver.resolveException(request, response, null, new InvalidCsrfTokenException(csrfToken, actualToken));
} catch (JwtException e) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request));
}

if (missingToken) {
resolver.resolveException(request, response, null, new MissingCsrfTokenException(actualToken));
} else {
resolver.resolveException(request, response, null, new InvalidCsrfTokenException(csrfToken, actualToken));
}
}
}
}
}

Теперь немного перепишем метод конфигурации в WebSecurityConfig

   /**
* Задать настройки безопасности
*
* @param httpSecurity - объект веб-безопасности
* @throws Exception - исклбючение
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.NEVER)
.and()
.addFilterAt(new JwtCsrfFilter(jwtTokenRepository, resolver), CsrfFilter.class)
// не использовать csrf-токен
.csrf().ignoringAntMatchers("/**")
.and()
// позволяет ограничивать запросы
.authorizeRequests()
//Доступ только для не зарегистрированных пользователей
.antMatchers("/auth/register", "/auth/login").not().fullyAuthenticated()
//Доступ только для пользователей с ролью Администратор
.antMatchers("/admin/**").hasRole("ADMIN")
//Доступ только для всех авторизованных пользователей
.antMatchers("/taskList", "/tasks/**").authenticated()
//Доступ разрешен всем
.antMatchers("/", "/js/**", "/css/**", "/img/**", "/contact").permitAll()
//Все остальные страницы требуют аутентификации
.anyRequest().authenticated()
.and()
// Настройка для входа в систему
.formLogin()
// страница регистрации
.loginPage("/auth/login")
//Перенаправление на главную страницу после успешного входа
.defaultSuccessUrl("/")
.permitAll()
.and()
// настройка выхода
.logout()
.permitAll()
.logoutSuccessUrl("/").and()
.httpBasic()
.authenticationEntryPoint(((request, response, e) -> resolver.resolveException(request, response, null, e)));;
}

Представление

Теперь нам осталось переписать кнопку удаления в шаблоне списка задач taskList.html

<table class="table table-striped">
<thead>
<tr>
<th>Заголовок</th>
<th>Текст</th>
<th>Автор</th>
<th>Удаление</th>
<th>Редактирование</th>
</tr>
</thead>
<tr th:each="task : ${tasks}">
<td th:utext="${task.title}">...</td></a>
<td th:utext="${task.text}">...</td>
<td th:utext="${task.author.username}">...</td>
<td>
<form th:action="@{/deleteTask/{id}(id=${task.id})}" method="POST">
<button class="btn btn-danger btn-delete" th:id="${task.id}">Удалить</button>
</form>
</td>
<td>
<a th:href="@{/task/{id}(id=${task.id})}">
<button class="btn btn-info">Редактировать</button>
</a>
</td>
</tr>
</table>

Также не забудьте удалить скрипт удаления по клику внизу файла

Проверка

Запустим снова программу Postman

Чтобы авторизироваться с помощью REST API, у POST запроса выберите вкладку авторизация и введите свои логин и пароль, после чего нажмите на кнопку Send

cmd

Если логин и пароль введены правильно, вы получите ответ

cmd

Теперь перейдите во вкладку Headers и найдите пункт x-csrf-token, скопируйте его

cmd

и при формировании новых REST запросов просто добавьте его значение с ключом X-CSRF-TOKEN

cmd

Теперь если отправить REST запрос с токеном, мы получим список задач

cmd

Возможно, сервер в первый раз не ответит и по токену выдаст пустой ответ, тогда нужно просто ещё раз отправить запрос на вход, и всё будет ок.

Если параметр токена отключить и попробовать снова, в ответ мы получим ошибку авторизации

cmd

Самостоятельная работа

У REST контроллера у каждого пользователя всё ещё есть доступ ко всем задачам. Перепишите его так, чтобы только администратор имел доступ ко всем задачам, а рядовой пользователь - только к своим.

Развёртывание

Когда мы устанавливаем наше приложение на сервер, это называется развёртывание(deploy)

В нашем сервере мы используем 17 версию java. Чтобы Heroku об этом узнал, необходимо добавить в корень проекта файл system.properties

cmd

а в нём написать всего одну строчку:

java.runtime.version=17.0.2

cmd

Нам необходимо как-то передать heroku данные для подключения к базе. Добавлять в git файл настнройки неправильно. Для решения этой задачи мы создадим два разных файла настроек: один для локального запуска, он будет иметь постфикс -dev от слова develop - разработка и второй для публикации в репозитории с постфиксом -prod от слова production - поставка.

Чтобы управлять тем, какие именно настройки нужно использовать, мы уберём файл application.properties из игнор-списка, но оставим в нём всего одну строчку (скопируйте куда-нибудь содержимое, оно нам скоро пригодится)

spring.profiles.active=dev

Она говорит, что при сборке необходимо использовать файл настроек с постфиксом -dev, если мы поменяем её на

spring.profiles.active=prod

то будет загружаться файл настроек с постфиксом prod.

Создадим эти файлы и application-dev.properties добавим в игнорлист, в него запишем всё, что было раньше в application.properties.

cmd

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

В файле настроек application-prod.properties мы укажем только названия этих переменных, а зададим их значения в настройках приложения heroku.

spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driverClassName=org.postgresql.Driver

spring.thymeleaf.cache=false

admin.registerCode = ${ADMIN_REGISTER_CODE}

Чтобы обратиться из настроек к переменным среды, используйте выражение вида ${VAR_NAME}, где VAR_NAME - имя переменной

Теперь зададим необходимые переменные среды в настройках приложения. Для этого перейдите в раздел настроек Settings и нажмите на кнопку Reveal Config Vars

cmd

В открывшемся блоке добавьте переменную и нажмите Add

cmd

Заполните все необходимые значения

cmd

Важно

Не забывайте менять режим сборки приложения на dev в настройках application.properties, если хотите запустить приложение локально и prod если отправляете его на github для развёртывания. В противном случае либо сайт не будет работать, либо ваш локально развёрнутый сервер.

Теперь можно настроить саму развёртку.

Heroku умеет развёртывать приложения в автоматическом режиме. Для этого достаточно просто связать его приложение в репозиторием github. Чтобы это сделать, сначала войдите в аккаунт гитхаба, потом откройте вкладку Deploy в приложении, после чего нажмите Connect to Github

cmd

Если вы уже авторизовались на гитхабе, подключение аккаунта выполнится автоматически, вам нужно лишь ввести один или несколько символов, которые содержатся в названии репозитория и выбрать его из списка

cmd

После чего нажмите Connect

cmd

После связывания просто нажмите Deploy Branch. Некоторое время heroku потратит на сборку приложения

cmd

После чего выведет сообщение об удачной сборке, если всё окей.

cmd

Если никаких проблем нет, то сервер поднимется

cmd

И теперь сайт будет доступен по адресу https://appname.herokuapp.com, где app-name - название вашего приложения. У меня оно назвается buran-tbot, поэтому его адрес будет https://buran-tbot.herokuapp.com

Попробуйте открыть ваш сайт.

cmd

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

Скачать исходники можно здесь. Дамп базы данных dump.sql лежит в корне.