Thymeleaf появился довольно давно, как минимум 10 лет назад, но он до сих пор весьма популярен и активно поддерживается. Шаблоны Thymeleaf удобны тем, что при простом открытии в браузере они выглядят как обычные HTML-страницы и их можно использовать как статический прототип приложения.
В этой статье рассмотрим, как создать простое приложение Spring WebFlux с Thymeleaf, аутентификацией Okta OIDC, защитой от CSRF-атак и контролем полномочий.
Будем использовать следующие фреймворки и инструменты:
HTTPie 3.0.2
Java 11
Okta CLI 0.10.0
Что такое 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 вы можете в следующих статьях:
Learn How to Build a Single-Page App with Vue and Spring Boot
Kubernetes to the Cloud with Spring Boot and JHipster
Spring Native in Action with the Okta Spring Boot Starter
Исходный код из статьи вы можете найти на GitHub.
Всех, дочитавших статью до конца, приглашаем на открытое занятие «Validation Framework в Spring». На занятии рассмотрим, как валидировать различные объекты с использованием javax.validation, в Spring проектах с особенностями. Регистрация — по ссылке.