JavaRush /Курси /Spring Security /PasswordEncoder і <...

PasswordEncoder і DelegatingPasswordEncoder

Spring Security
Рівень 6 , Лекція 2
Відкрита

1. Raw- і stored-пароль у застосунку

Ми вже побачили, що пароль не можна зберігати raw і що для нього потрібна незворотна схема, а не зворотне шифрування. Тепер важливо зібрати це в нормальний Spring-код: де живе raw-пароль, що саме зберігається і хто взагалі виконує перевірку.

Поки ви пишете навчальні контролери та сервіси, дуже легко ставитися до пароля як до ще одного поля в DTO: «ну, рядок і рядок». Проблема в тому, що пароль — це секрет, і в нього інший життєвий цикл. Якщо не розділити у голові «що користувач увів» і «що ми зберігаємо», ви майже гарантовано напишете код, який або небезпечний, або просто не працює так, як ви очікуєте, а іноді — і те, і те одразу.

Домовмося про два терміни, які ми використовуватимемо весь курс (і дуже бажано — у вашому коді також).

raw password — це те, що користувач вводить (наприклад, у JSON тіла запиту, у формі, через basic auth). Це значення повинно жити дуже коротко: прийшло, перевірилося, зникло. Воно не повинно потрапляти до бази, у логи, у винятки, у «тимчасові змінні про всяк випадок».

stored password (або password hash, password encoded value) — це рядок, який застосунок зберігає як «представлення пароля». Цей рядок не дорівнює вихідному паролю, і він не зобов’язаний бути однаковим навіть для одного й того самого пароля. Його сенс — бути придатним для перевірки через matches(...).

Невелика таблиця, щоб візуально закріпити:

Поняття Що це Де трапляється Як довго живе Приклад
rawPassword початкове введення користувача вхідний запит секунди/мілісекунди "qwerty123"
storedPassword результат безпечної підготовки до зберігання user store (пам’ять/БД) тижні/роки "{bcrypt}$2a$10$..."

До речі, тут прихована перша «педагогічна міна»: якщо ви назвете все просто password, то через тиждень самі забудете, де у вас сирий пароль, а де збережений. Тому в коді краще називати прямо: rawPassword, storedPassword, passwordHash. Це не занудство — це захист від вашої ж неуважності в пʼятницю ввечері.

2. PasswordEncoder: encode і matches

Spring Security влаштований так, що він не змушує вас вручну писати криптографію та порівняння рядків. Замість цього він пропонує дуже простий контракт: PasswordEncoder. Важлива ідея в тому, що цей контракт — частина загальної архітектури автентифікації: DaoAuthenticationProvider перевіряє пароль саме через PasswordEncoder. Тобто, якщо ви вибрали й налаштували encoder правильно, ви не пишете «перевірку пароля» в контролерах і не вигадуєте свій міні-Spring-Security на коліні.

На практичному рівні інтерфейс PasswordEncoder можна запам’ятати як «дві кнопки»:

encode(rawPassword) — підготувати пароль до зберігання;
matches(rawPassword, storedPassword) — перевірити, чи збігається введення з тим, що ми зберігаємо.

Саме в такому порядку ми й думатимемо про життєвий цикл:

flowchart TD
    A["сирий пароль
(введення користувача)"] B["PasswordEncoder.encode(...)"] C["збережений пароль
({id}... рядок)"] D["PasswordEncoder.matches(...)"] E["true/false"] A --> B --> C A --> D C --> D --> E

Отже, encode(...) — це не «перевірка», а «створення значення для зберігання». А matches(...) — це не «порівняння рядків», а «перевірка за правилами алгоритму», включно із сіллю, cost factor і всіма внутрішніми деталями.

І так, слово encode тут може збивати новачка з пантелику. Воно не означає «зробити знову читабельним». Це радше «закодувати в безпечний формат зберігання». Наче ви не «переклали текст на іншу мову», а «упакували його в сейф із пломбою».

3. encode(...): як влаштовано результат

У новачків є дуже людське бажання: «Гаразд, пароль ми зберігаємо у вигляді рядка. Отже, щоб перевірити, треба взяти введений пароль, теж пропустити через encode(...), а потім порівняти два рядки. Логічно ж?» Логічно — як ідея. Але технічно це неправильно для більшості нормальних алгоритмів зберігання паролів. Саме тому Spring Security дає вам matches(...), щоб ви не намагалися «перевирахувати і порівняти» там, де це не працює.

У безпечних password-алгоритмах зазвичай є сіль та інші параметри, тому один і той самий пароль при кожному encode(...) може давати різні рядки. Це не баг. Це фіча. І якщо ви намагатиметеся порівнювати результати encode(...) через equals(...), ви самі себе загоните в кут.

Маленький приклад на Java — для навчання в консоль; у реальному застосунку ми так паролі, звісно, не друкуємо:

import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

// Беремо «базовий» DelegatingPasswordEncoder зі стандартними алгоритмами та префіксом {id}
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

// Один і той самий raw-пароль двічі кодуємо: через сіль результат буде різним
String first = encoder.encode("qwerty123");
String second = encoder.encode("qwerty123");

// Порівнювати рядки тут як спосіб перевірки пароля не можна — і це видно з виведення
System.out.println(first.equals(second)); // false (і це нормально)

У цей момент корисно зробити паузу й сказати собі: «Гаразд, зберігання пароля — це не “функція, яка завжди повертає один і той самий результат”». Це як відбиток пальця, знятий різними сканерами: ніби про одне й те саме, але представлення може відрізнятися, зате перевірка справжності працює.

Звідси практичний висновок: encode(...) ви викликаєте, коли створюєте або змінюєте пароль (наприклад, під час реєстрації користувача або зміни пароля). Але ви не використовуєте encode(...) як частину перевірки під час логіну.

4. matches(...): перевірка пароля

Коли студент уперше бачить matches(raw, stored), у нього часто виникає відчуття: «Та це просто зручна обгортка над equals(...)». Насправді це важлива інженерна точка: тут захована логіка, яка враховує і сіль, і параметри алгоритму, і правильне порівняння, зокрема безпечне за часом. Якщо encode(...) — це підготовка, то matches(...) — це перевірка справжності за правилами «банківського сейфа», а не за правилами «два тексти рівні».

Найпростіший сценарій виглядає так: ви зберігаєте stored (те, що колись отримали через encode(...)), і під час логіну порівнюєте введення користувача з цим stored через matches(...).

import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

// Використовуємо той самий DelegatingPasswordEncoder, який використовуватиметься в застосунку
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

// stored — це саме те, що ви покладете в БД/сховище (разом із префіксом {id})
String stored = encoder.encode("qwerty123");

// matches(...) — єдино коректний спосіб перевірити raw проти stored
System.out.println(encoder.matches("qwerty123", stored)); // true
System.out.println(encoder.matches("wrong", stored));     // false

Тут важливі дві дрібниці, які потім економлять години налагодження.

Перша — порядок аргументів. matches(raw, stored) читається як «введений пароль збігається зі збереженим представленням?». Якщо ви переплутаєте їх місцями, у вас не «іноді працюватиме», а буде дивна, погано пояснювана нісенітниця.

Друга — stored має бути саме тим, що ви зберігаєте. Не «шматочком хеша», не «хешем без префікса», не «ми десь обрізали рядок до 60 символів, бо так красивіше». Зберігаємо те, що дав encoder, і перевіряємо тим, що дав encoder. Без творчості.

5. Один PasswordEncoder як Spring Bean

Після знайомства з API з’являється наступна спокуса: «Гаразд, зрозумів. Тоді в потрібних місцях писатиму new BCryptPasswordEncoder() і все». І ось тут починається тиха катастрофа архітектури. Не тому, що BCryptPasswordEncoder поганий, а тому, що «створювати encoder на місці» — це шлях до рознобою, випадкових налаштувань, різних форматів зберігання і неможливості потім безпечно мігрувати формат паролів.

У нашому проєкті Secure Content Platform API ми вже ухвалили важливе рішення: security-конфігурація має бути явною та читабельною (це ми зробили в попередні дні через явний SecurityFilterChain). Із PasswordEncoder логіка така сама: один encoder baseline для застосунку, і він живе як Spring Bean. Тоді і Spring Security, і ваш код використовують одну й ту саму точку істини.

Мінімальний (і цілком нормальний) варіант у конфігурації:

import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

// Усередині вашого @Configuration-класу (наприклад, SecurityConfig)
@Bean
PasswordEncoder passwordEncoder() {
    // Єдина точка істини для кодування й перевірки паролів у всьому застосунку
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

Зверніть увагу на важливий методичний момент: ми не прив’язуємо застосунок «намертво» до одного класу encoderʼа в бізнес-коді. Ми говоримо: «ось контракт PasswordEncoder, ось його реалізація на рівні конфігурації». Це як розетка в стіні: вас не турбує, який провід де прокладено, доки розетка працює і відповідає стандарту.

І так, надалі Spring Security сам підхопить цей бін для своїх провайдерів автентифікації. Тобто ви не просто «створили зручний об’єкт», ви вбудували коректний парольний механізм у загальний потік автентифікації.

6. DelegatingPasswordEncoder і зміна алгоритмів

Тепер — най«доросліша» частина цієї лекції, але без важкого enterprise-пафосу. Реальний світ не живе з одним алгоритмом паролів вічно. Сьогодні ви стартували з bcrypt, за рік вирішили перейти на argon2, ще за два роки купили продукт із legacy-таблицею, де був pbkdf2. Варіант «ну давайте просто змінимо encoder і перерахуємо все» звучить красиво, але технічно часто неможливий, тому що ви не знаєте початкових паролів користувачів. І це добре: ви й не повинні їх знати.

Отже, вам потрібна система, яка вміє перевіряти різні формати збережених паролів, не перетворюючи код на «if-else по рядках» і не змушуючи вас тримати зоопарк encoderʼів у кожній точці. Для цього Spring Security і придумав DelegatingPasswordEncoder.

Ідея дуже проста: у збереженому рядку міститься маленька «мітка формату». За цією міткою DelegatingPasswordEncoder обирає, яким саме алгоритмом (яким конкретно PasswordEncoder) треба перевіряти пароль.

Уявіть DelegatingPasswordEncoder як диспетчера в аеропорту. Він сам не «літає», але дивиться на номер рейсу (префікс {id}) і спрямовує пасажира до потрібного гейта (потрібного encoder). Якщо замість номера рейсу ви принесете йому аркуш без заголовка, диспетчер чесно скаже: «Я не розумію, що з цим робити».

7. Формат {id} і зберігання рядка цілком

Коли ви використовуєте PasswordEncoderFactories.createDelegatingPasswordEncoder(), результат encode(...) зазвичай виглядає як рядок, що починається з {id}. Наприклад, {bcrypt}. Далі йде «тіло» збереженого значення, яке може містити сіль, cost та інші параметри. Для новачка це виглядає як «якийсь довгий рядок», але для Spring Security це структурований формат, який не можна ламати руками.

Важливий принцип: зберігати потрібно рядок цілком, як єдиний артефакт. Не треба відокремлювати {bcrypt} в окрему колонку. Не треба видаляти префікс «щоб було красивіше». Не треба обрізати рядок. Не треба «нормалізувати» його до нижнього регістру. Парольний хеш — не номер телефону, йому форматування не потрібне.

Давайте подивимося на приклад (знову ж таки: виводимо в консоль тільки в навчальних цілях, щоб побачити формат):

import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

// DelegatingPasswordEncoder сам додасть префікс {id} до encoded-рядка
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

// Те, що повернув encode(...), і є "артефакт для зберігання" (цілком)
String stored = encoder.encode("qwerty123");

// У навчальних цілях дивимося формат: побачимо префікс на кшталт {bcrypt}
System.out.println(stored); // {bcrypt}$2a$10$... (щоразу буде по-різному)

Якщо ви спробуєте відрізати префікс і залишити тільки «довгу частину», DelegatingPasswordEncoder при matches(...) зазвичай не зможе зрозуміти, що це за формат, і викине виняток. Це не шкідливість фреймворку. Це захист від ситуації «ми не знаємо, чим це було закодовано, тож спробуємо вгадати» (а вгадування в security — поганий бізнес-план).

Щоб ще раз закріпити, можна показати делегування на пальцях. Ми вручну створимо bcrypt-хеш, приклеїмо до нього префікс {bcrypt}, а потім попросимо DelegatingPasswordEncoder перевірити пароль:

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

// DelegatingPasswordEncoder обирає реалізацію за префіксом {id}
PasswordEncoder delegating = PasswordEncoderFactories.createDelegatingPasswordEncoder();

// Демонстрація механіки: "приклеїли" {bcrypt} до результату BCrypt
// У реальному застосунку так руками зазвичай не роблять — це лише для розуміння принципу
String stored = "{bcrypt}" + new BCryptPasswordEncoder().encode("qwerty123");

// DelegatingPasswordEncoder побачить {bcrypt} і відправить перевірку в BCryptPasswordEncoder
System.out.println(delegating.matches("qwerty123", stored)); // true

Цей приклад важливий не тим, що «можна руками клеїти префікси» (у нормальному застосунку так не треба), а тим, що він показує механіку: префікс — це підказка, який encoder використовувати.

8. createDelegatingPasswordEncoder() як baseline

Коли ви тільки починаєте, хочеться «правильний алгоритм вибрати одразу і назавжди». Але на фундаментальному рівні корисніше інше: обрати коректний baseline, який дає нормальну безпеку й при цьому не прив’язує вас до одного формату без можливості міграції. Саме тому в Spring Security існує PasswordEncoderFactories.createDelegatingPasswordEncoder() — це зручна фабрика, яка повертає готовий DelegatingPasswordEncoder зі стандартними мапінгами.

Практично це означає: ми фіксуємо один бін PasswordEncoder, і далі весь проєкт (і Spring Security, і наш код) використовує його як єдине джерело істини. Це той самий baseline незалежно від того, звідки застосунок отримує користувачів: із пам’яті, з БД чи з будь-якого іншого user store. Але сьогодні нам важливо інше: побачити, що в нас є один «центральний перевіряльник», якому байдуже, який алгоритм стоїть за конкретним збереженим паролем.

Можна навіть зробити маленьку демонстрацію прямо в коді проєкту (наприклад, тимчасово через CommandLineRunner), щоб побачити, що encoded-пароль завжди починається з {...}:

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.password.PasswordEncoder;

// Усередині @Configuration-класу
@Bean
CommandLineRunner showPasswordFormat(PasswordEncoder encoder) {
    return args -> {
        // Демонстрація формату encoded-рядка: побачимо префікс {id}, наприклад {bcrypt}
        System.out.println(encoder.encode("qwerty123")); // {bcrypt}$2a$10$...
    };
}

Ще раз: це демонстрація. Ми не робимо «генератор паролів через логи» як продуктову фічу. Але для розуміння формату це працює чудово: ви очима побачите префікс {bcrypt}, і далі вам буде простіше зрозуміти, навіщо ми взагалі говоримо про DelegatingPasswordEncoder.

9. PasswordService як єдині двері

Коли механізм уже зрозумілий, з’являється наступне практичне питання: де тримати роботу з паролями у вашому застосунку, щоб завтра випадково не почати логувати сирий пароль або не зробити подвійне кодування? На фундаментальному рівні хороша відповідь проста: зробіть маленький сервіс-обгортку, який стане «єдиними дверима» для операцій із паролем. Це не магія, це дисципліна: чим менше місць у коді знають про паролі, тим менше шансів зробити дурницю.

Мінімальний варіант такого сервісу в проєкті може виглядати так:

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class PasswordService {
    private final PasswordEncoder encoder;

    // Впроваджуємо загальний PasswordEncoder із контексту Spring, а не створюємо вручну
    public PasswordService(PasswordEncoder encoder) {
        this.encoder = encoder;
    }

    // Явна назва методу підкреслює життєвий цикл: "готуємо до зберігання"
    public String encodeForStorage(String rawPassword) {
        return encoder.encode(rawPassword);
    }

    // Єдино коректна перевірка raw проти stored — через matches(...)
    public boolean matches(String rawPassword, String storedPassword) {
        return encoder.matches(rawPassword, storedPassword);
    }
}

Зверніть увагу, як це допомагає читати код. У будь-якій точці проєкту ви бачите encodeForStorage — отже, це момент створення або зміни пароля. Ви бачите matches — отже, це перевірка. І при цьому ви не розмазуєте по коду PasswordEncoderFactories..., не створюєте encoder вручну, не сперечаєтеся самі з собою, який саме алгоритм «треба б».

І ще один важливий момент: цей сервіс — зручне місце, щоб потім централізовано стежити за тим, щоб пароль ніде не витікав у логи. Навіть якщо ви не логуєте зараз, краще мати очевидну точку, куди можна додати захисні перевірки, ніж потім намагатися вичистити «випадкові printlnʼи» по всьому проєкту.

10. Типові помилки під час роботи з PasswordEncoder

Помилка № 1: перевіряти пароль через encode + equals.
Це майже завжди призводить до того, що логін «раптово» не працює. Причина в тому, що encode(...) може щоразу видавати новий результат через сіль. Верифікація має відбуватися лише через matches(raw, stored), інакше ви порівнюєте не сенс, а випадково згенеровані рядки.

Помилка № 2: переплутати місцями аргументи matches(...).
У новачків це трапляється частіше, ніж здається, особливо якщо змінні названі туманно (password1, password2). Тримайте зрозумілі імена rawPassword і storedPassword та пам’ятайте семантику: «raw збігається зі stored». Тоді навіть без IDE ви читатимете виклик правильно.

Помилка № 3: зберігати не весь encoded-рядок, а «тільки хеш».
Якщо ви використовуєте DelegatingPasswordEncoder, префікс {id} — це частина формату. Приберіть його, обріжте рядок, «почистіть» — і ви або отримаєте виняток, або почнете костилити «а давайте завжди вважати, що це bcrypt». Це і є той ручний хаос, від якого Spring Security намагається вас захистити.

Помилка № 4: створювати encoder «на місці» в різних класах.
Сьогодні ви зробили new BCryptPasswordEncoder(), завтра хтось додав new BCryptPasswordEncoder(12), післязавтра ви почали використовувати DelegatingPasswordEncoder — і ось у вас уже три різні світи, які зберігають і перевіряють паролі по-різному. Encoder має бути один (як бін), а застосунок має залежати від інтерфейсу PasswordEncoder.

Помилка № 5: логувати сирий пароль (або включати його до винятків чи відповідей API).
Це звучить банально, але реально трапляється в навчальних проєктах постійно: «ой, я просто debug зробив». Проблема в тому, що логи живуть довго, логи читають інші люди, логи їдуть у централізовані системи. Пароль не повинен потрапляти туди в жодному вигляді. Навіть як тимчасовий debug.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ