Пошук уроків, статей та іншого контенту
Захистите паролі за допомогою salt і сучасних алгоритмів хешування та реалізуєте перевірку під час входу.
Пароль користувача не повинен зберігатися в базі даних у відкритому вигляді. Якщо база даних стане доступною зловмиснику, він отримає всі паролі та зможе використовувати їх на інших сервісах.
Для захисту паролів використовують одностороннє хешування:
пароль перетворюється на рядок фіксованої структури;
отримати початковий пароль із хешу практично неможливо;
під час входу новий пароль хешується повторно;
отримані значення порівнюються.
Хешування відрізняється від шифрування:
шифрування можна обернути за допомогою ключа;
хешування призначене для одностороннього перетворення.
Не використовуйте для паролів звичайні швидкі хеші на кшталт SHA-256 або MD5. Вони створені для інших задач і надто швидкі для перебору паролів.
Salt — це випадкове значення, яке додається до кожного пароля перед хешуванням.
hash(password + salt)Для кожного користувача потрібно генерувати новий salt. Це захищає від:
однакових хешів для однакових паролів;
попередньо обчислених таблиць хешів;
швидкого визначення, які користувачі мають однаковий пароль.
Salt не потрібно приховувати. Його можна зберігати разом із хешем.
Наприклад, у базі даних можна зберігати один рядок:
scrypt$16384$8$1$base64-salt$base64-hashАбо окремі поля:
password_hash
password_saltЗручніше зберігати всі параметри та значення в одному форматованому рядку, щоб у майбутньому можна було змінити параметри хешування.
У цьому прикладі використаємо scrypt — алгоритм хешування паролів, вбудований у Node.js.
scrypt є memory-hard алгоритмом: він потребує не лише часу процесора, а й значного обсягу пам’яті. Це ускладнює масовий перебір паролів на спеціалізованому обладнанні.
Next.js виконує серверний код у середовищі Node.js, тому scrypt можна використовувати без додаткової бібліотеки.
Хешування паролів потрібно виконувати лише на сервері. Не імпортуйте код із
node:cryptoу клієнтські компоненти.
Створимо файл lib/passwords.js:
import {
randomBytes,
scrypt as scryptCallback,
timingSafeEqual,
} from "node:crypto";
import { promisify } from "node:util";
const scrypt = promisify(scryptCallback);
const KEY_LENGTH = 64;
const SCRYPT_OPTIONS = {
N: 16384,
r: 8,
p: 1,
maxmem: 32 * 1024 * 1024,
};
export async function hashPassword(password) {
if (typeof password !== "string" || password.length === 0) {
throw new Error("Пароль не може бути порожнім");
}
const salt = randomBytes(16);
const derivedKey = await scrypt(
Buffer.from(password, "utf8"),
salt,
KEY_LENGTH,
SCRYPT_OPTIONS,
);
return [
"scrypt",
SCRYPT_OPTIONS.N,
SCRYPT_OPTIONS.r,
SCRYPT_OPTIONS.p,
salt.toString("base64"),
derivedKey.toString("base64"),
].join("$");
}
export async function verifyPassword(password, storedHash) {
if (typeof password !== "string" || typeof storedHash !== "string") {
return false;
}
const parts = storedHash.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") {
return false;
}
const [, nValue, rValue, pValue, saltValue, hashValue] = parts;
const N = Number(nValue);
const r = Number(rValue);
const p = Number(pValue);
if (
!Number.isInteger(N) ||
!Number.isInteger(r) ||
!Number.isInteger(p) ||
N <= 1 ||
r <= 0 ||
p <= 0
) {
return false;
}
try {
const salt = Buffer.from(saltValue, "base64");
const expectedHash = Buffer.from(hashValue, "base64");
const actualHash = await scrypt(
Buffer.from(password, "utf8"),
salt,
expectedHash.length,
{
N,
r,
p,
maxmem: 32 * 1024 * 1024,
},
);
if (actualHash.length !== expectedHash.length) {
return false;
}
return timingSafeEqual(actualHash, expectedHash);
} catch {
return false;
}
}Під час створення хешу:
генерується випадковий salt;
пароль і salt передаються в scrypt;
результат перетворюється на Base64;
алгоритм, його параметри, salt і хеш об’єднуються в один рядок.
Під час перевірки:
рядок розбирається на частини;
із нього дістаються salt і параметри;
введений пароль хешується з тим самим salt;
отримані значення порівнюються через timingSafeEqual.
timingSafeEqual допомагає уникнути витоку інформації через різницю в часі порівняння байтів.
Під час реєстрації не зберігайте пароль. Збережіть лише результат hashPassword.
import { NextResponse } from "next/server";
import { hashPassword } from "@/lib/passwords";
export async function POST(request) {
const body = await request.json();
const { email, password } = body;
if (typeof email !== "string" || typeof password !== "string") {
return NextResponse.json(
{ error: "Email і пароль є обов'язковими" },
{ status: 400 },
);
}
if (password.length < 8) {
return NextResponse.json(
{ error: "Пароль має містити щонайменше 8 символів" },
{ status: 400 },
);
}
const passwordHash = await hashPassword(password);
// Замість цього виклику використовуйте вашу базу даних.
await users.create({
email,
passwordHash,
});
return NextResponse.json(
{ message: "Користувача створено" },
{ status: 201 },
);
}Цей обробник можна розмістити у файлі:
app/api/register/route.jsОб’єкт users у прикладі позначає ваш шар роботи з базою даних. У реальному застосунку запит має записати значення passwordHash у поле користувача.
Важливо: у базі даних не повинно бути поля на кшталт password, яке містить початковий пароль.
Під час входу потрібно:
знайти користувача за email;
отримати з бази збережений хеш;
викликати verifyPassword;
створити сесію лише після успішної перевірки.
import { NextResponse } from "next/server";
import { verifyPassword } from "@/lib/passwords";
export async function POST(request) {
const body = await request.json();
const { email, password } = body;
if (typeof email !== "string" || typeof password !== "string") {
return NextResponse.json(
{ error: "Неправильні облікові дані" },
{ status: 401 },
);
}
// Замість цього виклику використовуйте вашу базу даних.
const user = await users.findByEmail(email);
if (!user) {
return NextResponse.json(
{ error: "Неправильні облікові дані" },
{ status: 401 },
);
}
const isPasswordValid = await verifyPassword(
password,
user.passwordHash,
);
if (!isPasswordValid) {
return NextResponse.json(
{ error: "Неправильні облікові дані" },
{ status: 401 },
);
}
// Тут створюється сесія або встановлюється захищена cookie.
return NextResponse.json({
message: "Вхід виконано",
});
}Файл обробника:
app/api/login/route.jsДля неіснуючого користувача та неправильного пароля краще повертати однакове повідомлення. Інакше зловмисник зможе визначати, які email уже зареєстровані.
Через випадковий salt два однакові паролі зазвичай матимуть різні результати:
import { hashPassword, verifyPassword } from "./lib/passwords.js";
const firstHash = await hashPassword("Correct Horse Battery Staple");
const secondHash = await hashPassword("Correct Horse Battery Staple");
console.log(firstHash === secondHash);
// false
console.log(await verifyPassword("Correct Horse Battery Staple", firstHash));
// true
console.log(await verifyPassword("wrong password", firstHash));
// falseЦе очікувана поведінка. Для перевірки не потрібно порівнювати хеші напряму. Використовуйте функцію перевірки, яка дістає salt зі збереженого значення та повторює хешування.
Хешування не замінює базові правила перевірки введення:
перевіряйте мінімальну довжину пароля;
не обрізайте пароль без явної причини;
не записуйте пароль у логи;
не передавайте пароль у URL;
обмежуйте кількість спроб входу на рівні застосунку або інфраструктури;
не повертайте пароль у відповідях API.
Обмеження довжини також важливе. Надзвичайно великі значення можуть створювати зайве навантаження на сервер, оскільки хешування пароля є навмисно ресурсомісткою операцією.
Параметри алгоритму можуть змінюватися з часом. Наприклад, потужність серверів зростає, тому старі параметри можуть стати недостатньо дорогими для перебору.
Оскільки параметри зберігаються разом із хешем, можна:
перевірити пароль зі старими параметрами;
після успішного входу перевірити, чи потрібно оновлення;
повторно хешувати пароль із новими параметрами;
зберегти новий хеш.
Не змінюйте salt самостійно під час перевірки. Новий salt генерується лише під час створення нового хешу.
// Неправильно
await users.create({
email,
password,
});Зберігайте тільки хеш:
const passwordHash = await hashPassword(password);
await users.create({
email,
passwordHash,
});Один глобальний salt не забезпечує належного захисту. Salt має генеруватися окремо для кожного хешу.
Не хешуйте паролі через:
// Неправильно для паролів
createHash("sha256").update(password).digest("hex");SHA-256 є швидким загальним хешем, а не алгоритмом для зберігання паролів. Використовуйте scrypt, Argon2id, bcrypt або інший спеціалізований алгоритм.
Клієнтський код не повинен вирішувати, чи правильний пароль. Перевірка та доступ до збереженого хешу мають залишатися на сервері.
Не використовуйте окремі повідомлення для випадків «користувача не знайдено» та «неправильний пароль». Краще повернути загальне повідомлення:
Неправильні облікові даніНе записуйте в логи:
початковий пароль;
значення cookie сесії;
токени;
тіло запиту без необхідної фільтрації.
Паролі потрібно хешувати, а не шифрувати або зберігати як звичайний текст.
Для кожного пароля генеруйте окремий випадковий salt.
Використовуйте спеціалізований алгоритм: scrypt, Argon2id або bcrypt.
Salt і параметри алгоритму можна зберігати разом із хешем.
Під час входу повторно хешуйте введений пароль і порівнюйте результат через безпечну функцію.
Хешування та перевірка мають виконуватися лише на сервері Next.js.
Не розкривайте, чи існує користувач, і не записуйте паролі в логи.