Skip to content

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. У ньому встановлено такі зв’язки:
URL
погляд
/, /home
/templates/home.html
/hello
/templates/hello.html
/login
/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 з правами доступу. У ньому встановлено такі зв’язки:
URL
правило
код
/, /home
доступ без аутентифікації

http.authorizeRequests().antMatchers("/", "/home").permitAll()
autres URL
доступ лише після автентифікації
http.anyRequest().authenticated();
  • рядок 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] таке:

Click <a href="/hello">here</a> to see a greeting.

URL [/hello] буде запитано, коли ви натиснете на посилання. Воно захищене:

URL
правило
код
/, /home
доступ без авторизації

http.authorizeRequests().antMatchers("/", "/home").permitAll()
autres URL
доступ лише для авторизованих користувачів
http.anyRequest().authenticated();

Щоб отримати доступ, необхідно пройти автентифікацію. Spring Security перенаправить браузер клієнта на сторінку автентифікації. Згідно з наведеною конфігурацією, це сторінка URL [/login]. Ця сторінка доступна для всіх:


http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();

Отже, ми отримуємо [1]:

Вихідний код отриманої сторінки такий:

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
...
    <form method="post" action="/login">
...
       <input type="hidden" name="_csrf" value="87bea06a-a177-459d-b279-c6068a7ad3eb" />
   </form>
</body>
</html>
  • у рядку 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]:
    <h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>

На сторінці [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] розширюється, щоб враховувати користувачів, їхні паролі та ролі. З’являються три нові таблиці:

Image

Таблиця [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:
Authorization:Basic code

де code — це кодування рядка login:password за алгоритмом Base64. Наприклад, Base64-кодування рядка admin:admin — це YWRtaW46YWRtaW4=. Отже, користувач із логіном [admin] та паролем [admin] надішле такий заголовок HTTP для автентифікації:

Authorization:Basic YWRtaW46YWRtaW4=
  • рядки 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];

Виконання тестів завершилося успішно з такими записами в журналах:

----------Utilisateur [{"id":14,"version":0,"identity":"admin","login":"admin","password":"$2a$10$FN1LMKjPU46aPffh9Zaw4exJOLo51JJPWrxqzak/eJrbt3CO9WzVG"}]
Roles :
{"id":6,"version":0,"name":"ROLE_ADMIN"}

----------Utilisateur [{"id":15,"version":0,"identity":"user","login":"user","password":"$2a$10$SJehR9Mv2VdyRZo9F0rXa.hKAoGLhJg6kSdyfExi40mEJrNOj0BTq"}]
Roles :
{"id":7,"version":0,"name":"ROLE_USER"}

----------Utilisateur [{"id":16,"version":0,"identity":"guest","login":"guest","password":"$2a$10$ubyWJb/vg2XZnUOAUjspZuz9jpHP3fIbPTbwQU115EtLdeSZ2PB7q"}]
Roles :
{"id":5,"version":0,"name":"ROLE_GUEST"}

----------Utilisateur [{"id":17,"version":0,"identity":"x","login":"x","password":"$2a$10$kEXA56wpKHFReVqwQTyWguKguK8I4uhA2zb6t3wGxag8Dyv7AhLom"}]
Roles :
{"id":5,"version":0,"name":"ROLE_GUEST"}

16.4.9. Тестування веб-сервісу

Ми протестуємо веб-сервіс із клієнтом Chrome [Advanced Rest Client]. Нам потрібно буде вказати заголовок автентифікації HTTP:

Authorization:Basic code

де [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]:

  

отримаємо такий результат:

YWRtaW46YWRtaW4=

Тепер, коли ми знаємо, як згенерувати заголовок автентифікації 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]:

  

отримаємо такий результат:

dXNlcjp1c2Vy
  • у [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 клієнт тепер повинен надіслати заголовок автентифікації, наприклад:

Authorization:Basic YWRtaW46YWRtaW4=

Це робиться наступним чином:


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 секунди.