Encodera

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.

Оглавление

Вход на сайт: форма и сессия

API проверяет пароль в каждом запросе, но человеку в браузере так неудобно. Для сайта используют вход через форму: пользователь один раз вводит имя и пароль, сервер запоминает его в сессии, а браузер хранит номер сессии в cookie JSESSIONID. Для сайта мы выбрали именно сессии, а не токены JWT: они встроены в Spring Security, а для сайта с серверными страницами их достаточно. JWT нужен, когда к API обращается отдельное приложение — например, мобильное; об этом следующий урок.

Вторая цепочка

// src/main/java/com/example/blog/SecurityConfig.java
@Bean
@Order(2)
public SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
    http
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/posts/new").authenticated()
                    .requestMatchers(HttpMethod.GET, "/", "/posts/*", "/css/**").permitAll()
                    .requestMatchers("/error").permitAll()
                    .anyRequest().authenticated())
            .formLogin(form -> form
                    .loginPage("/login")
                    .defaultSuccessUrl("/", false)
                    .permitAll())
            .logout(logout -> logout.logoutSuccessUrl("/"));
    return http.build();
}

У этой цепочки нет securityMatcher, поэтому она берёт всё, что не забрала цепочка API с @Order(1). Строка /posts/new стоит выше /posts/*: иначе форма новой статьи попала бы под «открыто для всех». CSRF здесь включён — для форм в браузере он нужен.

Страница входа

// src/main/java/com/example/blog/PageController.java
@GetMapping("/login")
public String login() {
    return "login";
}
<!-- src/main/resources/templates/login.html -->
<p class="error" th:if="${param.error}">Неверное имя или пароль.</p>
 
<form th:action="@{/login}" method="post">
    <label>Имя <input type="text" name="username" autofocus></label>
    <label>Пароль <input type="password" name="password"></label>
    <button type="submit">Войти</button>
</form>

Поля должны называться username и password — так их ждёт Spring Security. Сам POST /login обрабатывает он же, писать метод не нужно. При неверном пароле браузер вернётся на /login?error, и param.error покажет сообщение. Благодаря th:action Thymeleaf сам добавит в форму скрытое поле _csrf с токеном.

Кто вошёл

Чтобы показывать в шапке имя и кнопку выхода, подключите дополнение Thymeleaf для Spring Security:

<!-- pom.xml -->
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<!-- src/main/resources/templates/fragments/layout.html -->
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
 
<header th:fragment="header">
    <a th:href="@{/}">Блог</a>
    <a th:href="@{/posts/new}">Новая статья</a>
 
    <span sec:authorize="isAuthenticated()">
        <span sec:authentication="name">anna</span>
        <form th:action="@{/logout}" method="post" style="display: inline">
            <button type="submit">Выйти</button>
        </form>
    </span>
    <a sec:authorize="isAnonymous()" th:href="@{/login}">Войти</a>
</header>

Выход — это POST /logout с токеном CSRF, поэтому он сделан формой, а не ссылкой. В контроллере форм пользователя даёт параметр Principal: postService.create(form, principal.getName()), а список авторов из формы можно убрать.

Проверка

Откройте http://localhost:8080/posts/new — браузер перейдёт на /login. После входа Анной в шапке появится anna и кнопка «Выйти», а форма опубликует статью от её имени. Тот же путь через curl выглядит так: ответ на верный вход —

HTTP/1.1 302
Set-Cookie: JSESSIONID=E631F5BDDBCB28E0E6DF15E25E8E6754; Path=/; HttpOnly
Location: http://localhost:8080/

а с неверным паролем Location ведёт на /login?error. Отправка формы статьи без поля _csrf получит 403.

Частая ошибка

Если не открыть адрес /error, клиенты API вместо 401 начнут получать переадресацию на форму входа:

HTTP/1.1 302
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"

Цепочка API отвечает отказом, Spring показывает страницу /error, а её уже обрабатывает цепочка сайта и требует входа. Строка .requestMatchers("/error").permitAll() возвращает правильный 401.

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.