Команда Spring АйО перевела и адаптировала докладДаниэля Гарнье‑Муару «Spring Security Architecture Principles», в котором на наглядных примерах рассказывается, как пользоваться возможностями Spring Security, не запутываясь на каждом шагу и не зарабатывая себе головную боль. 

Доклад публикуется тремя частями. В первой части было рассказано об основных подходах к созданию цепочек фильтров, а также разработан простейший фильтр. Во второй части мы рассказали об Authentication объектах и продемонстрировали, как разработать специализированный фильтр для обеспечения доступа программы-робота к основному приложению. В третьей части мы поговорим об абстракции AuthenticationProvider и приведем пример ее использования.


Ну что же, поговорим об абстракции AuthenticationProvider

Во второй части серии, где говорилось о реализациях Authentication, было упомянуто, что isAuthenticated() всегда возвращает true и что credentials всегда null. На деле все несколько сложнее, и никто не стал бы хранить эти свойства, если бы их значения были всегда одинаковы. Во второй части мы лишь несколько упростили ситуацию, чтобы не усложнять приведенный пример. 

Authentication объекты — это нечто большее, чем просто представление залогиненного пользователя. В Spring Security они используются для двух вещей. Это либо мешок с креденшелами, которые я хочу аутентифицировать, либо результат успешной аутентификации. То есть если вы берете сценарий с именем пользователя и паролем, передача от имени пользователя и пароля в Authentication объект происходит в AuthenticationManager.

Spring Security считает запрос и создаст UsernamePasswordAuthenticationToken, который пока не аутентифицирован и содержит имя пользователя и пароль, но не содержит адреса электронной почты или дня рождения пользователя, потому что он еще не залогинен, и необходимо проверить пароль.

Затем все эти данные передаются в метод AuthenticationManager.authenticate(),  осуществит проверку с данными полученными из базы/иного источника данных или же выполнит ее, работая с данными хранящимися в памяти, как в этом демо, и, если пользователь существует и пароль правильный, он производит аутентифицированную версию UsernamePasswordAuthenticationToken. В этой аутентифицированной версии у пользователя есть не только имя, но и другие детали, такие как адрес электронной почты, дата рождения, роли, права и т.д. В этом случае isAuthenticated равно true.

Если что-то идет не так, например, пароль неверный или пользователя не существует, тогда AuthenticationManager отклоняет запрос и выбрасывает AuthenticationException.

Существуют многочисленные реализации AuthenticationManager, но та, что вам нужна на данный момент — это ProviderManager. По сути это список провайдеров аутентификации с неким дополнительным функционалом.

AuthenticationProvider — это набор объектов, вызываемых AuthenticationManager, каждый из которых умеет работать с конкретным типом объектов, имплементирующим Authentication. В нем есть метод аутентификации, но он работает только с одним типом аутентификации, то есть вы можете реализовывать разные сценарии аутентификации в их собственных классах. То есть у AuthenticationProvider-ов есть метод authenticate(), они могут произвести успешную аутентификацию, выбросить AuthenticationException или сообщить нам, что аккаунт заблокирован. Иногда текущий AuthenticationProvider не знает, что делать с полученными данными и возвращает null, чтобы мы могли передать решение этого вопроса другому провайдеру. 

Пример сценария использования

Давайте сделаем сценарий использования для этого. Есть приложение, в котором присутствуют две страницы (публичная и закрытая), а также реализован API логина. Кроме того, робот от команды SRE может проверить закрытую страницу при помощи специального фильтра. Теперь нам нужен админ, чтобы администрировать это приложение.

Поэтому мы наняли парня из Парижа по имени Даниэль, у которого множество достоинств, но при этом, к сожалению, очень плохая память. Даниэль не может запомнить свой пароль, поэтому мы сделаем то, что никогда не следует делать в продакшен. Это допустимо только для демо. Мы скажем нашему приложению: когда приходит Даниэль, просто не проверяй пароль. Это наш админ, его можно впустить и так.

Это можно сделать, просто создав новый фильтр, но такой подход подразумевает много ручной работы: считать тело POST запроса, извлечь из него имя пользователя и пароль, поместить их в две переменные, но зачем делать то, что уже сделано за нас? Поэтому мы будем работать с реализацией AuthenticationProvider.

class DanielAuthenticationProvider implements AuthenticationProvider {
}

Здесь будут два метода, и первый из них — это supports(), который говорит вам, какие типы Authentication мы поддерживаем. Это нужно только для случая логина с именем пользователя и паролем.

return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);

Так что, если кто-то пытается залогиниться через Google OAuth2, мы не будем применять этот провайдер. То же самое верно и для логина с помощью робота. 

Второй метод — это authenticate(), в котором мы делаем проверку аутентификации:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (Objects.equals(authentication.getName(), "daniel")) {
		// ok!
}
// ????
}

Окей, мы должны вернуть UsernamePasswordAuthenticationToken. Давайте создадим пользователя. Мы можем загрузить его из базы данных. Давайте создадим Даниэля с паролем — здесь придется установить пароль, он не важен в нашем случае, но так уж получилось, что билдер пользователя нуждается в пароле. Нужны также роли, поэтому скажем, что Даниэль является как пользователем, так и админом. Потому что именно для этой цели мы наняли Даниэля.

Получается вот такой код:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
	if (Objects.equals(authentication.getName(), "daniel")) {
    		var daniel = User.withUsername("daniel")
        			.password("~~~ignored~~~")
        			.roles("user", "admin")
        			.build();
	}
	// ????
}

Теперь  нам надо превратить это в объект аутентификации, поэтому используем  UsernamePasswordAuthenticationToken.authenticated(). Здесь нам понадобится Principal, то есть identity пользователя — и это daniel. Нужны креденшелы, но они могут быть null, так как в случае daniel-a нет смысла их проверять. И потом необходимо получить Authorities, поэтому вызываем getAuthorities().

Если же пользователь, который пытается залогиниться — не Даниэль, просто  делегируем и позволяем Spring Security сделать то, что должно быть сделано по умолчанию - просто возвращаем null, тем самым сигнализируем ProviderManager-у, что данный AuthenticationProvider не может аутентифицировать текущего пользователя. По итогу получаем следующее:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
	if (Objects.equals(authentication.getName(), "daniel")) {
    		var daniel = User.withUsername("daniel")
        			.password("~~~ignored~~~")
        			.roles("user", "admin")
        			.build();
    		return UsernamePasswordAuthenticationToken.authenticated(
        			daniel,
        			null,
        			daniel.getAuthorities()
    		);
	}
	return null;
}

Теперь, когда у нас есть AuthenticationProvider, мы обязаны зарегистрировать его в SecurityConfig:

class SecurityConfig {
	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        			.formLogin(l -> l.defaultSuccessUrl("/private"))
        			.logout(l -> l.logoutSuccessUrl("/"))
        			.oauth2Login(withDefaults())
        			.addFilterBefore(new ProhibidoFilter(), AuthorizationFilter.class)
        			.addFilterBefore(new RobotAuthenticationFilter(), AuthorizationFilter.class)
        			.authenticationProvider(new DanielAuthenticationProvider())
        			.build();
	}
}

Порядок следования фильтров в этой конфигурации очень важен, а именно, ваши собственные провайдеры аутентификации должны идти первыми. Так у вас появляется шанс отработать свое собственное поведение до того, как делегировать остальное Spring Security. 

Если теперь перейти на страницу логина, ввести имя пользователя daniel, произвольный набор символов в качестве пароля и нажать Sign in, мы попадаем на закрытую страницу, и она говорит нам: «Привет, Даниэль!»

Если же попытаться залогиниться с другим пользователем, но с неверным паролем, мы получим, как и следовало ожидать, ошибку «Bad credentials». Только у Даниэля есть особый доступ к роли админа. 

Такой подход позволяет нам проверять имя пользователя и пароль, не думая о том, как они здесь появились. Что очень хорошо, потому что если завтра появится другая схема аутентификации, которая производит запросы на аутентификацию в форме имени пользователя и пароля, тогда по умолчанию будет использоваться этот новый провайдер. Соответственно, если завтра в конфигурации появится httpBasic:

class SecurityConfig {
	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        			.formLogin(l -> l.defaultSuccessUrl("/private"))
        			.logout(l -> l.logoutSuccessUrl("/"))
        			.oauth2Login(withDefaults())
				.httpBasic(withDefaults())
        			.addFilterBefore(new ProhibidoFilter(), AuthorizationFilter.class)
        			.addFilterBefore(new RobotAuthenticationFilter(), AuthorizationFilter.class)
        			.authenticationProvider(new DanielAuthenticationProvider())
        			.build();
	}
}

то мы сможем сделать HTTP запрос:

http :8080/private --auth daniel:springio-is-awesome

Результатом станет исходный код закрытой страницы:

<head>
  <meta charset="UTF-8">
  <title>? Private Page [Spring Sec: The Good Parts]</title>
  <link rel="stylesheet" href="css/style.css">
  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
</head>
<body>
  <h1>VIP section ???</h1>
  <p>Hello, ~[daniel]~ !</p>
  <p>You are on the very exclusive private page.</p>
  <p></p>
  <form method="post" action="/logout">
	<input name="_csrf" type="hidden" value="4BftfkRXRK0ykGDGjVolgKgk3Hrf3JzErwjF0T8Tg7PZpkq6gXLVT3dhJc8foAbwu3cRt58R8Rjtua7pzjik6Ft15YrvnnqN">
	<button class="btn" type="submit">Log out</button>
  </form>
</body>
</html>

Но если в запросе будут креденшелы user:foobar, запрос будет отклонен. И никакого дополнительного кода писать для этого не пришлось. О таких вещах, как HTML, HTTP и прочее, нам беспокоиться не надо. При этом мы имеем полный доступ ко всем преимуществам, всем механизмам безопасности, которые уже встроены в AuthenticationManager. Там есть и многое другое, например, события, observability; можно реализовать пример события прямо здесь и сейчас, добавив следующий бин:

@Bean
ApplicationListener<AuthenticationSuccessEvent> listener() {
	return (evt) -> {
    		var auth = evt.getAuthentication();
    		System.out.println(
        			"? [%s] logged in as [%s]".formatted(
            		auth.getName(),
            		auth.getClass().getSimpleName()
        			)
    		);
	};
}

Этот слушатель пишет в логи каждое событие успешной аутентификации. Поэтому, если зайти в приложение как daniel, в самом низу в логах появится:

? [daniel] logged in as [UsernamePasswordAuthenticationToken]

А если залогиниться через Google, получим следующее:

? [106669480804459984951] logged in as [OAuth2LoginAuthenticationToken]

Подведем итоги

Вкратце суммируя вышесказанное, получим следующие основные выводы:

  • Authentication объект — это и запрос на аутентификацию, и его успешный результат.

  • AuthenticationProvider валидирует креденшелы и оперирует только в домене “auth” (он никак не связан с HTTP, HTML и т.д.)

  • AuthenticationProvider использует для своих целей инфраструктуру Spring Security

И еще один момент: когда вы хотите реализовать кастомизированный логин, вы делаете фильтр плюс AuthenticatioProvider. Это не вошло в доклад и в статью, но пример можно посмотреть в репозитории.


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

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