16. [Cours]: Захист доступу до веб-сервісу за допомогою Spring Security
Ключові слова: багаторівнева архітектура, Spring, ін’єкція залежностей, веб-сервіс / захищений jSON, клієнт / сервер
16.1. Support
![]() | ![]() |
Проекти цього розділу знаходяться в папці [support / chap-16]. Скрипт SQL призначений для створення бази даних, необхідної для тестування.
16.2. Місце Spring Security у веб-додатку
Розглянемо місце Spring Security у розробці веб-додатку. Найчастіше такий додаток будується на багатошаровій архітектурі, наприклад, такій:
![]() |
- шар [Spring Security] надає доступ до шару [web] лише авторизованим користувачам.
16.3. Посібник із Spring Security
Ми знову імпортуємо посібник Spring, виконавши кроки 1–3, наведені нижче:
![]() |
![]() |
Проєкт складається з таких елементів:
- у папці [templates] містяться сторінки HTML проекту;
- [Application] — це виконуваний клас проекту;
- [MvcConfig] — це клас конфігурації Spring MVC;
- [WebSecurityConfig] — це клас конфігурації Spring Security;
16.3.1. Конфігурація Maven
Проєкт [3] є проєктом Maven. Розглянемо його файл [pom.xml], щоб дізнатися про його залежності:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework</groupId>
<artifactId>gs-securing-web</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.3.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- тег::security[] -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- end::security[] -->
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- рядки 10–14: проект є проектом Spring Boot;
- рядки 17–20: залежність від фреймворку [Thymeleaf];
- рядки 22–25: залежність від фреймворку Spring Security;
16.3.2. Види Thymeleaf
![]() |
Вигляд [home.html] має такий вигляд:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click <a th:href="@{/hello}">here</a> to see a greeting.
</p>
</body>
</html>
- рядок 12: атрибут [th:href="@{/hello}"] згенерує атрибут [href] тегу [<a>]. Значення [@{/hello}] згенерує шлях [<context>/hello], де [context] є контекстом веб-додатку;
Згенерований код HTML має такий вигляд:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click
<a href="/hello">here</a>
to see a greeting.
</p>
</body>
</html>
Вигляд [hello.html] такий:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
</body>
</html>
- рядок 9: Атрибут [th:inline="text"] згенерує текст тегу [<h1>]. Цей текст містить вираз $, який має бути обчислений. Елемент [[${#httpServletRequest.remoteUser}]] є значенням атрибута [RemoteUser] поточного запиту HTTP. Це ім’я користувача, що ввійшов у систему;
- рядок 10: форма HTML. Атрибут [th:action="@{/logout}"] згенерує атрибут [action] тегу [form]. Значення [@{/logout}] згенерує шлях [<context>/logout], де [context] — це контекст веб-додатка;
Згенерований код HTML має такий вигляд:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1>Hello user!</h1>
<form method="post" action="/logout">
<input type="submit" value="Sign Out" />
<input type="hidden" name="_csrf" value="b152e5b9-d1a4-4492-b89d-b733fe521c91" />
</form>
</body>
</html>
- рядок 8: переклад «Hello [[${#httpServletRequest.remoteUser}]]!»;
- рядок 9: переклад @{/logout};
- рядок 11: приховане поле з назвою (атрибут name) _csrf;
Вигляд [login.html] такий:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<div th:if="${param.error}">Invalid username and password.</div>
<div th:if="${param.logout}">You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<label> User Name : <input type="text" name="username" />
</label>
</div>
<div>
<label> Password: <input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
</form>
</body>
</html>
- рядок 9: атрибут [th:if="${param.error}"] забезпечує, що тег <div> буде згенеровано лише в тому випадку, якщо URL, який відображає сторінку входу, містить параметр [error] (http://context/login?error);
- рядок 10: атрибут [th:if="${param.logout}"] забезпечує, що тег <div> буде згенеровано лише в тому випадку, якщо URL, що відображає сторінку входу, містить параметр [logout] (http://context/login?logout);
- рядки 11–23: форма HTML;
- рядок 11: форма буде відправлена на URL [<context>/login], де <context> — це контекст веб-додатку;
- рядок 13: поле введення з назвою [username];
- рядок 17: поле введення з назвою [password];
Згенерований код HTML має такий вигляд:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example </title>
</head>
<body>
<div>
You have been logged out.
</div>
<form method="post" action="/login">
<div>
<label>
User Name :
<input type="text" name="username" />
</label>
</div>
<div>
<label>
Password:
<input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
<input type="hidden" name="_csrf" value="ef809b0a-88b4-4db9-bc53-342216b77632" />
</form>
</body>
</html>
Зверніть увагу, що у рядку 28 Thymeleaf додав приховане поле з назвою [_csrf].
16.3.3. Конфігурація Spring MVC
![]() |
Клас [MvcConfig] налаштовує фреймворк Spring MVC:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/").setViewName("home");
registry.addViewController("/hello").setViewName("hello");
registry.addViewController("/login").setViewName("login");
}
}
- рядок 7: анотація [@Configuration] перетворює клас [MvcConfig] на клас конфігурації;
- рядок 8: клас [MvcConfig] успадковує клас [WebMvcConfigurerAdapter], щоб перевизначити деякі його методи;
- рядок 10: перевизначення методу батьківського класу;
- рядки 11–16: метод [addViewControllers] дозволяє пов’язувати URL із поданнями HTML. У ньому встановлено такі зв’язки:
погляд | |
/templates/home.html | |
/templates/hello.html | |
/templates/login.html |
Суфікс [html] та папка [templates] є значеннями за замовчуванням, які використовує Thymeleaf. Їх можна змінити у налаштуваннях. Папка [templates] має знаходитися в кореневому каталозі Classpath проекту:
![]() |
У наведеному вище прикладі [1] папки [java] та [resources] є папками-джерелами (source folders). Це означає, що їхній вміст буде розміщений у кореневому каталозі Classpath проекту. Отже, у [2] папки [hello] та [templates] будуть у кореневому каталозі Classpath.
16.3.4. Налаштування Spring Security
![]() |
Клас [WebSecurityConfig] налаштовує фреймворк Spring Security:
package hello;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
- рядок 9: анотація [@Configuration] перетворює клас [WebSecurityConfig] на клас конфігурації;
- рядок 10: анотація [@EnableWebSecurity] перетворює клас [WebSecurityConfig] на клас конфігурації Spring Security;
- рядок 11: клас [WebSecurity] успадковує клас [WebSecurityConfigurerAdapter], щоб перевизначити деякі його методи;
- рядок 12: перевизначення методу батьківського класу;
- рядки 13–16: метод [configure(HttpSecurity http)] перевизначено для визначення прав доступу до різних URL у додатку;
- рядок 14: метод [http.authorizeRequests()] дозволяє пов’язувати URL з правами доступу. У ньому встановлено такі зв’язки:
правило | код | |
доступ без аутентифікації | | |
доступ лише після автентифікації |
- рядок 15: визначає метод аутентифікації. Аутентифікація здійснюється через форму URL [/login], доступну для всіх [http.formLogin().loginPage("/login").permitAll()]. Вихід із системи (logout) також доступний для всіх;
- рядки 19–21: перевизначають метод [configure(AuthenticationManagerBuilder auth)], який керує користувачами;
- рядок 20: автентифікація здійснюється за допомогою «жорстко» визначених користувачів [auth.inMemoryAuthentication()]. Користувач тут визначається за допомогою логіна [user], пароля [password] та ролі [USER]. Користувачам з однаковою роллю можна надати однакові права;
16.3.5. Виконуваний клас
![]() |
Клас [Application] має такий вигляд:
package hello;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@EnableAutoConfiguration
@Configuration
@ComponentScan
public class Application {
public static void main(String[] args) throws Throwable {
SpringApplication.run(Application.class, args);
}
}
- рядок 8: анотація [@EnableAutoConfiguration] вимагає від Spring Boot (рядок 3) виконати конфігурацію, яку розробник не зробив явно;
- рядок 9: перетворює клас [Application] на клас конфігурації Spring;
- рядок 10: вимагає сканування папки класу [Application] з метою пошуку компонентів Spring. Таким чином будуть виявлені обидва класи [MvcConfig] та [WebSecurityConfig], оскільки вони мають анотацію [@Configuration];
- рядок 13: метод [main] класу, що виконується;
- рядок 14: статичний метод [SpringApplication.run] виконується з класом конфігурації [Application] як параметром. Ми вже стикалися з цим процесом і знаємо, що вбудований у залежності Maven проекту сервер Tomcat буде запущений, а проект розгорнуто на ньому. Ми бачили, що чотири об’єкти URL управлялися об’єктом [/, /home, /login, /hello] і що деякі з них були захищені правами доступу.
16.3.6. Тестування додатка
Почнемо з запиту на URL [/], який є одним із чотирьох прийнятих URL. Він пов’язаний із поданням [/templates/home.html]:
![]() |
Запитана URL [/] є загальнодоступною. Саме тому ми її отримали. Посилання [here] таке:
URL [/hello] буде запитано, коли ви натиснете на посилання. Воно захищене:
правило | код | |
доступ без авторизації | | |
доступ лише для авторизованих користувачів |
Щоб отримати доступ, необхідно пройти автентифікацію. Spring Security перенаправить браузер клієнта на сторінку автентифікації. Згідно з наведеною конфігурацією, це сторінка URL [/login]. Ця сторінка доступна для всіх:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
Отже, ми отримуємо [1]:
![]() |
Вихідний код отриманої сторінки такий:
- у рядку 7 з’являється приховане поле, якого немає на вихідній сторінці [login.html]. Його додав Thymeleaf. Цей код, що називається CSRF (Cross Site Request Forgery), призначений для усунення вразливості безпеки. Цей токен необхідно надіслати до Spring Security разом з даними автентифікації, щоб автентифікація була прийнята;
Нагадаємо, що Spring Security розпізнає лише користувача user/password. Якщо ввести інше значення, наприклад [2], ми отримаємо ту саму сторінку з повідомленням про помилку в [3]. Spring Security перенаправив браузер на сторінку URL [http://localhost:8080/login?error]. Наявність параметра [error] спричинила відображення тегу:
<div th:if="${param.error}">Invalid username and password.</div>
Тепер введемо очікувані значення user/password [4]:
![]() |
- у [4] ми авторизуємося;
- у [5] Spring Security перенаправляє нас на URL [/hello], оскільки саме URL ми запитували, коли нас перенаправили на сторінку входу. Ідентифікаційні дані користувача були відображені в наступному рядку [hello.html]:
На сторінці [5] відображається така форма:
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
При натисканні на кнопку [Sign Out] буде виконано POST на сторінці URL [/logout]. Цей файл, як і URL та [/login], доступний для всіх:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
У нашому об’єднанні URL / views ми нічого не визначили для URL та [/logout]. Що відбудеться? Спробуємо:
![]() |
- у [6] ми натискаємо кнопку [Sign Out];
- з [7] бачимо, що нас перенаправили на URL [http://localhost:8080/login?logout]. Це Spring Security ініціював це перенаправлення. Наявність параметра [logout] у URL призвела до відображення наступного рядка у вигляді:
<div th:if="${param.logout}">You have been logged out.</div>
16.3.7. Висновок
У попередньому прикладі ми могли б спочатку написати веб-додаток, а потім забезпечити його безпеку. Spring Security не є інтрузивним. Можна налаштувати безпеку вже написаного веб-додатка. Крім того, ми з’ясували наступне:
- можна визначити сторінку автентифікації;
- автентифікація повинна супроводжуватися токеном CSRF, виданим Spring Security;
- якщо автентифікація не вдалася, користувача перенаправляють на сторінку автентифікації, додавши до параметра URL параметр error;
- якщо автентифікація проходить успішно, користувач перенаправляється на сторінку, яку він запитував під час автентифікації. Якщо безпосередньо зайти на сторінку автентифікації, оминувши проміжну сторінку, Spring Security перенаправляє нас на URL [/] (цей випадок не розглядався);
- вихід із системи здійснюється шляхом запиту на сторінку URL [/logout] із параметром POST. Spring Security перенаправляє нас на сторінку автентифікації з параметром logout у URL;
Усі ці висновки ґрунтуються на стандартній поведінці Spring Security. Цю поведінку можна змінити шляхом конфігурації, перевизначивши певні методи класу [WebSecurityConfigurerAdapter].
Попередній підручник не дуже допоможе нам далі. Адже ми будемо використовувати:
- базу даних для зберігання користувачів, їхніх паролів та ролей;
- аутентифікацію за заголовком HTTP;
Посібників з того, що ми хочемо зробити тут, досить мало. Рішення, яке буде запропоновано, — це комбінація коду, знайденого тут і там.
16.4. Налаштування безпеки веб-сервісу / JSON-даних про товари
16.4.1. База даних
База даних [dbintrospringdata] розширюється, щоб враховувати користувачів, їхні паролі та ролі. З’являються три нові таблиці:

Таблиця [USERS]: користувачі
- ID: первинний ключ;
- VERSION: стовпець версійності рядка;
- IDENTITY: описовий ідентифікатор користувача;
- LOGIN: логін користувача;
- PASSWORD: його пароль;
У таблиці USERS паролі не зберігаються у відкритому вигляді:
![]() |
Алгоритм, який шифрує паролі, — це алгоритм BCRYPT.
Таблиця [ROLES]: ролі
- ID: первинний ключ;
- VERSION: стовпець версійності рядка;
- NAME: ім’я ролі. За замовчуванням Spring Security очікує імен у форматі ROLE_XX, наприклад ROLE_ADMIN або ROLE_GUEST;
![]() |
Таблиця [USERS_ROLES]: таблиця з'єднання USERS / ROLES
Один користувач може мати кілька ролей, а одна роль може об’єднувати кількох користувачів. Ми маємо відношення «багато-до-багатьох», яке реалізовано в таблиці [USERS_ROLES].
- ID: первинний ключ;
- VERSION: стовпець версійності рядка;
- USER_ID: ідентифікатор користувача;
- ROLE_ID: ідентифікатор ролі;
![]() |
16.4.2. Проєкт Eclipse
Ми створюємо такий проект Eclipse:
1 ![]() |
- в [1]: новий проект із такими пакетами:
- [spring.security.entities]: містить сутності JPA, що відповідають трьом новим таблицям бази даних;
- [spring.security.repositories]: містить [repositories] з Spring Data, пов’язані з трьома новими таблицями;
- [spring.security.dao]: містить сервіс, що базується на [repositories];
- [spring.security.config]: містить конфігурацію проєкту, зокрема налаштування безпечного доступу до веб-сервісу;
- [spring.security.boot]: містить клас запуску захищеного веб-сервісу;
16.4.3. Конфігурація Maven
Новий проєкт — це проєкт Maven, налаштований за допомогою такого файлу [pom.xml]:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring.security</groupId>
<artifactId>intro-spring-security-server-01</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>intro-spring-security-server-01</name>
<description>démo spring security</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.7.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>istia.st.webjson</groupId>
<artifactId>intro-server-webjson-01</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Журнали Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
</dependency>
<!-- Тестування Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- плагіни -->
<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.18.1</version>
</plugin>
</plugins>
</build>
</project>
- рядки 23–27: використовуємо існуючі налаштування з архівом розглянутого веб-сервісу /json;
- рядки 29–32: залежність, що забезпечує класи Spring Security;
- рядки 34–37: бібліотека логів;
- рядки 39–42: бібліотека, що дозволяє використовувати анотації Spring Boot;
- рядки 44–48: бібліотека, необхідна для тестування;
16.4.4. Нові сутності [JPA]
![]() |
Рівень JPA визначає три нові сутності:
![]() |
Клас [User] є відображенням таблиці [USERS]:
package spring.security.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import spring.data.entities.AbstractEntity;
@Entity
@Table(name = "USERS")
public class User extends AbstractEntity {
// властивості
@Column(name = "NAME")
private String name;
@Column(name = "LOGIN")
private String login;
@Column(name = "PASSWORD")
private String password;
// конструктор
public User() {
}
public User(String name, String login, String password) {
this.name = name;
this.login = login;
this.password = password;
}
// методи getter та setter
...
}
- рядок 11: клас розширює клас [AbstractEntity], який вже використовується для інших об’єктів;
Клас [Role] є відображенням таблиці [ROLES]:
package spring.security.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import spring.data.entities.AbstractEntity;
@Entity
@Table(name = "ROLES")
public class Role extends AbstractEntity {
// властивості
@Column(name="NAME")
private String name;
// конструктори
public Role() {
}
public Role(String name) {
this.name = name;
}
// методи getter та setter
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
Клас [UserRole] є відображенням таблиці [USERS_ROLES]:
package spring.security.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import spring.data.entities.AbstractEntity;
@Entity
@Table(name = "USERS_ROLES")
public class UserRole extends AbstractEntity {
// зовнішні ключі
@Column(name = "USER_ID", insertable = false, updatable = false)
private Long userId;
@Column(name = "ROLE_ID", insertable = false, updatable = false)
private Long roleId;
// UserRole посилається на User
@ManyToOne
@JoinColumn(name = "USER_ID")
private User user;
// UserRole посилається на роль
@ManyToOne
@JoinColumn(name = "ROLE_ID")
private Role role;
// конструктори
public UserRole() {
}
public UserRole(User user, Role role) {
this.user = user;
this.role = role;
}
// методи getter та setter
...
}
- рядки 22–24: реалізують зовнішній ключ таблиці [USERS_ROLES] до таблиці [USERS];
- рядки 27–29: реалізують зовнішній ключ таблиці [USERS_ROLES] до таблиці [ROLES];
16.4.5. [repositories]
![]() |
Кожна з наведених вище сутностей JPA управляється сутністю Spring Data [repository]:
![]() |
Інтерфейс [UserRepository] керує доступом до сутностей [User]:
package spring.security.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import spring.security.entities.Role;
import spring.security.entities.User;
public interface UserRepository extends CrudRepository<User, Long> {
// список ролей користувача, ідентифікованого за його ідентифікатором
@Query("select ur.role from UserRole ur where ur.user.id=?1")
Iterable<Role> getRoles(long id);
// список ролей користувача, ідентифікованого за його унікальним логіном
@Query("select ur.role from UserRole ur where ur.user.login=?1 and ur.user.password=?2")
Iterable<Role> getRoles(String login, String password);
// пошук користувача за логіном
User findUserByLogin(String login);
}
- рядок 9: інтерфейс [UserRepository] розширює інтерфейс [CrudRepository] з Spring Data (рядок 4);
- рядки 12–13: метод [getRoles(User user)] дозволяє отримати всі ролі користувача, ідентифікованого за його [id]
- рядки 16–17: те саме, але для користувача, ідентифікованого за логіном та паролем;
- рядок 20: для пошуку користувача за його логіном;
Інтерфейс [RoleRepository] керує доступом до об’єктів [Role]:
package spring.security.repositories;
import org.springframework.data.repository.CrudRepository;
import spring.security.entities.Role;
public interface RoleRepository extends CrudRepository<Role, Long> {
// пошук ролі за її назвою
Role findRoleByName(String name);
}
- рядок 7: інтерфейс [RoleRepository] розширює інтерфейс [CrudRepository];
- рядок 10: можна шукати роль за її назвою;
Інтерфейс [UserRoleRepository] керує доступом до об’єктів [UserRole]:
package spring.security.repositories;
import org.springframework.data.repository.CrudRepository;
import spring.security.entities.UserRole;
public interface UserRoleRepository extends CrudRepository<UserRole, Long> {
}
- рядок 5: інтерфейс [UserRoleRepository] лише розширює інтерфейс [CrudRepository], не додаючи до нього нових методів;
16.4.6. Класи управління користувачами та ролями
![]() |
![]() |
Spring Security вимагає створення класу, що реалізує наступний інтерфейс [UsersDetail]:
![]() |
Цей інтерфейс реалізовано класом [AppUserDetails]:
package spring.security.dao;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import spring.security.entities.Role;
import spring.security.entities.User;
import spring.security.repositories.UserRepository;
public class AppUserDetails implements UserDetails {
private static final long serialVersionUID = 1L;
// властивості
private User user;
private UserRepository userRepository;
// конструктори
public AppUserDetails() {
}
public AppUserDetails(User user, UserRepository userRepository) {
this.user = user;
this.userRepository = userRepository;
}
// -------------------------інтерфейс
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : userRepository.getRoles(user.getId())) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getLogin();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// методи getter та setter
...
}
- рядок 14: клас [AppUserDetails] реалізує інтерфейс [UserDetails];
- рядки 19–20: клас інкапсулює користувача (рядок 19) та репозиторій, що дозволяє отримати детальну інформацію про цього користувача (рядок 20);
- рядки 26–29: конструктор, який створює екземпляр класу з користувачем та його репозиторієм;
- рядки 32–36: реалізація методу [getAuthorities] інтерфейсу [UserDetails]. Він повинен створити колекцію елементів типу [GrantedAuthority] або його похідних. Тут ми використовуємо похідний тип [SimpleGrantedAuthority] (рядок 36), який інкапсулює ім’я однієї з ролей користувача з рядка 19;
- рядки 35–37: відбувається перебір списку ролей користувача з рядка 19 для формування списку елементів типу [SimpleGrantedAuthority];
- рядки 42–44: реалізують метод [getPassword] інтерфейсу [UserDetails]. Повертається пароль користувача з рядка 19;
- рядки 42–44: реалізують метод [getUserName] інтерфейсу [UserDetails]. Повертається логін користувача з рядка 19;
- рядки 51–54: обліковий запис користувача ніколи не втрачає чинності;
- рядки 56–59: обліковий запис користувача ніколи не блокується;
- рядки 61–64: ідентифікатори користувача ніколи не втрачають чинності;
- рядки 66–69: обліковий запис користувача завжди активний;
Spring Security також вимагає наявності класу, що реалізує інтерфейс [AppUserDetailsService]:
![]() |
Цей інтерфейс реалізовано наступним класом [AppUserDetailsService]:
package spring.security.dao;
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.stereotype.Service;
import spring.security.entities.User;
import spring.security.repositories.UserRepository;
@Service
public class AppUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
// пошук користувача за логіном
User user = userRepository.findUserByLogin(login);
// знайдено?
if (user == null) {
throw new UsernameNotFoundException(String.format("login [%s] inexistant", login));
}
// повертаємо дані користувача
return new AppUserDetails(user, userRepository);
}
}
- рядок 12: клас буде компонентом Spring, отже, доступним у своєму контексті;
- рядки 15–16: компонент [UserRepository] буде введений саме тут;
- рядки 19–28: реалізація методу [loadUserByUsername] інтерфейсу [UserDetailsService] (рядок 10). Параметром є логін користувача;
- рядок 21: користувач шукається за його логіном;
- рядки 23–25: якщо його не знайдено, генерується виняток;
- рядок 27: створюється та відображається об’єкт [AppUserDetails]. Він дійсно має тип [UserDetails] (рядок 19);
16.4.7. Конфігурація проєкту
![]() |
Проєкт складається з двох класів:
![]() |
Клас [DaoConfig] налаштовує рівень [DAO], який вводиться новим проєктом:
package spring.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@EnableJpaRepositories(basePackages = { "spring.security.repositories" })
@ComponentScan(basePackages = { "spring.security.dao" })
@Import({ spring.data.config.DaoConfig.class })
public class DaoConfig {
// константи
final static private String[] ENTITIES_PACKAGES = { "spring.data.entities", "spring.security.entities" };
@Bean
public String[] packagesToScan() {
return ENTITIES_PACKAGES;
}
}
- рядок 10: імпортується клас конфігурації [spring.data.config.DaoConfig] з проєкту [intro-spring-data-01], який реалізує рівень [DAO] для товарів та категорій;
- рядок 8: вказуються папки поточного проєкту, що містять [repositories] Spring Data;
- рядок 9: вказуються папки поточного проєкту, що містять компоненти Spring, пов’язані з шаром [DAO];
- рядок 14: вказуються папки, що містять сутності JPA. Це папки проєкту [intro-spring-data-01] та проєкту захищеного сервера. Ця інформація є предметом біна в рядках 16–19. Цей бін перевизначає бін з такою самою назвою з проєкту [intro-spring-data-01]:
final static private String[] ENTITIES_PACKAGES = { "spring.data.entities" };
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan(packagesToScan());
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
@Bean
public String[] packagesToScan() {
return ENTITIES_PACKAGES;
}
У шарі [DAO] рядок 8 сканує папки, вказані в рядку 1. Через перевизначення біна з рядків 14–17 у захищеному проєкті (рядки 16–19) рядок 8, наведений вище, відтепер скануватиме папки ["spring.data.entities", "spring.security.entities"]. Слід зауважити, що імпортований у рядку 10 клас [spring.security.config.DaoConfig] повинен містити анотацію [@Configuration], інакше описане вище явище не працюватиме.
Клас [SecurityConfig] налаштовує аспект безпеки проєкту. Ми вже зустрічали клас конфігурації Spring Security:
package hello;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
Ми будемо дотримуватися того самого підходу:
- рядок 11: визначимо клас, який успадковує клас [WebSecurityConfigurerAdapter];
- рядок 13: визначити метод [configure(HttpSecurity http)], який визначає права доступу до різних URL веб-сервісу;
- рядок 19: визначимо метод [configure(AuthenticationManagerBuilder auth)], який визначає користувачів та їхні ролі;
Клас [SecurityConfig] матиме такий вигляд:
package spring.security.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpMethod;
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.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import spring.security.dao.AppUserDetailsService;
@EnableWebSecurity
@ComponentScan(basePackages = { "spring.security.service" })
@Import({ spring.webjson.config.AppConfig.class, DaoConfig.class })
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AppUserDetailsService appUserDetailsService;
// захист
private boolean activateSecurity = true;
@Override
protected void configure(AuthenticationManagerBuilder registry) throws Exception {
// аутентифікація здійснюється за допомогою біна [appUserDetailsService]
// пароль шифрується за допомогою алгоритму хешування BCrypt
registry.userDetailsService(appUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// захищений додаток?
if (activateSecurity) {
// пароль передається у заголовку Authorization: Basic xxxx
http.httpBasic();
// метод HTTP OPTIONS має бути дозволений для всіх
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// лише роль ADMIN може використовувати додаток
http.authorizeRequests() //
.antMatchers("/", "/**") // усі URL
.hasRole("ADMIN");
// сесія відсутня
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
}
- рядок 16: для активації елементів Spring Security;
- рядок 17: додаємо компоненти Spring із пакета [spring.security.service];
- рядок 18: імпортуємо біни з рівня [DAO], який щойно розглянули, а також біни незахищеного веб-сервера / jSON;
- рядки 21–22: вводиться клас [AppUserDetails], який надає доступ користувачам до додатка;
- рядок 25: логічне значення, яке забезпечує (true) або не забезпечує (false) безпеку веб-додатку;
- рядки 27–32: метод [configure(HttpSecurity http)] визначає користувачів та їхні ролі. Він отримує як параметр тип [AuthenticationManagerBuilder]. Цей параметр доповнюється двома даними (рядок 38):
- посилання на службу [appUserDetailsService] із рядка 22, яка надає доступ до зареєстрованих користувачів. Слід зауважити, що інформація про те, що вони зареєстровані в базі даних, тут не з’являється. Отже, вони можуть зберігатися в кеші, надаватися веб-службою тощо;
- тип шифрування, що використовується для пароля. Нагадаємо, що ми використовували алгоритм BCrypt;
- рядки 34–52: метод [configure(HttpSecurity http)] визначає права доступу до URL веб-сервісу;
- рядок 37: у вступному проекті ми бачили, що за замовчуванням Spring Security керує токеном CSRF (Cross Site Request Forgery), який користувач, що бажає пройти автентифікацію, повинен надіслати серверу. Тут цей механізм вимкнено. У поєднанні з булевим значенням (isSecured=false) це дозволяє використовувати веб-додаток без заходів безпеки;
- рядок 41: увімкнено режим автентифікації за допомогою заголовка HTTP. Клієнт повинен надіслати такий заголовок HTTP:
де code — це кодування рядка login:password за алгоритмом Base64. Наприклад, Base64-кодування рядка admin:admin — це YWRtaW46YWRtaW4=. Отже, користувач із логіном [admin] та паролем [admin] надішле такий заголовок HTTP для автентифікації:
- рядки 46–48: вказують, що всі URL веб-сервісу доступні для користувачів, які мають роль [ROLE_ADMIN]. Це означає, що користувач, який не має цієї ролі, не може отримати доступ до веб-сервісу;
- рядок 50: у режимі [session] користувач, який пройшов автентифікацію один раз, не повинен повторно проходити її під час наступних входів. Тут цей режим вимкнено, тому користувач повинен буде проходити автентифікацію під час кожного входу;
16.4.8. Тестування рівня [DAO]
![]() |
![]() |
Спочатку ми створюємо виконуваний клас [CreateUser], здатний створити користувача з певною роллю:
package sprin.security.tests;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.security.crypto.bcrypt.BCrypt;
import spring.security.config.DaoConfig;
import spring.security.entities.Role;
import spring.security.entities.User;
import spring.security.entities.UserRole;
import spring.security.repositories.RoleRepository;
import spring.security.repositories.UserRepository;
import spring.security.repositories.UserRoleRepository;
public class CreateUser {
public static void main(String[] args) {
// синтаксис: логін пароль roleName
// потрібні три параметри
if (args.length != 3) {
System.out.println("Syntaxe : [pg] user password role");
System.exit(0);
}
// отримуємо параметри
String login = args[0];
String password = args[1];
String roleName = String.format("ROLE_%s", args[2].toUpperCase());
// контекст Spring
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
UserRepository userRepository = context.getBean(UserRepository.class);
RoleRepository roleRepository = context.getBean(RoleRepository.class);
UserRoleRepository userRoleRepository = context.getBean(UserRoleRepository.class);
// чи існує ця роль?
Role role = roleRepository.findRoleByName(roleName);
// якщо її немає, то її створюють
if (role == null) {
role = roleRepository.save(new Role(roleName));
}
// Чи існує вже користувач?
User user = userRepository.findUserByLogin(login);
// якщо його немає, то створюємо
if (user == null) {
// пароль хешується за допомогою bcrypt
String crypt = BCrypt.hashpw(password, BCrypt.gensalt());
// зберігаємо користувача
user = userRepository.save(new User(login, login, crypt));
// створюємо зв'язок із роллю
userRoleRepository.save(new UserRole(user, role));
} else {
// користувач вже існує — чи має він запитувану роль?
boolean trouvé = false;
for (Role r : userRepository.getRoles(user.getId())) {
if (r.getName().equals(roleName)) {
trouvé = true;
break;
}
}
// якщо не знайдено, створюється зв’язок із роллю
if (!trouvé) {
userRoleRepository.save(new UserRole(user, role));
}
}
// закриття контексту Spring
context.close();
// кінець
System.out.println("Travail terminé...");
}
}
- рядок 17: клас очікує три аргументи, що визначають користувача: його логін, пароль та роль;
- рядки 25–27: ці три параметри отримуються;
- рядок 29: контекст Spring будується на основі класу конфігурації [AppConfig];
- рядки 30–32: отримуються посилання на три об’єкти [Repository], які можуть знадобитися для створення користувача;
- рядок 34: перевіряється, чи роль уже існує;
- рядки 36–38: якщо ні, то створюємо її в базі даних. Вона матиме ім’я типу [ROLE_XX];
- рядок 40: перевіряємо, чи вже існує логін;
- рядки 42–49: якщо логіна ще немає, створюємо його в базі даних;
- рядок 44: шифруємо пароль. Тут використовуємо клас [BCrypt] з Spring Security (рядок 4). Тому нам потрібні архіви цього фреймворку. Файл [pom.xml] містить таку залежність:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- рядок 46: користувач зберігається в базі даних;
- рядок 48: а також зв’язок, що пов’язує його з його роллю;
- рядки 51–57: якщо логін уже існує — перевіряється, чи серед його ролей вже є та, яку ми хочемо йому присвоїти;
- рядки 59–61: якщо шукану роль не знайдено, створюється рядок у таблиці [USERS_ROLES] для зв’язку користувача з його роллю;
- не передбачено захисту від можливих винятків. Це допоміжний клас для швидкого створення користувача з роллю.
При виконанні класу з аргументами [x x guest] у базі даних отримуємо такі результати:
Таблиця [USERS]
![]() |
Таблиця [ROLES]
![]() |
Таблиця [USERS_ROLES]
![]() |
Тепер розглянемо другий клас [UsersTest], який є тестом JUnit:
![]() |
package spring.security.tests;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import spring.security.config.DaoConfig;
import spring.security.dao.AppUserDetails;
import spring.security.dao.AppUserDetailsService;
import spring.security.entities.Role;
import spring.security.entities.User;
import spring.security.repositories.UserRepository;
@SpringApplicationConfiguration(classes = DaoConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class UsersTest {
@Autowired
private UserRepository userRepository;
@Autowired
private AppUserDetailsService appUserDetailsService;
// маппер jSON
private ObjectMapper mapper = new ObjectMapper();
@Test
public void findAllUsersWithTheirRoles() throws JsonProcessingException {
Iterable<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(String.format("\n----------Utilisateur [%s]",mapper.writeValueAsString(user)));
display("Roles :", userRepository.getRoles(user.getId()));
}
}
@Test
public void findUserByLogin() {
// отримуємо користувача [admin]
User user = userRepository.findUserByLogin("admin");
// перевіряємо, чи його пароль — [admin]
Assert.assertTrue(BCrypt.checkpw("admin", user.getPassword()));
// перевіряємо роль admin / admin
List<Role> roles = Lists.newArrayList(userRepository.getRoles("admin", user.getPassword()));
Assert.assertEquals(1L, roles.size());
Assert.assertEquals("ROLE_ADMIN", roles.get(0).getName());
}
@Test
public void loadUserByUsername() {
// отримуємо користувача [admin]
AppUserDetails userDetails = (AppUserDetails) appUserDetailsService.loadUserByUsername("admin");
// перевіряється, чи його пароль — [admin]
Assert.assertTrue(BCrypt.checkpw("admin", userDetails.getPassword()));
// перевіряється роль admin / admin
@SuppressWarnings("unchecked")
List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) userDetails.getAuthorities();
Assert.assertEquals(1L, authorities.size());
Assert.assertEquals("ROLE_ADMIN", authorities.get(0).getAuthority());
}
// допоміжна функція — відображає елементи колекції
private void display(String message, Iterable<?> elements) throws JsonProcessingException {
System.out.println(message);
for (Object element : elements) {
System.out.println(mapper.writeValueAsString(element));
}
}
}
- рядки 37–44: візуальна перевірка. Виводяться всі користувачі з їхніми ролями;
- рядки 46–56: перевіряємо, чи користувач [admin] має пароль [admin] та роль [ROLE_ADMIN], використовуючи репозиторій [UserRepository];
- рядок 51: [admin] — це пароль у відкритому вигляді. У базі даних він зашифровано за алгоритмом BCrypt. Метод [BCrypt.checkpw] дозволяє перевірити, чи відкритий пароль після шифрування дійсно дорівнює тому, що міститься в базі даних;
- рядки 58–69: перевіряється, чи користувач [admin] має пароль [admin] та роль [ROLE_ADMIN] за допомогою служби [appUserDetailsService];
Виконання тестів завершилося успішно з такими записами в журналах:
16.4.9. Тестування веб-сервісу
Ми протестуємо веб-сервіс із клієнтом Chrome [Advanced Rest Client]. Нам потрібно буде вказати заголовок автентифікації HTTP:
де [code] — це код Base64 для рядка [login:password]. Для генерації цього коду можна скористатися такою програмою:
![]() |
package spring.security.helpers;
import org.springframework.security.crypto.codec.Base64;
public class Base64Encoder {
public static void main(String[] args) {
// очікуються два аргументи: логін та пароль
if (args.length != 2) {
System.out.println("Syntaxe : login password");
System.exit(0);
}
// отримуємо обидва аргументи
String chaîne = String.format("%s:%s", args[0], args[1]);
// кодується рядок
byte[] data = Base64.encode(chaîne.getBytes());
// виводиться її Base64-кодування
System.out.println(new String(data));
}
}
Якщо запустити цю програму з двома аргументами [admin admin]:
![]() |
отримаємо такий результат:
Тепер, коли ми знаємо, як згенерувати заголовок автентифікації HTTP, запускаємо захищений веб-сервіс, а потім за допомогою клієнта Chrome [Advanced Rest Client] запитуємо список усіх товарів:
![]() |
- у [1] ми запитуємо URL категорій;
- у запиті [2] використовуємо метод GET;
- у [3] ми вказуємо заголовок HTTP для автентифікації. Код [YWRtaW46YWRtaW4=] — це Base64-кодування рядка [admin:admin];
- у [4] ми надсилаємо команду HTTP;
Відповідь сервера така:
![]() |
- у [1] — заголовок автентифікації HTTP;
- у [2] сервер повертає відповідь jSON;
Ми отримуємо список категорій:
![]() |
Тепер спробуємо надіслати запит HTTP із неправильним заголовком автентифікації. У відповідь отримаємо таке:
![]() |
- у [1]: заголовок автентифікації HTTP;
Ми отримуємо таку відповідь:
![]() |
- в [2]: відповідь веб-сервісу;
Тепер спробуємо користувача user / user. Він існує, але не має доступу до веб-сервісу. Якщо запустити програму кодування Base64 з двома аргументами [user user]:
![]() |
отримаємо такий результат:
![]() |
- у [1]: помилковий заголовок автентифікації HTTP;
![]() |
- в [2]: відповідь веб-сервісу. Вона відрізняється від попередньої, яка була [401 Unauthorized]. Цього разу користувач успішно пройшов аутентифікацію, але не має достатніх прав для доступу до URL;
Наш захищений веб-сервіс тепер працює.
16.4.10. URL — підтвердження автентифікації
![]() |
Ми створимо URL, який дозволить нам визначити, чи має користувач дозвіл на доступ до веб-сервісу. Для цього ми створюємо новий контролер MVC [AuthenticateController] наступного вигляду:
package spring.security.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import spring.webjson.models.Response;
@Controller
public class AuthenticateController {
// залежності Spring
@Autowired
private ApplicationContext context;
@RequestMapping(value = "/authenticate", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String authenticate() throws JsonProcessingException {
// відповідь jSON
ObjectMapper mapperResponse = context.getBean(ObjectMapper.class);
return mapperResponse.writeValueAsString(new Response<Void>(0, null, null));
}
}
- рядок 15: клас [AuthenticateController] є контролером Spring. Як такий, він експонує URL;
- рядок 22: надає URL [/authenticate];
- рядок 23: результат методу буде надіслано безпосередньо клієнту;
- рядки 26–27: метод просто повертає порожній об’єкт [Response], але з [status], рівним 0, що вказує на відсутність помилок;
Для чого потрібен цей URL? Коли ми просто захочемо автентифікувати користувача, ми запитаємо його. Ми бачили, що якщо рівень безпеки не приймає цього користувача, він повертає виняток. Ось приклад;
З користувачем [admin:admin]:
![]() | ![]() |
Ми отримуємо порожню відповідь, але без винятку.
З користувачем [user:user]:
![]() | ![]() |
Сталося виключення.
16.4.11. Висновок
Додавання необхідних класів до Spring Security вдалося здійснити без змін у вихідному веб-проєкті / json. Цей дуже сприятливий випадок зумовлений тим, що три таблиці, додані до бази даних, є незалежними від існуючих таблиць. Їх навіть можна було б розмістити в окремій базі даних. В інших випадках додані таблиці можуть мати зв’язки з існуючими таблицями. Тоді доводиться змінювати сутності JPA, що, як правило, впливає на всі рівні проекту.
16.5. Клієнт, запрограмований для захищеного веб-сервісу / jSON
Ми вже написали клієнт для незахищеного веб-сервісу / jSON:
![]() |
Тепер ми створимо клієнт, запрограмований для захищеного веб-сервісу:
![]() |
Ми дублюємо вже написаний проєкт [intro-webjson-client] у новий проєкт [intro-spring-security-client-01]:
![]() |
16.5.1. Клас [AbstractDao]
Клас [AbstractDao] забезпечує зв’язок HTTP із захищеним веб-сервером / jSON. Як ми щойно бачили, під час цього з’єднання HTTP клієнт тепер повинен надіслати заголовок автентифікації, наприклад:
Це робиться наступним чином:
package spring.security.client.dao;
import java.net.URI;
...
public abstract class AbstractDao {
// дані
@Autowired
protected RestTemplate restTemplate;
@Autowired
protected String urlServiceWebJson;
// загальний запит
protected String getResponse(User user, String url, String jsonPost) {
// URL: URL для зв’язку
- рядок 15: загальний метод [getResponse], що відповідає за зв’язок HTTP із захищеним веб-сервісом, тепер приймає як перший параметр користувача, який запитує URL. Клас [User] має такий вигляд:
Цей клас має такий вигляд:
![]() |
package spring.security.client.entities;
public class User {
// властивості
private String login;
private String password;
// конструктор
public User() {
}
public User(String login, String password) {
this.login = login;
this.password = password;
}
// методи отримання та встановлення
...
}
Отже, метод [getResponse] виглядає так:
package spring.security.client.dao;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.RequestEntity.BodyBuilder;
import org.springframework.http.RequestEntity.HeadersBuilder;
import org.springframework.web.client.RestTemplate;
import spring.security.client.entities.User;
public abstract class AbstractDao {
// дані
@Autowired
protected RestTemplate restTemplate;
@Autowired
protected String urlServiceWebJson;
private String getBase64(User user) {
// кодування імені користувача та пароля у форматі Base64 — потрібно Java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPassword());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
// загальний запит
protected String getResponse(User user, String url, String jsonPost) {
// URL: URL — зверніться до
// jsonPost: значення jSON для відправлення
try {
// виконання запиту
RequestEntity<?> request;
if (jsonPost == null) {
HeadersBuilder<?> headersBuilder = RequestEntity.get(new URI(String.format("%s%s", urlServiceWebJson, url)))
.accept(MediaType.APPLICATION_JSON);
if (user != null) {
headersBuilder = headersBuilder.header("Authorization", getBase64(user));
}
request = headersBuilder.build();
} else {
BodyBuilder bodyBuilder = RequestEntity.post(new URI(String.format("%s%s", urlServiceWebJson, url)))
.header("Content-Type", "application/json").accept(MediaType.APPLICATION_JSON);
if (user != null) {
bodyBuilder = bodyBuilder.header("Authorization", getBase64(user));
}
request = bodyBuilder.body(jsonPost);
}
// запит виконується
return restTemplate.exchange(request, new ParameterizedTypeReference<String>() {
}).getBody();
} catch (URISyntaxException e1) {
throw new DaoException(20, e1);
} catch (RuntimeException e2) {
throw new DaoException(21, e2);
}
}
}
- рядки 42–44, 49–51: якщо користувач [user] не дорівнює null, то додається заголовок автентифікації. Кодування Base64 імені користувача та його пароля забезпечується методом [getBase64] у рядках 25–29. Слід звернути увагу на те, що цей метод використовує клас [Base64], який належить до JDK 1.8.
- Окрім зазначених рядків, код залишається без змін;
16.5.2. Інтерфейс [IDao]
Усі методи інтерфейсу [IDao] приймають додатковий параметр [User user]:
![]() |
package spring.security.client.dao;
import java.util.List;
import spring.security.client.entities.Categorie;
import spring.security.client.entities.Produit;
import spring.security.client.entities.User;
public interface IDaoClient {
// аутентифікація
public void authenticate(User user);
// додавання списку товарів
public List<Produit> addProduits(User user, List<Produit> produits);
// видалення всіх товарів
public void deleteAllProduits(User user);
// оновлення списку товарів
public List<Produit> updateProduits(User user, List<Produit> produits);
// отримання всіх товарів
public List<Produit> getAllProduits(User user);
// додавання списку категорій
public List<Categorie> addCategories(User user, List<Categorie> categories);
// видалення всіх категорій
public void deleteAllCategories(User user);
// оновлення списку категорій
public List<Categorie> updateCategories(User user, List<Categorie> categories);
// отримання всіх категорій
public List<Categorie> getAllCategories(User user);
// конкретний товар
public Produit getProduitByIdWithCategorie(User user, Long idProduit);
public Produit getProduitByIdWithoutCategorie(User user, Long idProduit);
public Produit getProduitByNameWithCategorie(User user, String nom);
public Produit getProduitByNameWithoutCategorie(User user, String nom);
// конкретна категорія
public Categorie getCategorieByIdWithProduits(User user, Long idCategorie);
public Categorie getCategorieByIdWithoutProduits(User user, Long idCategorie);
public Categorie getCategorieByNameWithProduits(User user, String nom);
public Categorie getCategorieByNameWithoutProduits(User user, String nom);
}
- рядок 12: ми додали метод [authenticate(User user)] для автентифікації користувача. Він генерує виняток, якщо користувач не має права доступу до URL [/authenticate] веб-сервісу;
16.5.3. Клас [Dao]
Усі методи класу [Dao] отримують додатковий параметр [User user], який вони передають у загальний метод [getResponse] класу [AbstractDao]. Ось два приклади:
// автентифікація
@Override
public void authenticate(User user) {
getResponse(user, "/authenticate", null);
}
@Override
public List<Produit> addProduits(User user, List<Produit> produits) {
// ----------- додавання товарів (без зазначення категорії)
try {
// маппери jSON
ObjectMapper mapperPost = context.getBean(ObjectMapper.class);
mapperPost.setFilters(jsonFilterProduitWithoutCategorie);
ObjectMapper mapperResponse = mapperPost;
// запит
Response<List<Produit>> response = mapperResponse.readValue(
getResponse(user, "/addProduits", mapperPost.writeValueAsString(produits)),
new TypeReference<Response<List<Produit>>>() {
});
// помилка?
if (response.getStatus() != 0) {
// виникає 1 виняток
throw new DaoException(response.getStatus(), response.getMessages());
} else {
// повертається основна частина відповіді сервера
return response.getBody();
}
} catch (DaoException e1) {
throw e1;
} catch (IOException | RuntimeException e2) {
throw new DaoException(100, e2);
}
}
16.5.4. Юнітарні тести класу [Dao]
Клас [Test01] для модульних тестів класу [Dao] змінено наступним чином:
![]() |
package client.tests.junit;
...
@SpringApplicationConfiguration(classes = DaoConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class Test01 {
// контекст Spring
@Autowired
private ApplicationContext context;
// шар [DAO]
@Autowired
private IDaoClient dao;
// користувачі
static private User admin;
static private User user;
static private User unknown;
@BeforeClass
public static void init() {
admin = new User("admin", "admin");
user = new User("user", "user");
unknown = new User("x", "y");
}
@Before
public void cleanAndFill() {
// очищення бази даних перед кожним тестом
log("Vidage de la base de données", 1);
// очищаємо таблицю [CATEGORIES] — каскадно буде очищено таблицю [PRODUITS]
dao.deleteAllCategories(admin);
// --------------------------------------------------------------------------------------
log("Remplissage de la base", 1);
// заповнюються таблиці
List<Categorie> categories = new ArrayList<Categorie>();
for (int i = 0; i < 2; i++) {
Categorie categorie = new Categorie(String.format("categorie%d", i));
for (int j = 0; j < 5; j++) {
categorie.addProduit(new Produit(String.format("produit%d%d", i, j), 100 * (1 + (double) (i * 10 + j) / 100),
String.format("desc%d%d", i, j)));
}
categories.add(categorie);
}
// додавання категорії — за каскадним принципом товари також будуть внесені
dao.addCategories(admin, categories);
}
@Test
public void showDataBase() throws BeansException, JsonProcessingException {
// список категорій
log("Liste des catégories", 2);
List<Categorie> categories = dao.getAllCategories(admin);
affiche(categories, context.getBean("jsonMapperCategorieWithoutProduits", ObjectMapper.class));
// список товарів
log("Liste des produits", 2);
List<Produit> produits = dao.getAllProduits(admin);
affiche(produits, context.getBean("jsonMapperProduitWithoutCategorie", ObjectMapper.class));
// деякі перевірки
Assert.assertEquals(2, categories.size());
Assert.assertEquals(10, produits.size());
Categorie categorie = findCategorieByName("categorie0", categories);
Assert.assertNotNull(categorie);
Produit produit = findProduitByName("produit03", produits);
Assert.assertNotNull(produit);
Long idCategorie = produit.getIdCategorie();
Assert.assertEquals(categorie.getId(), idCategorie);
}
...
@Test()
public void checkUserUser() {
ServiceException se = null;
try {
dao.authenticate(user);
} catch (ServiceException e) {
se = e;
}
Assert.assertNotNull(se);
Assert.assertEquals("403 Forbidden", se.getMessages().get(0));
}
@Test()
public void checkUserUnknown() {
ServiceException se = null;
try {
dao.authenticate(unknown);
} catch (ServiceException e) {
se = e;
}
Assert.assertNotNull(se);
Assert.assertEquals("401 Unauthorized", se.getMessages().get(0));
}
@Test()
public void checkUserAdmin() {
ServiceException se = null;
try {
dao.authenticate(admin);
} catch (ServiceException e) {
se = e;
}
Assert.assertNull(se);
}
...
}
- під час ініціалізації тестового класу, рядки 21–26, створюються три користувачі:
- користувач [admin] має доступ до URL веб-сервісу, тест, рядки 96–104;
- користувач [user] існує, але не має дозволу на використання URL веб-сервісу, тест рядки 71–81;
- користувач [unknown] не існує, перевірка рядків 83–93;
- методи тестування такі самі, як і для незахищеного веб-сервісу, за винятком того, що методи інтерфейсу [IDaoClient] викликаються з першим параметром — користувачем [admin], який має право використовувати URL;
Тест пройшов успішно, але можна помітити, що він працює повільніше, ніж із незахищеним веб-сервісом. Захист додатка суттєво збільшує час його відгуку. Варто відзначити важливий фактор, що впливає на продуктивність захищеного веб-сервісу: у класі [AppConfig], який його налаштовує, ми написали:
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// захищений додаток?
if (activateSecurity) {
// пароль передається у заголовку Authorization: Basic xxxx
http.httpBasic();
// метод HTTP OPTIONS має бути дозволений для всіх
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// лише роль ADMIN може використовувати додаток
http.authorizeRequests() //
.antMatchers("/", "/**") // усі URL
.hasRole("ADMIN");
// сесія відсутня
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
Рядок 17 має свою ціну. Він змушує користувача проходити аутентифікацію при кожному доступі. Якщо його закомментувати, тривалість попереднього тесту JUnit скорочується з 10,57 секунди до 4,21 секунди, це відбувається тому, що користувач [admin] проходить аутентифікацію лише під час першого тесту, а не під час наступних (навіть якщо заголовок аутентифікації HTTP надсилається клієнтом, сервер не перевіряє пароль користувача повторно). У разі незахищеного веб-сервісу тривалість тесту JUnit скорочується до 2,33 секунди.
























































