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 проектах с особенностями. Регистрация — по ссылке.