Интерфейс PasswordEncoder из Spring Security используется для выполнения одностороннего преобразования пароля с целью обеспечения его безопасного хранения. Поскольку PasswordEncoder предусматривает одностороннее преобразование, он не предназначен для случаев, когда преобразование пароля должно быть двусторонним (т.е. для хранения учетных данных, используемых для аутентификации в базе данных). Обычно PasswordEncoder используется для хранения пароля, который необходимо сравнить с паролем пользователя во время аутентификации.

История сохраненных паролей

На протяжении многих лет стандартный механизм хранения паролей эволюционировал. Вначале пароли хранились в виде обычного текста. Предполагалось, что пароли безопасны, поскольку для доступа к хранилищу данных, в котором хранились пароли, требовались учетные данные. Однако злоумышленники смогли найти способы получения больших "дампов данных" имен пользователей и паролей, используя атаки с внедрением SQL-кода (SQL Injection). По мере того, как все больше и больше учетных данных пользователей утекали в общий доступ, экспертам по безопасности стало понятно, что необходимо усиливать защиту паролей пользователей.

Разработчикам было рекомендовано хранить пароли после их пропуска через односторонний алгоритм хэширования, например SHA-256. Когда пользователь пытался пройти аутентификацию, хэшированный пароль сопоставлялся с хэшем пароля, который этот пользователь ввёл. Это означало, что системе нужно было хранить только односторонний хэш пароля. В случае взлома были бы раскрыты только односторонние хэши паролей. Поскольку хэши были односторонними, а угадать пароль по хэшу было сложно с точки зрения вычислений, тратить усилия на то, чтобы подобрать каждый пароль в системе, не было смысла. Чтобы взломать эту новую систему, злоумышленники решили создать таблицы поиска, известные как радужные таблицы. Вместо того чтобы каждый раз угадывать каждый пароль, они вычисляли пароль единожды и хранили его в таблице поиска.

Чтобы снизить эффективность радужных таблиц, разработчикам было рекомендовано использовать "солёные" пароли. Вместо исключительного использования пароля в качестве входных данных для функции хэширования к каждому паролю пользователя генерировались случайные байты (известные как соль). Соль и пароль пользователя прогонялись через функцию хэширования, в результате чего получался уникальный хэш. Соль хранилась вместе с паролем пользователя в виде открытого текста. Затем, если пользователь пытался пройти аутентификацию, хэшированный пароль сравнивался с хэшем хранимой соли и паролем, который этот пользователь ввёл. Уникальная соль означала, что радужные таблицы теряли свою эффективность, поскольку хэш был разным для каждой комбинации соли и пароля.

В наше время мы понимаем, что криптографические хэши (например, алгоритм SHA-256) больше не являются безопасными. Причина в том, что с помощью современного оборудования можно выполнять миллиарды хэш-вычислений в секунду. Это означает, что можно с легкостью взломать каждый пароль по отдельности.

Разработчикам теперь рекомендуется использовать адаптивные вычислительно необратимые функции для хранения пароля. Валидация паролей с помощью адаптивных вычислительно необратимых функций заведомо требует больших затрат ресурсов (т.е. ресурсов процессора, памяти и т.д.). Адаптивная вычислительно необратимая функция позволяет сконфигурировать "коэффициент трудоёмкости и затрат", который может расти по мере совершенствования оборудования. Рекомендуется подстроить "коэффициент трудоёмкости и затрат" таким образом, чтобы проверка пароля в вашей системе занимала около 1 секунды. Такой компромисс заключается в том, чтобы злоумышленникам было сложнее взломать пароль, но при этом не создавать чрезмерную нагрузку на вашу собственную систему ввиду огромных затрат ресурсов. В Spring Security предпринята попытка обеспечить адекватную отправную точку "коэффициента трудоёмкости и затрат", но пользователям рекомендуется настраивать "коэффициент трудоёмкости и затрат" под их собственные системы, поскольку производительность будет сильно варьироваться от системы к системе. Примерами адаптивных вычислительно необратимых функций, которые следует использовать, являются bcrypt, PBKDF2, scrypt и argon2.

Поскольку адаптивные вычислительно необратимые функции сами по себе предполагают наличия больших вычислительных ресурсов, валидация имени пользователя и пароля для каждого запроса значительно снижает производительность приложения. Spring Security (или любая другая библиотека) никак не может повлиять на ускорение валидации пароля, так как надлежащий уровень безопасности достигается как раз за счет того, что валидация является ресурсоемкой задачей. Пользователям рекомендуется обменивать долгосрочные учетные данные (т.е. имя пользователя и пароль) на краткосрочные (т.е. сессию, токен OAuth и т.д.). Краткосрочные учетные данные можно быстро валидировать без ущерба для безопасности.

DelegatingPasswordEncoder

До версии Spring Security 5.0 по умолчанию использовался NoOpPasswordEncoder, который требовал пароли в виде обычного текста. Исходя из раздела "История паролей", можно ожидать, что по умолчанию будет использоваться что-то вроде BCryptPasswordEncoder. Однако при этом игнорируются три реальные проблемы:

  • Существует множество приложений, использующих старые кодировки паролей, которые трудно перенести

  • Наиболее оптимальный метод хранения паролей снова изменится

  • Будучи фреймворком Spring Security не может часто вносить критические изменения

Вместо этого Spring Security представляет DelegatingPasswordEncoder, который решает все эти проблемы:

  • Обеспечивая кодирования паролей с использованием текущих рекомендаций по хранению паролей

  • Позволяя валидировать пароли в современных и устаревших форматах

  • Позволяя производить обновления кодировки в будущем

Создать экземпляр DelegatingPasswordEncoder лешко можно, используя PasswordEncoderFactories.

Создание DelegatingPasswordEncoder по умолчанию
Java
PasswordEncoder passwordEncoder =
    PasswordEncoderFactories.createDelegatingPasswordEncoder();
Kotlin
val passwordEncoder: PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()

Кроме того, вы можете создать свой кастомный экземпляр. Например:

Создание кастомного DelegatingPasswordEncoder
Java
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());
PasswordEncoder passwordEncoder =
    new DelegatingPasswordEncoder(idForEncode, encoders);
Kotlin
val idForEncode = "bcrypt"
val encoders: MutableMap<String, PasswordEncoder> = mutableMapOf()
encoders[idForEncode] = BCryptPasswordEncoder()
encoders["noop"] = NoOpPasswordEncoder.getInstance()
encoders["pbkdf2"] = Pbkdf2PasswordEncoder()
encoders["scrypt"] = SCryptPasswordEncoder()
encoders["sha256"] = StandardPasswordEncoder()
val passwordEncoder: PasswordEncoder = DelegatingPasswordEncoder(idForEncode, encoders)

Формат хранения паролей

Общий формат пароля следующий:

Формат хранения DelegatingPasswordEncoder
{id}encodedPassword

Например, id – это идентификатор, используемый для поиска подходящего для использования PasswordEncoder, а encodedPassword – это исходный закодированный пароль для выбранного PasswordEncoder. id должен находиться в начале пароля, начинаться с { и заканчиваться }. Если id не удается найти, id будет иметь значение null. Например, ниже приведен возможный список паролей, закодированных с использованием различных id. Все изначальные пароли – это "password".

Пример закодированных с помощью DelegatingPasswordEncoder паролей
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG 
{noop}password 
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc 
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=  
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 
  1. Идентификатором PasswordEncoder для первого пароля будет иметь значение bcrypt, а сам закодированный пароль будет представлен в виде $2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG. При совпадении он будет передан BCryptPasswordEncoder
  2. Идентификатором PasswordEncoder для второго пароля будет иметь значение noop, а сам закодированный пароль будет представлен в виде password. При совпадении он будет передан NoOpPasswordEncoder
  3. Идентификатором PasswordEncoder для третьего пароля будет иметь значение pbkdf2, а сам закодированный пароль будет представлен в виде 5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc. При совпадении он будет передан Pbkdf2PasswordEncoder
  4. Идентификатор PasswordEncoder для четвертого пароля будет иметь значение scrypt, а сам закодированный пароль будет представлен в виде $e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=. При совпадении он будет передан SCryptPasswordEncoder
  5. Идентификатор PasswordEncoder для итогового пароля будет иметь значение sha256, а сам закодированный пароль будет представлен в виде 97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0. При совпадении он будет передан StandardPasswordEncoder

Некоторые пользователи могут обеспокоиться тем, что формат хранения открыт для потенциального хакера. Об этом не стоит волноваться, поскольку хранение пароля не зависит от того, является ли алгоритм закрытым. Кроме того, большинство форматов злоумышленник сможет легко вычислить и без префикса. Например, BCrypt-пароли зачастую начинаются с $2a$.

Кодирование пароля

Переданный в конструктор idForEncode определяет, какой PasswordEncoder будет использоваться для кодирования паролей. В случае с DelegatingPasswordEncoder, который мы создали выше, это означает, что результат кодирования password будет передан BCryptPasswordEncoder и снабжен префиксом {bcrypt}. Итоговый результат будет выглядеть следующим образом:

Пример кодирования с помощью DelegatingPasswordEncoder
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

Подбор пароля

Сопоставление осуществляется на основе {id} и отображения id на PasswordEncoder, указанного в конструкторе. По умолчанию результат вызова matches(CharSequence, String) с паролем и id, который не был отображен (включая идентификатор со значением null), приведет к IllegalArgumentException. Эту логику работы можно настроить с помощью DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder).

Используя id, мы можем сопоставлять любую кодировку пароля, но кодировать пароли, используя самую современную кодировку паролей. Это важно, поскольку в отличие от шифрования, хэши паролей организованы таким образом, что восстановить простой текст будет крайне трудно. Поскольку возможности восстановить простой текст нет, это затруднит перенос паролей. Хотя пользователи могут с легкостью переносить NoOpPasswordEncoder, мы решили добавить его по умолчанию, чтобы было легче приступить к работе.

Приступаем к работе

Если вы готовите демо-версию или образец, будет немного проблематично затрачивать время на хэширование паролей ваших пользователей. Существуют вспомогательные механизмы, облегчающие эту задачу, но они по-прежнему не предназначены для производственного развёртывания.

Пример withDefaultPasswordEncoder
Java
User user = User.withDefaultPasswordEncoder()
  .username("user")
  .password("password")
  .roles("user")
  .build();
System.out.println(user.getPassword());
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
Kotlin
val user = User.withDefaultPasswordEncoder()
    .username("user")
    .password("password")
    .roles("user")
    .build()
println(user.password)
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

Если вы создаете несколько пользователей, вы также можете повторно использовать конструктор.

withDefaultPasswordEncoder, который повторно использует конструктор
Java
UserBuilder users = User.withDefaultPasswordEncoder();
User user = users
  .username("user")
  .password("password")
  .roles("USER")
  .build();
User admin = users
  .username("admin")
  .password("password")
  .roles("USER","ADMIN")
  .build();
Kotlin
val users = User.withDefaultPasswordEncoder()
val user = users
    .username("user")
    .password("password")
    .roles("USER")
    .build()
val admin = users
    .username("admin")
    .password("password")
    .roles("USER", "ADMIN")
    .build()

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

Кодирование с помощью Spring Boot CLI

Самый простой способ правильно закодировать пароль – использовать Spring Boot CLI.

Например, следующая команда кодирует пароль password для использования с DelegatingPasswordEncoder:

Пример использования команды encodepassword через Spring Boot CLI
spring encodepassword password
{bcrypt}$2a$10$X5wFBtLrL/kHcmrOGGTrGufsBX8CJ0WpQpF3pgeuxBB/H73BK1DW6

Устранение неполадок

Следующая ошибка возникает, если один из хранимых паролей не имеет идентификатора.

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
	at org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:233)
	at org.springframework.security.crypto.password.DelegatingPasswordEncoder.matches(DelegatingPasswordEncoder.java:196)

Самый простой способ устранить ошибку – перейти на явное указание PasswordEncoder, которым кодируются ваши пароли. Проще всего решить эту проблему можно, если выяснить, каким образом в настоящее время хранятся ваши пароли, и явно указать правильный PasswordEncoder.

Если вы переходите с Spring Security 4.2.x, то сможете вернуться к предыдущей логике работы, открыв бин NoOpPasswordEncoder.

Кроме того, можно снабдить все пароли правильным идентификатором и продолжать использовать DelegatingPasswordEncoder. Например, если вы используете BCrypt, то можете перенести свой пароль из чего-то вроде:

$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

в

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

Полный список отображений приведен в Javadoc по PasswordEncoderFactories.

BCryptPasswordEncoder

Реализация BCryptPasswordEncoder использует широко поддерживаемый алгоритм bcrypt для хэширования паролей. Для того чтобы сделать его более устойчивым к взлому, bcrypt намеренно замедлен. Как и другие адаптивные вычислительно необратимые функции, его следует тонко настроить таким образом чтобы проверка пароля в вашей системе занимала около 1 секунды. Реализация BCryptPasswordEncoder по умолчанию использует параметр надёжности со значением 10, как указано в Javadoc к BCryptPasswordEncoder. Рекомендуется настроить и протестировать параметр надежности в своей системе таким образом, чтобы проверка пароля занимала примерно 1 секунду.

BCryptPasswordEncoder
Java
// Создаем кодировщик с параметром надежности 16
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16);
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
Kotlin
// Создаем кодировщик с параметром надежности 16
val encoder = BCryptPasswordEncoder(16)
val result: String = encoder.encode("myPassword")
assertTrue(encoder.matches("myPassword", result))

Argon2PasswordEncoder

Реализация Argon2PasswordEncoder использует алгоритм Argon2 для хэширования паролей. Argon2 – победитель конкурса по хэшированию паролей. Для того чтобы противостоять взлому паролей на пользовательском оборудовании, Argon2 был намеренно замедлен и требует большого объема памяти. Как и другие адаптивные вычислительно необратимые функции, его следует тонко настроить таким образом чтобы проверка пароля в вашей системе занимала около 1 секунды. Текущая реализация Argon2PasswordEncoder требует BouncyCastle.

Argon2PasswordEncoder
Java
// Создаем кодировщик со всеми настройками по умолчанию
Argon2PasswordEncoder encoder = new Argon2PasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
Kotlin
// Создаем кодировщик со всеми настройками по умолчанию
val encoder = Argon2PasswordEncoder()
val result: String = encoder.encode("myPassword")
assertTrue(encoder.matches("myPassword", result))

Pbkdf2PasswordEncoder

Реализация Pbkdf2PasswordEncoder использует алгоритм PBKDF2 для хэширования паролей. Для того чтобы противостоять взлому паролей, PBKDF2 был намеренно замедлен. Как и другие адаптивные вычислительно необратимые функции, его следует тонко настроить таким образом чтобы проверка пароля в вашей системе занимала около 1 секунды. Этот алгоритм является отличным выбором, если требуется сертификация FIPS.

Pbkdf2PasswordEncoder
Java
// Создаем кодировщик со всеми настройками по умолчанию
Pbkdf2PasswordEncoder encoder = new Pbkdf2PasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
Kotlin
// Создаем кодировщик со всеми настройками по умолчанию
val encoder = Pbkdf2PasswordEncoder()
val result: String = encoder.encode("myPassword")
assertTrue(encoder.matches("myPassword", result))

SCryptPasswordEncoder

Реализация SCryptPasswordEncoder использует алгоритм scrypt для хэширования паролей. Для того чтобы противостоять взлому паролей на пользовательском оборудовании, этот алгоритм был намеренно замедлен и требует большого объема памяти. Как и другие адаптивные вычислительно необратимые функции, его следует тонко настроить таким образом чтобы проверка пароля в вашей системе занимала около 1 секунды.

SCryptPasswordEncoder
Java
// Создаем кодировщик со всеми настройками по умолчанию
SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
Kotlin
// Создаем кодировщик со всеми настройками по умолчанию
val encoder = SCryptPasswordEncoder()
val result: String = encoder.encode("myPassword")
assertTrue(encoder.matches("myPassword", result))

Другие PasswordEncoders

Существует значительное количество других реализаций PasswordEncoder, которые предназначены исключительно для обратной совместимости. Все они устарели, что больше не позволяет считать их безопасными. Однако планов по их удалению нет, поскольку перенос существующих унаследованных систем затруднен.

Конфигурация для хранения паролей

Spring Security по умолчанию использует DelegatingPasswordEncoder. Однако это можно настроить, открыв PasswordEncoder в качестве бина Spring.

Если вы переходите с Spring Security 4.2.x, то сможете вернуться к предыдущей логике работы, открыв бин NoOpPasswordEncoder.

Возврат к NoOpPasswordEncoder не считается безопасным. Вместо этого следует перейти на использование DelegatingPasswordEncoder для обеспечения безопасного кодирования паролей.

NoOpPasswordEncoder
Java
@Bean
public static PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}
XML
<b:bean id="passwordEncoder"
        class="org.springframework.security.crypto.password.NoOpPasswordEncoder" factory-method="getInstance"/>
Kotlin
@Bean
fun passwordEncoder(): PasswordEncoder {
    return NoOpPasswordEncoder.getInstance();
}

XML-конфигурация требует, чтобы именем бина NoOpPasswordEncoder было passwordEncoder.

Изменение конфигурации пароля

Большинство приложений, позволяющих пользователю задавать пароль, также требуют наличия функции обновления этого пароля.

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

Вы можете настроить Spring Security на передачу этой конечной точки обнаружения. Например, если конечная точка изменения пароля в вашем приложении – /change-password, то можно сконфигурировать Spring Security следующим образом:

Конечная точка смены пароля по умолчанию
Java
http
    .passwordManagement(Customizer.withDefaults())
XML
<sec:password-management/>
Kotlin
http {
    passwordManagement { }
}

Затем, когда менеджер паролей перейдет по адресу /.well-known/change-password, Spring Security переадресует вашу конечную точку, /change-password.

Или, если ваша конечная точка не /change-password, то можно будет задать ее следующим образом:

Change Password Endpoint
Java
http
    .passwordManagement((management) -> management
        .changePasswordPage("/update-password")
    )
XML
<sec:password-management change-password-page="/update-password"/>
Kotlin
http {
    passwordManagement {
        changePasswordPage = "/update-password"
    }
}

При вышеуказанной конфигурации, когда менеджер паролей переходит по адресу /.well-known/change-password, Spring Security будет осуществлять переадресацию на /update-password.