Thymeleaf появился довольно давно, как минимум 10 лет назад, но он до сих пор весьма популярен и активно поддерживается. Шаблоны Thymeleaf удобны тем, что при простом открытии в браузере они выглядят как обычные HTML-страницы и их можно использовать как статический прототип приложения.

В этой статье рассмотрим, как создать простое приложение Spring WebFlux с Thymeleaf, аутентификацией Okta OIDC, защитой от CSRF-атак и контролем полномочий.

Будем использовать следующие фреймворки и инструменты:

Что такое Thymeleaf?

Thymeleaf — это опенсорсный серверный шаблонизатор для различных типов приложений как веб, так и других, созданный Даниэлем Фернандесом (Daniel Fernández). Шаблоны похожи на HTML и могут использоваться со Spring MVC, Spring Security и другими популярными фреймворками. В том числе есть интеграция со Spring WebFlux, но на данный момент об этом довольно мало информации. Thymeleaf-стартер выполняет автоматическую настройку template engine, template resolver и reactive view resolver. 

Возможности Thymeleaf включают в себя:

  • Работу с фрагментами: рендеринг только части шаблона. Может использоваться при обновлении части страницы при ответе на AJAX-запросы. Также есть механизм "компонент": фрагменты могут включаться в несколько разных шаблонов.

  • Обработку форм с использованием объектов-моделей, содержащих поля формы.

  • Рендеринг переменных и внешних текстовых сообщений с помощью языка выражений Thymeleaf Standard Expression Syntax.

  • Наличие циклов и условных конструкций.

Spring WebFlux-приложение с Thymeleaf

Мы напишем простое монолитное реактивное приложение на Spring Boot с Thymeleaf. Заготовку приложения можно создать через веб-интерфейс Spring Initializr или с помощью следующей команды HTTPie:

https -d start.spring.io/starter.zip bootVersion==2.6.4 \
  baseDir==thymeleaf-security \
  groupId==com.okta.developer.thymeleaf-security \
  artifactId==thymeleaf-security \
  name==thymeleaf-security \
  packageName==com.okta.developer.demo \
  javaVersion==11 \
  dependencies==webflux,okta,thymeleaf,devtools

У нас будет Maven-проект. Распакуйте его и добавьте пару зависимостей: thymeleaf-extras-springsecurity5 для поддержки Spring Security в шаблонах и spring-security-test для тестов.

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
    <version>3.0.4.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

Аутентификация с помощью OpenID Connect

Вам понадобится бесплатный аккаунт разработчика Okta. Установите Okta CLI и запустите okta register для создания нового аккаунта. Если у вас уже есть учетная запись, то используйте okta login. Для создания нового приложения выполните okta apps create

Имя приложения (Application name) можете оставить по умолчанию или изменить по вашему усмотрению. Тип приложения (Type of Application) выберите Web. Framework of Application — Okta Spring Boot Starter. Значение Redirect URI оставьте по умолчанию: перенаправление входа (Login Redirect) на http://localhost:8080/login/oauth2/code/okta и выхода (Logout Redirect) на http://localhost:8080.

Okta CLI создаст OIDC Web App в вашей Okta Org, добавит указанные вами URI перенаправления и предоставит доступ группе Everyone. После завершения должно появиться сообщение, похожее на это:

Okta application configuration has been written to: 
  /path/to/app/src/main/resources/application.properties

Реквизиты доступа вашего приложения будут в файле src/main/resources/application.properties.

okta.oauth2.issuer=https://dev-133337.okta.com/oauth2/default
okta.oauth2.client-id=0oab8eb55Kb9jdMIr5d6
okta.oauth2.client-secret=NEVER-SHOW-SECRETS

Для создания приложения вы также можете использовать Okta Admin Console. Подробнее об этом см. раздел Create a Spring Boot App в документации.

Давайте переименуем application.properties в application.yml и добавим следующие параметры:

spring:
  thymeleaf:
    prefix: file:src/main/resources/templates/  
  security:
    oauth2:
      client:
        provider:
          okta:
            user-name-attribute: email

okta:
  oauth2:
    issuer: https://{yourOktaDomain}/oauth2/default
    client-id: {clientId}
    client-secret: {clientSecret}
    scopes:
      - email
      - openid

Обратите внимание, что нам пока не нужен scope profile. Для запросов OpenID Connect обязателен только openid. Свойство thymeleaf.prefix разрешает горячую перезагрузку шаблонов, если в проект подключена зависимость spring-boot-devtools.

Шаблоны Thymeleaf

Для шаблонов создайте папку src/main/resources/templates и в ней файл home.html со следующим содержимым:

<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>User Details</title>
    <!--/*/ <th:block th:include="head :: head"/> /*/-->
</head>
<body id="samples">
<div th:replace="menu :: menu"></div>

<div id="content" class="container">
    <h2>Okta Hosted Login + Spring Boot Example</h2>

    <div th:unless="${#authorization.expression('isAuthenticated()')}" class="text fw-light fs-6 lh-1">
        <p>Hello!</p>
        <p>If you're viewing this page then you have successfully configured and started this example server.</p>
        <p>This example shows you how to use the <a href="https://github.com/okta/okta-spring-boot">Okta Spring Boot
            Starter</a> to add the <a
            href="https://developer.okta.com/docs/guides/implement-grant-type/authcode/main/">Authorization
            Code Flow</a> to your application.</p>
        <p>When you click the login button below, you will be redirected to the login page on your Okta org. After you
            authenticate, you will be returned to this application.</p>
    </div>

    <div th:if="${#authorization.expression('isAuthenticated()')}" class="text fw-light fs-6 lh-1">
        <p>Welcome home, <span th:text="${#authentication.principal.name}">Joe Coder</span>!</p>
        <p>You have successfully authenticated against your Okta org, and have been redirected back to this 
          application.</p>
    </div>

    <form th:unless="${#authorization.expression('isAuthenticated()')}" method="get" 
          th:action="@{/oauth2/authorization/okta}">
        <button id="login-button" class="btn btn-primary" type="submit">Sign In</button>
    </form>

</div>
</body>
<!--/*/ <th:block th:include="footer :: footer"/> /*/-->
</html>

В приведенном выше шаблоне закомментированный тег <th:block/> позволяет включить фрагменты верхнего и нижнего колонтитулов, определенных в header.html и footer.html. Они содержат зависимости Bootstrap для оформления шаблонов. Также вместо <div th:replace ...> будет вставлен фрагмент меню.

Условные выражения th:if и th:unless используются для проверки статуса аутентификации. Если пользователь не аутентифицирован, будет отображаться кнопка "Sign In". Иначе — приветствие с именем пользователя.

Далее создайте шаблон head.html:

<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head">
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" 
          integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
</head>
<body>
<p>Nothing to see here, move along.</p>
</body>
</html>

И footer.html:

<html xmlns:th="http://www.thymeleaf.org">
<head>
</head>
<body>
<p>Nothing to see here, move along.</p>
</body>
<footer th:fragment="footer">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"  
            integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" 
            crossorigin="anonymous"></script>
</footer>
</html>

А также шаблон menu.html фрагмента меню:

<html xmlns:th="http://www.thymeleaf.org">

<body id="samples">
<nav class="navbar border mb-4 navbar-expand-lg navbar-light bg-light" th:fragment="menu">
    <div class="container-fluid">
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
                data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent"
                aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item"><a class="nav-link" th:href="@{/}">Home</a></li>
            </ul>
            <form class="d-flex" method="post" th:action="@{/logout}"
                  th:if="${#authorization.expression('isAuthenticated()')}">
                <input class="form-control me-2" type="hidden" th:name="${_csrf.parameterName}"
                       th:value="${_csrf.token}"/>
                <button id="logout-button" type="submit" class="btn btn-danger">Logout</button>
            </form>
        </div>
    </div>
</nav>
</body>
</html>

Контроллер

Для доступа к странице home потребуется контроллер. Создайте в пакете com.okta.developer.demo класс HomeController со следующим содержимым:

package com.okta.developer.demo;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.reactive.result.view.Rendering;
import reactor.core.publisher.Mono;

import java.util.List;
import java.util.stream.Collectors;

@Controller
public class HomeController {

    private static Logger logger = LoggerFactory.getLogger(HomeController.class);

    @GetMapping("/")
    public Mono<Rendering> home(Authentication authentication) {
        List<String> authorities = authentication.getAuthorities()
                .stream()
                .map(scope -> scope.toString())
                .collect(Collectors.toList());
        return Mono.just(Rendering.view("home").modelAttribute("authorities", authorities).build());
    }
}

Этот контроллер отображает представление home и заполняет в атрибуте модели полномочия (authorities) для дальнейшей проверки прав доступа.

Настройка безопасности

Okta-стартер по умолчанию настроен на аутентифицированный доступ ко всем страницам. Нам это нужно немного подправить, поэтому добавьте класс SecurityConfiguration в тот же пакет, что и раньше.

package com.okta.developer.demo;

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;

import java.net.URI;

@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfiguration {

    @Bean
    public ServerLogoutSuccessHandler logoutSuccessHandler(){
        RedirectServerLogoutSuccessHandler handler = new RedirectServerLogoutSuccessHandler();
        handler.setLogoutSuccessUrl(URI.create("/"));
        return handler;
    }

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http
            .authorizeExchange().pathMatchers("/").permitAll().and().anonymous()
            .and().authorizeExchange().anyExchange().authenticated()
            .and().oauth2Client()
            .and().oauth2Login()
            .and().logout().logoutSuccessHandler(logoutSuccessHandler());

        return http.build();
    }
}

Здесь мы разрешаем анонимный доступ всем пользователям к корневой странице (/), чтобы они могли залогиниться.

Запуск приложения

Запустите приложение с помощью Maven:

./mvnw spring-boot:run

Перейдите по адресу http://localhost:8080 — вы увидите страницу home и кнопку "Sign In". Нажмите кнопку и залогиньтесь, используя учетные данные Okta. После успешного входа вы должны быть перенаправлены на страницу home и увидеть содержимое для аутентифицированных пользователей.

Защита контента с помощью авторизации

Далее добавим шаблон userProfile.html, который будет отображать информацию о claim, содержащихся в ID токене, возвращенном Okta, а также полномочия (authorities), полученные Spring Security от токена.

<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>User Details</title>
    <!--/*/ <th:block th:include="head :: head"/> /*/-->
</head>
<body id="samples">
<div th:replace="menu :: menu"></div>

<div id="content" class="container">

    <div>
        <h2>My Profile</h2>
        <p>Hello, <span th:text="${#authentication.principal.attributes['name']}">Joe Coder</span>. Below is the 
            information that was read with your <a 
            href="https://developer.okta.com/docs/api/resources/oidc.html#get-user-information">ID Token</a>.
        </p>
        <p>This route is protected with the annotation <code>@PreAuthorize("hasAuthority('SCOPE_profile')")</code>, 
            which will ensure that this page cannot be accessed until you have authenticated, and have the scope <code>profile</code>.</p>
    </div>
    <table class="table table-striped">
        <thead>
        <tr>
            <th>Claim</th>
            <th>Value</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="item : ${details}">
              <td th:text="${item.key}">Key</td>
              <td th:id="${'claim-' + item.key}" th:text="${item.value}">Value</td>
          </tr>
        </tbody>
    </table>

    <table class="table table-striped">
        <thead>
        <tr>
            <th>Spring Security Authorities</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="scope : ${#authentication.authorities}">
              <td><code th:text="${scope}">Authority</code></td>
        </tr>
        </tbody>
    </table>

</div>
</body>
<!--/*/ <th:block th:include="footer :: footer"/> /*/-->
</html>

Настраиваем в HomeController маппинг:

@GetMapping("/profile")
@PreAuthorize("hasAuthority('SCOPE_profile')")
public Mono<Rendering> userDetails(OAuth2AuthenticationToken authentication) {
    return Mono.just(Rendering.view("userProfile")
        .modelAttribute("details", authentication.getPrincipal().getAttributes())
        .build());
}

Аннотация @PreAuthorize позволяет определить правила авторизации с помощью SpEL (Spring Expression Language). Правила проверяются перед выполнением метода. В данном случае только пользователи с полномочиями SCOPE_profile смогут обратиться к странице userProfile. Это защита на стороне сервера.

На клиентской стороне добавьте в шаблоне home.html ссылку для доступа к странице userProfile после "You successfully …". Ссылка будет отображаться только для пользователей с полномочиями (authority) SCOPE_profile.

<p>You have successfully authenticated against your Okta org, and have been redirected back to this application.</p>
<p th:if="${#lists.contains(authorities, 'SCOPE_profile')}">Visit the <a th:href="@{/profile}">My Profile</a> page in this application to view the information retrieved with your OAuth Access Token.</p>

Обратите внимание, что  условие авторизации реализовано именно таким образом, так как выражения вроде ${#authorization.expression('hasRole(''SCOPE_profile'')')} не работают в WebFlux из-за отсутствия поддержки в реактивном Spring Security (Spring Security 5.6). Поддерживается только минимальный набор выражений для проверки безопасности: [isAuthenticated(), isFullyAuthenticated(), isAnonymous(), isRememberMe()].

Запустите приложение еще раз. После входа в систему вы не увидите новую ссылку, но если перейдете по адресу http://localhost:8080/profile, то получите HTTP ERROR 403 Forbidden — доступ запрещен. Это связано с тем, что в application.yml мы настроили только получение scope для email и openid, а profile не возвращается в токене доступа (access token). Добавьте отсутствующий scope в application.yml, перезапустите. Теперь представление userProfile должно стать доступно:

Как видите, Spring Security назначает группы, содержащиеся в claim, а также запрошенные scope в качестве полномочий (authorities). У scope префикс SCOPE_. При создании приложения через Okta CLI по умолчанию создаются группы ROLE_ADMIN и ROLE_USER, и ваша учетная запись включается в эти группы.

Защита от CSRF-атак

Атака CSRF (Cross-site request forgery, межсайтовая подделка запроса) позволяет отправить данные с формы на странице злоумышленника на сайт-жертву, на котором пользователь уже аутентифицирован, и выполнить от лица пользователя вредоносные действия.

Защита от CSRF в Spring Security включена по умолчанию как для сервлет-приложений, так и для WebFlux. Основной способ защиты — Synchronizer Token Pattern. В каждый HTTP-запрос помещается случайно сгенерированное значение — CSRF-токен. Токен должен находиться в части запроса, которая не заполняется браузером автоматически. Например, для этого можно использовать HTTP-параметр или заголовок.

Давайте проверим защиту от CSRF, создав простое приложение для проведения опросов. Создайте шаблон quiz.html со следующим содержимым:

<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Thymeleaf Quiz</title>
    <!--/*/ <th:block th:include="head :: head"/> /*/-->
</head>
<body id="samples">
<div th:replace="menu :: menu"></div>

<div id="content" class="container">
    <div>
        <h2>Select the right answer</h2>
    </div>
    <form action="#" th:action="@{/quiz}" th:object="${quiz}"
          method="post" class="col-md-4 fw-light">
        <ul>
            <li th:errors="*{answer}" />
        </ul>
        <div class="col-md-12">
            <h3>What is Thymeleaf?</h3>
        </div>
        <div class="col-md-12 form-check">
            <input class="form-check-input" type="radio" th:field="*{answer}" value="A" id="check-1-1"/>
            <label class="form-check-label" for="check-1-1">
                <strong>A.</strong> A server-side Java template engine
            </label>
        </div>
        <div class="col-md-12 form-check">
            <input class="form-check-input" type="radio" th:field="*{answer}" value="B" id="check-1-2"/>
            <label class="form-check-label" for="check-1-2">
                <strong>B.</strong> A markup language
            </label>
        </div>
        <div class="col-md-12 form-check">
            <input class="form-check-input" type="radio" th:field="*{answer}" value="C" id="check-1-3"/>
            <label class="form-check-label" for="check-1-3">
                <strong>C.</strong> A web framework
            </label>
        </div>
        <div class="col-md-12 mt-4 mb-4">
            <p>Your CSRF token is: <span th:text="${_csrf.token}"/></p>
        </div>
        <div class="col-md-12">
            <button type="submit" class="btn btn-primary">Submit</button>
        </div>
    </form>
</div>
</body>
<!--/*/ <th:block th:include="footer :: footer"/> /*/-->
</html>

Токен CSRF доступен в качестве атрибута запроса, в учебных целях отобразим его в шаблоне quiz.html.

Также добавьте шаблон result.html для отображения результата опроса:

<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Thymeleaf Quiz Submission</title>
    <!--/*/ <th:block th:include="head :: head"/> /*/-->
</head>
<body id="samples">
<div th:replace="menu :: menu"></div>
<div class="container" id="content">
    <div class="text-center">
        <i class="bi-balloon-heart-fill" style="font-size: 6rem; color: green;" th:if=${quiz.answer=='A'}></i>
        <i class="bi-x-circle-fill" style="font-size: 6rem; color: red;" th:unless=${quiz.answer=='A'}></i>
        <div class="panel mt-4 text-center">
            <div class="panel-body">
                <h4>Your selected answer is <strong>
                    <span th:text="${quiz.answer}"></span>
                </strong></h4>
                <p th:if=${quiz.answer=='A'}>Good Job!</p>
            </div>
        </div>
        <div class="panel mt-4 text-center" th:unless=${quiz.answer=='A'}>
            <div class="panel-body">
                <p>It is not the right answer</p>
                <p><a th:href="@{/quiz}">Try again!</a></p>
            </div>
        </div>
    </div>
</div>
</body>
<!--/*/ <th:block th:include="footer :: footer"/> /*/-->
</html>

Далее класс QuizSubmission для хранения ответа:

package com.okta.developer.demo;

public class QuizSubmission {

    private String answer;

    public String getAnswer() {
        return answer;
    }

    public void setAnswer(String answer) {
        this.answer = answer;
    }
}

И контроллер QuizController для отображения опроса и обработки данных формы:

package com.okta.developer.demo;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.reactive.result.view.Rendering;
import reactor.core.publisher.Mono;

@Controller
public class QuizController {

    private static Logger logger = LoggerFactory.getLogger(QuizController.class);

    @GetMapping("/quiz")
    @PreAuthorize("hasAuthority('SCOPE_quiz')")
    public Mono<Rendering> showQuiz() {
        return Mono.just(Rendering.view("quiz").modelAttribute("quiz", new QuizSubmission()).build());
    }

    @PostMapping(path = "/quiz", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
    @PreAuthorize("hasAuthority('SCOPE_quiz')")
    public Mono<Rendering> saveQuiz(QuizSubmission quizSubmission) {
        return Mono.just(Rendering.view("result").modelAttribute("quiz", quizSubmission).build());
    }
}

В новом контроллере и шаблонах доступ к опросу разрешен только пользователям с полномочиями SCOPE_quiz. Добавьте защищенную ссылку в шаблон home.html после ссылки на профиль:

<p>You have successfully authenticated against your Okta org, and have been redirected back to this application.</p>
<p th:if="${#lists.contains(authorities, 'SCOPE_profile')}">Visit the <a th:href="@{/profile}">My Profile</a> page in this application to view the information retrieved with your OAuth Access Token.</p>
<p th:if="${#lists.contains(authorities, 'SCOPE_quiz')}">Visit the <a th:href="@{/quiz}">Thymeleaf Quiz</a> to test Cross-Site Request Forgery (CSRF) protection.</p>

Перед повторным запуском приложения давайте проверим защиту от CSRF с помощью теста. Создайте QuizControllerTest в src/test/java в пакете com.okta.developer.demo:

package com.okta.developer.demo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;

import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOidcLogin;

@WebFluxTest
public class QuizControllerTest {

    @Autowired
    private WebTestClient client;

    @Test
    void testPostQuiz_noCSRFToken() throws Exception {
        QuizSubmission quizSubmission = new QuizSubmission();
        this.client.mutateWith(mockOidcLogin())
                .post().uri("/quiz")
                .exchange()
                .expectStatus().isForbidden()
                .expectBody().returnResult()
                .toString().contains("An expected CSRF token cannot be found");
    }

    @Test
    void testPostQuiz() throws Exception {
        this.client.mutateWith(csrf()).mutateWith(mockOidcLogin())
                .post().uri("/quiz")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .exchange().expectStatus().isOk();
    }

    @Test
    void testGetQuiz_noAuth() throws Exception {
        this.client.get().uri("/quiz").exchange().expectStatus().is3xxRedirection();
    }

    @Test
    void testGetQuiz() throws Exception {
        this.client.mutateWith(mockOidcLogin())
                .get().uri("/quiz").exchange().expectStatus().isOk();
    }
}

Тест testPostQuiz_noCSRFToken() проверяет, что опрос не может быть отправлен без CSRF-токена, даже если пользователь залогинен. Второй тест testPostQuiz() — токен CSRF добавляется к фиктивному запросу с помощью mutateWith(csrf()). Здесь ожидаемый статус ответа — HTTP 200 OK. Третий тест testGetQuiz_noAuth() проверяет, что запрос будет перенаправлен (в форму входа Okta), если пользователь не аутентифицирован. И последний тест testGetQuiz() проверяет, что можно получить доступ к опросу, если пользователь аутентифицирован с помощью OIDC.

Поскольку quiz не является стандартным scope или scope, определенным в Okta, вам необходимо определить ее для default-сервера авторизации перед запуском приложения. Перейдите в Okta Admin Console в меню Security > API, выберите сервер авторизации default. На вкладке Scopes нажмите Add Scope. Введите имя (Name) quiz и описание (Display phrase). Остальные поля оставьте со значениями по умолчанию и нажмите Create. Теперь при логине через OIDC можно требовать scope quiz.

Запустите приложение, не добавляя scope quiz в application.yml, и войдите в систему — вы не должны видеть ссылку на тест. Если выполнить GET-запрос по адресу http://localhost:8080/quiz, то ответ будет 403 Forbidden.

Теперь добавьте quiz в список scopes в конфигурации Okta в application.yml. Окончательная конфигурация должна выглядеть следующим образом:

spring:
  security:
    oauth2:
      client:
        provider:
          okta:
            user-name-attribute: email

okta:
  oauth2:
    issuer: https://{yourOktaDomain}/oauth2/default
    client-id: {clientId}
    client-secret: {clientSecret}
    scopes:
      - email
      - openid
      - profile
      - quiz

Запустите приложение еще раз. Вы должны увидеть ссылку "Visit the Thymeleaf Quiz to test Cross-Site Request Forgery (CSRF) protection". Нажмите на ссылку — вы перейдете на страницу с quiz:

Spring Security  добавляет CSRF-токен в форму в виде скрытого атрибута <input type="hidden" name="_csrf" value="...">.

Можно выполнить POST-запрос с помощью HTTPie и убедиться еще раз, что CSRF-защита работает.

$ http POST http://localhost:8080/

HTTP/1.1 403 Forbidden
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/plain
Expires: 0
Pragma: no-cache
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1 ; mode=block
content-length: 38

An expected CSRF token cannot be found

Интересный факт — CSRF-защита приоритетнее аутентификации в цепочке фильтров Spring Security.

Больше о Spring Boot и Spring Security

Надеюсь, вам понравилось это краткое введение в Thymeleaf и вы узнали, как защитить контент и реализовать авторизацию на стороне сервера с помощью Spring Security. Вы также убедились, насколько быстро и легко интегрировать OIDC-аутентификацию с помощью Okta. Узнать больше о Spring Boot Security и OIDC вы можете в следующих статьях:

Исходный код из статьи вы можете найти на GitHub.


Всех, дочитавших статью до конца, приглашаем на открытое занятие «Validation Framework в Spring». На занятии рассмотрим, как валидировать различные объекты с использованием javax.validation, в Spring проектах с особенностями. Регистрация — по ссылке.

Комментарии (0)