Інтерфейс 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+xQYvYv 7BeL1QxwRpY5Pc= 
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cffafaf8410849f27605abcbc0G

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

Зіставлення здійснюється на основі {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.

Повний список відображень наведено в Javadoc за PasswordEncoderFactories.

BCryptPasswordEncoder

Реалізація BCryptPasswordEncoder використовує алгоритм bcrypt, який широко підтримується, для хешування паролів. Щоб зробити його більш стійким до злому, bcrypt навмисно сповільнений. Як і інші адаптивні незворотні функції, його слід тонко налаштувати таким чином щоб перевірка пароля у твоїй системі займала близько 1 секунди. Реалізація BCryptPasswordEncoder за замовчуванням використовує параметр надійності зі значенням 10, як зазначено в Javadoc про BCryptPasswordEncoder. Рекомендується налаштувати та протестувати параметр надійності у своїй системі таким чином, щоб перевірка пароля займала приблизно 1 секунду.

BCryptPasswordEncoder
div class="spring-code-block spring-code-block--primary">
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(Customizer.withDefaults())
XML
<sec:password-management change-password-page="/update-password"/>
Kotlin
http {
    passwordManagement {
        changePasswordPage = "/update-password"
    }
}

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