Авторизация
В этой главе мы пропишем авторизацию и защитим REST-контроллер от несанкционированного доступа. Также мы настроем развёртывание приложения на облачной платформе Heroku.
Этот проект - продолжение проекта из предыдущей главы. Если у вас его нет, можете скачать исходники предыдущего проекта здесь.
Дамп базы данных лежит в корне. Дампом называется бэкап базы данных.
Скачать исходники можно здесь. Дамп базы
данных 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
Теперь откройте вкладку Maven
И нажмите кнопку обновления
Модель
Для авторизации нам понадобится концепция пользователей и ролей. Чтобы разграничить права доступа к тем или иным страницам, запросам, необходимо создать роли и связать возможности доступа именно с ролью, а потом пользователю просто присваивается набор ролей, в соответствии с котором уже определяются права доступа для конкретного пользователя.
Роли
Для работы с ролями, необходимо реализовать в классе интерфейс GrantedAuthority
. Создадим
для этого класс Role
.
- Role.java
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, не забудьте проставить все галочки
Теперь добавим второе поле названия и нажмём Execute
Теперь добавим две записи ролей с помощью кнопки с плюсом +
Теперь жмём на зелёную стрелочку, после обработки все изменения запишутся в базу данных и не будут отображаться зелёным
Пользователи
Теперь создадим сущность пользователя. Для этого добавим класс User
в пакет entities
.
Он должен расширять интерфейс UserDetails
. Пропишем сам класс
- User.java
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
говорит о том, что это поле не нужно использовать при работе с базой данных
(нет соответствующей колонки).
Правда, у ролей, т.к. это ответная часть, не указывается список удаления, а указывается, с каким полем коллекция будет связана.
Теперь создадим таблицу пользователей
и таблицу связывания ролей
Её название составляется из названия таблицы с полем и названия поля, которое мы указали в ответной части.
Контроллер
Теперь пропишем контроллер.
Репозитории
Для начала пропишем репозитории доступа к новым сущностям.
Для этого добавим
в пакет repository
интерфейсы UserRepository
и RoleRepository
.
Теперь пропишем сами интерфейсы:
- UserRepository.java
- RoleRepository.java
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);
}
package com.example.demo.repository;
import com.example.demo.entities.Role;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* Репозиторий ролей
*/
public interface RoleRepository extends JpaRepository<Role, Long> {
}
Сервисы
Теперь пропишем сервис для работы с пользователем. Для этого в пакете service
добавим сервис
пользователя UserService
- UserService.java
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;
}
}
Регистрация
В первую очередь нам необходимо создать форму регистрации
- RegisterForm.java
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
Он будет обрабатывать пути, начинающиеся на /auth
- AuthController.java
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
- WebSecurityConfig.java
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
и сохранить картинку туда.
Теперь добавим в файл 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
.
- register.html
<!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
Введите данные и нажмите кнопку Зарегистрироваться
. Если всё ок, то вас перенаправит на главную страницу.
Теперь откройте вкладку базы данных в Idea, а в ней выберите таблицу user_table
.
В неё добавился пользователь. Обратите внимание: пароль не хранится впрямую, хранится его
хэш. Именно для работы с паролями, как с хэшами, мы использовали BCryptPasswordEncoder
.
Теперь проверим, что при регистрации роли тоже задаются правильно. Для этого откроем таблицу user_table_roles
Всё окей: пользователь, которого мы создали, имеет 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("/");
}
Теперь пропишем шаблон страницы входа
- login.html
<!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
Т.к. эта страница доступна только авторизованным пользователям, то вас автоматически перебросит на страницу входа
Введите логин и пароль пользователя, которого вы зарегистрировали, и нажмите "Войти".
Если всё окей, вас перебросит на главную страницу. Теперь, если снова попробовать открыть список задач, то всё заработает, как раньше.
Это означает, что вы авторизованы. Ведь в настройках безопасности мы установили, что эта страница должна быть доступна только авторизованным пользователям.
Представление
Выход уже работает, достаточно просто перейти по адресу 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>
Теперь не авторизованные пользователи будут видеть главную страницу так:
А не авторизованные так:
Страница добавления списка доступна только зарегистрированным пользователям. Чтобы протестировать
разное представление на шаблоне обычной страницы, добавим страницу контактов. Для этого в веб-контроллер
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
, просто без формы и с выделенным пунктом меню. Также
перенесём туда все страницы работы с задачами.
Не забудьте поменять пути к шаблонам в контроллере WebController
у соответствующих методов.
- contact.html
<!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>
Страница загрузится как надо. Правда, во всех трёх шаблонах contact.html
, taskEdit.html
и
taskList.html
абсолютно одинаковый код подвала. Поэтому напишем новый фрагмент подвала
неглавной страницы и сделаем импорт его в каждом из рассматриваемых шаблонов.
- non-main-footer.html
- 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>
<!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>
<a sec:authorize="isAnonymous()" class="nav-link" href="/auth/login">
<button type="button" class="btn btn-outline-light me-2">Вход</button>
</a>
<a sec:authorize="isAnonymous()" class="nav-link" href="/auth/register">
<button type="button" class="btn btn-warning">Регистрация</button>
</a>
<a sec:authorize="isAuthenticated()" class="nav-link px-2 text-white" href="/taskList"
th:text="${#request.userPrincipal.getName()}">
</a>
<a sec:authorize="isAuthenticated()" class="nav-link" href="/logout">
<button type="button" class="btn btn-outline-light me-2">Выход</button>
</a>
</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 th:replace="fragments/non-main-footer :: footer"></footer>
</body>
</html>
<!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-white">Контакты</a></li>
</ul>
<a sec:authorize="isAnonymous()" class="nav-link" href="/auth/login">
<button type="button" class="btn btn-outline-light me-2">Вход</button>
</a>
<a sec:authorize="isAnonymous()" class="nav-link" href="/auth/register">
<button type="button" class="btn btn-warning">Регистрация</button>
</a>
<a sec:authorize="isAuthenticated()" class="nav-link px-2 text-white" href="/taskList"
th:text="${#request.userPrincipal.getName()}">
</a>
<a sec:authorize="isAuthenticated()" class="nav-link" href="/logout">
<button type="button" class="btn btn-outline-light me-2">Выход</button>
</a>
</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>
<form th:action="@{/task/{id}(id=${taskId})}"
th:object="${taskForm}" method="POST">
<table align="center">
<tr>
<th>Заголовок</th>
<td>
<input class="form-control" type="text" th:field="*{title}"/>
</td>
</tr>
<tr>
<th>Текст</th>
<td>
<input class="form-control" type="text" th:field="*{text}"/>
</td>
</tr>
<tr>
<th>Автор</th>
<td>
<input class="form-control" type="text" th:field="*{author}"/>
</td>
</tr>
<tr>
<td colspan="2" align="center">
</td>
</tr>
</table>
<br>
<input type="submit" class="btn btn-primary" value="Изменить"/>
</form>
</div>
</div>
</section>
<div th:if="${errorMessage}" th:utext="${errorMessage}" class="alert alert-danger" role="alert">
...!...
</div>
</main>
<footer th:replace="fragments/non-main-footer :: footer"></footer>
</body>
</html>
<!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-secondary">Список задач</a></li>
<li><a href="/contact" class="nav-link px-2 text-white">Контакты</a></li>
</ul>
<a sec:authorize="isAnonymous()" class="nav-link" href="/auth/login">
<button type="button" class="btn btn-outline-light me-2">Вход</button>
</a>
<a sec:authorize="isAnonymous()" class="nav-link" href="/auth/register">
<button type="button" class="btn btn-warning">Регистрация</button>
</a>
<a sec:authorize="isAuthenticated()" class="nav-link px-2 text-white" href="/taskList"
th:text="${#request.userPrincipal.getName()}">
</a>
<a sec:authorize="isAuthenticated()" class="nav-link" href="/logout">
<button type="button" class="btn btn-outline-light me-2">Выход</button>
</a>
</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>
<p>
<button type="button" class="btn btn-primary my-2" data-bs-toggle="modal"
data-bs-target="#exampleModal">
Добавить задачу
</button>
<a href="/" class="btn btn-secondary my-2">На главную</a>
</p>
</div>
</div>
</section>
<div class="album py-5 bg-light">
<div class="container">
<div class="row" >
<div class="col-8">
<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}">...</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>
</div>
<div class="col">
</div>
</div>
</div>
</div>
<div th:if="${errorMessage}" th:utext="${errorMessage}" class="alert alert-danger" role="alert">
...!...
</div>
</main>
<footer th:replace="fragments/non-main-footer :: footer"></footer>
<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form th:action="@{/addTask}"
th:object="${taskForm}" method="POST">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">Новая задача</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<table align="center">
<tr>
<th>Заголовок</th>
<td>
<input class="form-control" type="text" th:field="*{title}"/>
</td>
</tr>
<tr>
<th>Текст</th>
<td>
<input class="form-control" type="text" th:field="*{text}"/>
</td>
</tr>
<tr>
<th>Автор</th>
<td>
<input class="form-control" type="text" th:field="*{author}"/>
</td>
</tr>
<tr>
<td colspan="2" align="center">
</td>
</tr>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<input type="submit" class="btn btn-primary" value="Добавить"/>
</div>
</form>
</div>
</div>
</div>
<script>
$(document).ready(function () {
// здесь код скрипта, где в $ будет находиться объект, предоставляющий доступ к функциям jQuery
$(".btn-delete").click(function (event) {
$.post("/tasks/delete/" + event.target.id, {}, function (data) {
// перезагружаем страницу
location.reload()
});
});
});
</script>
</body>
</html>
В этих шаблонах я также поменял ссылку контактов с заглушки на актуальную. Также я переписал меню в той же логике, что и на главной странице.
Помимо кнопки выхода я добавил вывод имени пользователя, если он авторизован.
Пользовательские списки
Сейчас каждый авторизованный пользователь видит все задача, да и сами задачи не связаны с конкретным пользователем. Перепишем логику работы сайта так, чтобы пользователь мог видеть только свои задачи, а при добавлении не нужно было бы указывать имя пользователя, ведь пользователь авторизован, его имя мы можем получить автоматически.
Модель
Когда мы прописывали отношения пользователей и ролей, мы использовали связь "Многие ко многим"(ManyToMany). Это позволяло связать любого пользователя с любым набором ролей, но пришлось создать дополнительную таблицу связывания.
У отношений задач и пользователей другая суть: у каждой задачи указывается ровно один пользователь.
Тогда нам не нужна дополнительная таблица, достаточно просто хранить вместо имени пользователя
его id
. Такое отношение называется один ко многим (OneToMany)
Колонки таблицы, конечно можно изменить вручную, но это сложно. Проще просто удалить её и создать новую с новым набором колонок(полей).
Чтобы удалить таблицу, нужно кликнуть по ней правой кнопкой мыши и выбрать пункт Drop
.
Нам надо удалить таблицу tasks_table
В новом окне нажмите OK
Создадим таблицу заново
Теперь заменим поле автора в классе задач 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
поле пользователя, ведь теперь он будет определяться автоматически.
- TaskForm.java
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
Также в шаблоне списка задач я поменял вывод информации об авторе
<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>
Перезапустите сервер и добавьте задачу
Всё работает как надо
Я зарегистрировал второго пользователя u2
, и добавил от его имени две задачи
В списке пользователь u4
видит только свои задачи
Администратор
Теперь нам осталось написать логику работы администратора. Чтобы пользователь стал администратором, он должен будет при регистрации ввести специальный код, после этого ему в списке задач должны будут доступны все, а не только пользовательские.
Для начала добавьте запись с кодом регистрации администратора в application.properties
в папке resources
Для начала нам нужно переписать метод сохранения пользователей так, чтобы указывать не только пользователя, но и роль.
/**
* Сохранить пользователя
*
* @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";
}
Перезапустите сервер и создаёте пользователя с правами администратора. Теперь если зайти через него на страницу списка, в нём отобразятся все задачи
У первого пользователя всё ещё отображается только его задача. Это значит, что логика ролей пользователя работает так, как мы планировали.
REST
Мы полностью прописали защиту данных при работе через браузер, но у нас остались открытыми запросы к REST контроллеру. Чтобы управлять доступом к REST запросам, используется принцип токена.
Первым запросом по логину и паролю клиент должен получить его уникальный токен, а потом в каждом запросе передавать его.
Для начала добавьте в pom.xml
зависимость
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
и пересоберите проект maven
. Эта зависимость позволит формировать токены.
Теперь пропишем репозиторий токенов. Для этого в папке репозиториев создадим новый класс
JwtTokenRepository
В этом репозитории для формирования токена необходимо добавить секретный ключ шифрования. Вы можете его поменять на любой другой. Лучшим решением было бы для каждого пользователя использовать свой ключ, который строится на основе данных о пользователе, но и просто один и тот же ключ вполне приемлемое решение, потому что навряд ли наш сайт с задачами будет кто-то всерьёз взламывать.
Но для серьёзных проектов необходимо создавать ключ для каждого пользователя отдельно.
Контроллер
- JwtTokenRepository.java
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
- GlobalExceptionHandler.java
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
- JwtCsrfFilter.java
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
Если логин и пароль введены правильно, вы получите ответ
Теперь перейдите во вкладку Headers
и найдите пункт x-csrf-token
, скопируйте его
и при формировании новых REST запросов просто добавьте его значение с ключом X-CSRF-TOKEN
Теперь если отправить REST запрос с токеном, мы получим список задач
Возможно, сервер в первый раз не ответит и по токену выдаст пустой ответ, тогда нужно просто ещё раз отправить запрос на вход, и всё будет ок.
Если параметр токена отключить и попробовать снова, в ответ мы получим ошибку авторизации
Самостоятельная работа
У REST контроллера у каждого пользователя всё ещё есть доступ ко всем задачам. Перепишите его так, чтобы только администратор имел доступ ко всем задачам, а рядовой пользователь - только к своим.
Развёртывание
Когда мы устанавливаем наше приложение на сервер, это называется развёртывание(deploy)
В нашем сервере мы используем 17 версию java. Чтобы Heroku об этом узнал, необходимо добавить
в корень проекта файл system.properties
а в нём написать всего одну строчку:
java.runtime.version=17.0.2
Нам необходимо как-то передать heroku данные для подключения к базе. Добавлять в git файл настнройки
неправильно. Для решения этой задачи мы создадим два разных файла настроек: один для локального
запуска, он будет иметь постфикс -dev
от слова develop - разработка и второй для
публикации в репозитории с постфиксом -prod
от слова production - поставка.
Чтобы управлять тем, какие именно настройки нужно использовать, мы уберём файл application.properties
из игнор-списка, но оставим в нём всего одну строчку (скопируйте куда-нибудь содержимое, оно
нам скоро пригодится)
spring.profiles.active=dev
Она говорит, что при сборке необходимо использовать файл настроек с постфиксом -dev
,
если мы поменяем её на
spring.profiles.active=prod
то будет загружаться файл настроек с постфиксом prod
.
Создадим эти файлы и application-dev.properties
добавим в игнорлист, в него запишем всё, что
было раньше в application.properties
.
Правда, остаётся всё та же проблема с публичным доступом, что и раньше. Нельзя напрямую указывать данные для доступа к БД и размещать в открытом репозитории. Поэтому нам необходимо использовать переменные среды. Переменные среды - это пары ключ-значение, задаваемые для всей операционной системы, либо для конкретного пользователя.
В файле настроек application-prod.properties
мы укажем только названия этих переменных, а
зададим их значения в настройках приложения heroku.
- application-prod.properties
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
В открывшемся блоке добавьте переменную и нажмите Add
Заполните все необходимые значения
Не забывайте менять режим сборки приложения на dev
в настройках application.properties
,
если хотите запустить приложение локально и prod
если отправляете его на github для
развёртывания. В противном случае либо сайт не будет работать, либо ваш локально развёрнутый
сервер.
Теперь можно настроить саму развёртку.
Heroku умеет развёртывать приложения в автоматическом режиме. Для этого достаточно просто связать его приложение в репозиторием
github. Чтобы это сделать, сначала войдите в аккаунт гитхаба, потом откройте вкладку Deploy
в приложении, после чего нажмите Connect to Github
Если вы уже авторизовались на гитхабе, подключение аккаунта выполнится автоматически, вам нужно лишь ввести один или несколько символов, которые содержатся в названии репозитория и выбрать его из списка
После чего нажмите Connect
После связывания просто нажмите Deploy Branch
. Некоторое время heroku потратит на сборку приложения
После чего выведет сообщение об удачной сборке, если всё окей.
Если никаких проблем нет, то сервер поднимется
И теперь сайт будет доступен по адресу https://appname.herokuapp.com
, где app-name
- название вашего приложения.
У меня оно назвается buran-tbot
, поэтому его адрес будет https://buran-tbot.herokuapp.com
Попробуйте открыть ваш сайт.
Первая загрузка сайта может занять значительное время, т.к. на бесплатном тарифном плане heroku держит сервер поднятым только часть времени, а если им не пользуются, то он его приостанавливает для экономии ресурсов.
Скачать исходники можно здесь. Дамп базы
данных dump.sql
лежит в корне.