JavaRush /Курсы /Spring Security /formLogin и login pag...

formLogin и login page

Spring Security
8 уровень , 1 лекция
Открыта

1. Базовый formLogin

Когда защищённый endpoint уже есть и browser-friendly вход уже в принципе нужен, следующий вопрос становится очень приземлённым: где у этого login flow страница, куда улетают credentials и что именно должно быть доступно anonymous-пользователю. Здесь легко смешать formLogin, страницу входа и саму обработку логина, поэтому давай разложим их по местам.

Как только вы объявляете свой SecurityFilterChain, Spring Security перестаёт молча тащить за вас весь стартовый browser-login baseline. Это хорошая новость: поведение становится явным. Но вместе с этим способ входа тоже нужно включить явно, иначе у пользователя снова будет защищённый endpoint без понятной двери.

Без вашей конфигурации starter обычно закрывает всё по умолчанию и включает стандартный browser-friendly вход. Но после собственного SecurityFilterChain такие вещи уже стоит считать своей ответственностью.

Самый минимальный вариант (мы его будем развивать дальше) выглядит примерно так:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    // Собираем SecurityFilterChain вручную: как только вы объявили этот бин,
    // Spring Security перестаёт применять "авто-дефолты" и слушает вашу конфигурацию.
    return http
        .authorizeHttpRequests(auth -> auth
            // Любой запрос требует аутентификации
            .anyRequest().authenticated()
        )
        // Включаем form-based login
        // permitAll() здесь открывает только входные точки (/login и обработку логина), а не "всё приложение"
        .formLogin(form -> form.permitAll())
        .build();
}

Обратите внимание на важную деталь: form.permitAll() не “открывает ваше приложение”, а делает достижимыми только login page и endpoint обработки логина. Иначе получится система, в которую нельзя войти вовсе.

Встроенная страница входа без UI-сложности

Встроенная login page Spring Security — это один из тех инструментов, которые любят ругать примерно так же, как “встроенный калькулятор в Windows”: “ну да, простенький, но почему-то всегда под рукой”. Для обучения это идеальный вариант: вы можете наблюдать работу механики входа, не отвлекаясь на верстку, шаблонизаторы и фронтенд.

Если у вас есть UserDetailsService с in-memory пользователями и вы включили formLogin, то Spring Security сам покажет страницу логина. Важно понимать: эта страница — не “фронтенд вашего приложения”, а временная точка входа, чтобы не смешивать механику security с версткой.

Пример более реалистичного минимального конфига под наш проект (публичное читаем, приватное требует входа) может выглядеть так:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // Публичные endpoint'ы доступны без логина
            .requestMatchers("/api/public/**").permitAll()
            // Всё остальное — только для authenticated
            .anyRequest().authenticated()
        )
        // Включаем formLogin и разрешаем доступ к /login всем (иначе "дверь" будет закрыта)
        .formLogin(form -> form.permitAll())
        .build();
}

Что вы увидите в браузере? Если открыть /api/me или любой другой protected endpoint, Spring Security не “молча даст 401”, а перенаправит на страницу /login и предложит ввести логин/пароль. Это и есть ключевая фишка browser-oriented сценария: пользователю не нужно вручную формировать заголовки, он видит понятную форму.

И да, у этой формы есть одно важное педагогическое свойство: она помогает мозгу перестать думать, что безопасность — это “какие-то аннотации в контроллере”. Вы буквально видите: “вот момент, когда система спрашивает credentials”.

Два шага логина: страница и проверка

Самая частая путаница новичка в formLogin — это идея “логин = один URL”. В реальности логин — это сценарий, где участвуют минимум два запроса. Первый запрос показывает страницу, второй отправляет введённые данные. И если держать это в голове, магия внезапно превращается в вполне инженерную последовательность шагов.

Схематично это можно представить так:

flowchart TD
    A["Браузер хочет попасть на /api/me"] --> B["Сервер: нужно войти (redirect)"]
    B --> C["GET /login -> HTML-страница с формой"]
    C --> D["Пользователь вводит логин/пароль"]
    D --> E["POST /login (или другой loginProcessingUrl)"]
    E --> F["Spring Security проверяет credentials"]
    F --> G["Если ок: пользователь становится authenticated для следующих запросов"]

Обратите внимание на детали, которые особенно полезны именно сегодня:

Встроенная страница логина обычно находится по адресу /login и отдаётся на GET /login. То есть это “что показать пользователю”. А обработка логина — это POST /login (по умолчанию), где браузер отправляет поля формы. Это “куда реально отправляются credentials”.

Чтобы легче закрепилось, вот компактная табличка:

Шаг HTTP-запрос Смысл Кто “обслуживает”
1 GET /login Отдать страницу, где пользователь введёт данные Spring Security (генерирует HTML) или ваше приложение (если вы зададите loginPage)
2 POST /login Принять username/password и проверить Spring Security внутри filter chain

И ещё один важный практический момент: по умолчанию имена полей формы — username и password. Встроенная страница отправляет именно их, и formLogin ожидает именно их. Если вы делаете свою страницу, вы обязаны попасть в эти ожидания (или отдельно настраивать параметры — но это уже тема, которую мы сегодня сознательно не раздуваем).

permitAll() для login endpoints

Когда вы впервые пишете строгий SecurityFilterChain, у вас появляется соблазн сделать так: “всё закрыть, а потом открывать точечно”. Это отличная стратегия. Но она же порождает классическую ошибку: вы случайно закрываете саму дверь, через которую пользователь должен зайти, то есть /login и endpoint обработки логина. Получается security-версия анекдота: “чтобы войти в систему, войдите в систему”.

Самый прямой и базовый для текущего baseline способ этого не сделать — оставить form.permitAll() рядом с formLogin. Тогда login page и обработчик логина остаются достижимыми там же, где вы описываете сам flow.

// Открывает доступ к /login (GET) и обработчику логина (POST) даже если остальные запросы требуют auth
.formLogin(form -> form.permitAll())

Иногда те же точки открывают руками в authorizeHttpRequests. Это полезно, когда вы сознательно меняете URL-ы и хотите видеть их рядом с остальными matcher-правилами.

.authorizeHttpRequests(auth -> auth
    // Разрешаем страницу логина (GET /login)
    .requestMatchers("/login").permitAll()
    // Разрешаем обработку логина (POST /perform-login)
    .requestMatchers("/perform-login").permitAll()
    // Всё остальное закрыто
    .anyRequest().authenticated()
)

Это уже более verbose-стиль. Для текущего baseline обычно достаточно form.permitAll(): меньше шансов открыть одно место и забыть про другое.

Ещё один нюанс, который всплывает именно на кастомной странице: если ваша login page тянет стили и скрипты, то эти ресурсы тоже должны быть доступны без логина. Иначе вы получите ситуацию, когда страница входа открывается, но выглядит как “HTML из 2003 года, потому что CSS не пустили”. Это не страшно, но раздражает и отвлекает. На этапе обучения мы обычно держим страницу максимально простой, чтобы не плодить лишние разрешения.

2. Собственная login page

Своя login page без Thymeleaf

Собственная login page часто выглядит как “ну давайте сделаем красивее”. Но технически это гораздо более важное решение: как только вы задаёте loginPage(...), вы говорите Spring Security: “не генерируй страницу логина, я сам(а)”. И если вы после этого не отдадите HTML по нужному URL — у вас будет не логин, а честный 404 Not Found. И это будет максимально честный 404: “страницы нет, потому что её никто не сделал”.

Минимальная конфигурация с собственной страницей выглядит так:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // Для примера: защищаем всё
            .anyRequest().authenticated()
        )
        .formLogin(form -> form
            // Говорим: страницу логина по этому URL отдаёт приложение (а не Spring Security)
            .loginPage("/login")
            // И при этом входная точка должна быть доступна без аутентификации
            .permitAll()
        )
        .build();
}

Ключевая мысль: loginPage("/login") означает, что GET /login должно обработать ваше приложение, а не Spring Security. Самый простой “учебный” способ — отдать HTML прямо из контроллера. Да, это выглядит как “мы вернулись в эпоху динозавров”, но зато не требует ни шаблонизаторов, ни фронтенда.

Вот минимальный контроллер, который отдаёт HTML-форму:

import org.springframework.http.MediaType;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class LoginPageController {

    @GetMapping(value = "/login", produces = MediaType.TEXT_HTML_VALUE)
    String loginPage(CsrfToken csrf) {
        // Важно: при включённом CSRF нужно добавить скрытое поле с токеном,
        // иначе POST формы на обработчик логина будет отклонён.
        return """
            <form method="post" action="/login">
              <input type="hidden" name="%s" value="%s"/>
              <input name="username"/>
              <input name="password" type="password"/>
              <button>Login</button>
            </form>
            """.formatted(csrf.getParameterName(), csrf.getToken());
    }
}

Здесь есть один “служебный” момент: скрытое поле с CSRF-токеном. Встроенная страница логина добавляет его автоматически, а ваша — нет, поэтому мы добавили его вручную. Почему это вообще существует и от чего защищает — отдельная большая тема следующих дней. Сегодня достаточно понять простую практическую вещь: если CSRF включён, POST-форма без скрытого поля может не пройти.

И ещё раз подчеркну границу ответственности: мы создали только страницу. Мы не писали никакой @PostMapping("/login") и не будем писать. Проверка пароля — не задача контроллера.

loginPage vs loginProcessingUrl

Пока вы используете встроенную страницу, очень легко не заметить, что “страница” и “обработка” — разные понятия. Потому что по умолчанию они живут на одном URL /login, только с разными HTTP-методами. Но как только вы начинаете кастомизировать flow, это различие становится критичным — иначе вы получите состояние “форма отправляется куда-то, но логина нет”.

Если вы хотите разделить эти две части (и часто это делает жизнь яснее), вы задаёте loginProcessingUrl. Например, пусть страницу мы отдаём по /login, а credentials отправляем на /perform-login:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // Для примера: защищаем всё
            .anyRequest().authenticated()
        )
        .formLogin(form -> form
            // URL страницы входа (GET /login) отдаёт приложение
            .loginPage("/login")
            // URL обработки логина (POST /perform-login) перехватывает Spring Security фильтр
            .loginProcessingUrl("/perform-login")
            // Открываем эти endpoint'ы для всех, иначе логин станет недостижимым
            .permitAll()
        )
        .build();
}

Тогда ваша HTML-форма обязана отправлять POST именно туда:

String html(CsrfToken csrf) {
    // action должен совпадать с loginProcessingUrl
    // поля должны называться username/password (если вы не перенастраивали параметры formLogin)
    return """
        <form method="post" action="/perform-login">
          <input type="hidden" name="%s" value="%s"/>
          <input name="username"/><input name="password" type="password"/>
          <button>Login</button>
        </form>
        """.formatted(csrf.getParameterName(), csrf.getToken());
}

И вот тут происходит самый полезный “щелчок” в голове: loginProcessingUrl — это не “ваш контроллер”. Это endpoint, который обрабатывает Spring Security внутри filter chain. Если вы попытаетесь сделать так:

@PostMapping("/perform-login")
String performLogin() {
    // Этот метод, скорее всего, не будет вызван: запрос перехватит Spring Security фильтр
    return "not used";
}

то с высокой вероятностью вы потом будете смотреть в дебаггер и спрашивать: “Почему мой метод не вызывается?” А он не вызывается потому, что запрос перехватывает security-фильтр раньше, чем он доходит до MVC.

Для понимания механики это очень важный вывод: formLogin — это не “контроллерный логин”, это фильтровый логин, встроенный в request lifecycle.

3. Пример для Secure Content Platform API

Теперь давайте аккуратно приземлим всё в наш учебный домен Secure Content Platform API. На этом этапе нам не нужно усложнять конфигурацию всеми зонами доступа — сегодня важнее получить рабочий и наблюдаемый login flow. Поэтому мы делаем простую версию: публичные статьи доступны всем, всё остальное требует аутентификации, и вход делаем через встроенную страницу.

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // Публичная часть API доступна без логина
            .requestMatchers("/api/public/**").permitAll()
            // Любые остальные запросы требуют аутентификации
            .anyRequest().authenticated()
        )
        // Включаем browser-friendly вход через форму и открываем /login для всех
        .formLogin(form -> form.permitAll())
        .build();
}

В таком виде вы можете очень прозрачно проверить сценарий: зайти на публичные статьи без входа и попытаться открыть /api/me в браузере. На второй попытке вы увидите редирект на /login и форму.

Почему это хорошо именно сейчас? Потому что мы учимся отличать “правило доступа” от “способа входа”. Правило доступа вы уже умеете писать (authenticated()). А вот способ входа — это отдельная часть конфигурации, и formLogin делает её наглядной и дружелюбной для браузера. Дальше в следующих лекциях дня мы уже будем управлять успешным/неуспешным исходом и выходом из системы, но база уже должна быть понятна и не страшна.

4. Типичные ошибки в formLogin и login page

Ошибка №1: написать SecurityFilterChain, но не включить ни formLogin, ни другой способ аутентификации.
Такое часто случается после первой эйфории “я написал конфиг!”. В результате вы закрываете endpoint'ы через authenticated(), но не даёте пользователю понятного способа войти. Снаружи это выглядит как “всё запрещено и непонятно почему”. Лечится просто: если хотите browser login — включите formLogin() явно.

Ошибка №2: забыть про permitAll() и случайно закрыть /login.
Это выглядит особенно комично: вы требуете аутентификацию для всех запросов, включая страницу входа. Браузер начинает ходить по кругу (редирект на логин, который тоже закрыт), а вы получаете ощущение “Spring сломался”. На самом деле сломалась логика правил доступа. Для начала держите form.permitAll() рядом с formLogin и не усложняйте.

Ошибка №3: путать loginPage и loginProcessingUrl и ожидать, что “страница логина сама проверит пароль”.
Страница логина — это просто HTML, который пользователь видит. Проверка пароля происходит в security-слое, внутри filter chain, и к вашей странице относится только тем, что форма должна отправить нужные поля по нужному URL. Если в голове это смешалось, всё становится магией. Разведите: loginPage — “что показать”, loginProcessingUrl — “куда отправить”.

Ошибка №4: задать loginPage("/login"), но не реализовать GET /login.
Как только вы указываете свою страницу, встроенная генерация отключается. Если вы не добавили контроллер/ресурс, который отдаёт HTML, вы получите 404 и будете думать, что “formLogin не работает”. Он работает, просто вы пообещали страницу и не выдали.

Ошибка №5: пытаться сделать контроллер на loginProcessingUrl и удивляться, что он не вызывается.
POST /perform-login обрабатывается не вашим контроллером, а Spring Security фильтром. Если вы напишете @PostMapping("/perform-login"), вы почти наверняка увидите, что метод не срабатывает. Это не баг, это архитектура: фильтры работают до MVC.

1
Задача
Spring Security, 8 уровень, 1 лекция
Недоступна
Минимальный `formLogin` со встроенной страницей входа
Минимальный `formLogin` со встроенной страницей входа
1
Задача
Spring Security, 8 уровень, 1 лекция
Недоступна
Собственная login page и отдельный `loginProcessingUrl`
Собственная login page и отдельный `loginProcessingUrl`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ