Здравствуйте, в этой статье рассказывается о том, как внедрить аутентификацию в ваш SvelteKit проект. Это будет JWT аутентификация с использованием refresh токенов для дополнительной безопасности. Мы будем использовать Supabase в качестве базы данных (PostgreSQL), но основы должны быть теми же.
Как это будет работать?
Когда пользователь регистрируется, мы сохраняем информацию о пользователе и пароль в нашей базе данных. Также мы сгенерируем refresh токен и сохраним его как локально, так и в базе данных. Мы создадим JWT токен с информацией о пользователе и сохраним его в виде cookie
. Срок действия этого JWT токена истекает через 15 минут. Когда срок его действия истечет, мы проверим, существует ли refresh токен, и сравним его с тем, который сохранен в нашей базе данных. Если он совпадает, мы можем создать новый JWT токен. С помощью этой системы вы можете отозвать доступ пользователя к вашему веб-сайту, изменив refresh токен, сохраненный в базе данных (хотя это может занять до 15 минут).
Наконец, почему Supabase, а не Firebase? Лично я считаю, что неограниченное чтение / запись гораздо важнее размера хранилища при работе с бесплатной системой. Но любая база данных должна работать.
I. Структура
Этот проект будет состоять из 3-х страниц
index.svelte
: Страница проектаsignin.svelte
: Страница входаsignup.svelte
: Страница регистрации
Ну и пакеты, которые мы будем использовать
supabase
bcrypt
: Для хеширования паролейcrypto
: Для генерацииuser id
(UUID)jsonwebtoken
: Для создания JWTcookie
: Для парсингаcookie
с сервера
II. Supabase
Создайте новый проект. Теперь создайте новую таблицу users
(всё non-null
).
id
: int8, unique, isIdentityemail
: varchar, uniquepassword
: textusername
: varchar, uniqueuser_id
: uuid, uniquerefresh_token
: text
Перейдите в settings > api. Скопируйте service_role
и URL
. Создайте supabase-admin.ts
:
import { createClient } from '@supabase/supabase-js';
export const admin = createClient(
'URL',
'service_role'
);
Если вы используете Supabase, НЕ используйте этого клиента (admin
). Создайте нового клиента, используя свой anon
ключ.
III. Создание учётной записи (аккаунта)
Создайте новый эндпоинт (/api/create-user.ts
). Он будет для POST
запроса, и в качестве его body
(тела) потребуются email
, password
и username
.
export const post: RequestHandler = async (event) => {
const body = (await event.request.json()) as Body;
if (!body.email || !body.password || !body.username) return returnError(400, 'Invalid request');
if (!validateEmail(body.email) || body.username.length < 4 || body.password.length < 6)
return returnError(400, 'Bad request');
}
Кстати, returnError()
предназначен только для того, чтобы сделать код чище. И validateEmail()
просто проверяет, есть ли в строке @
, поскольку (насколько мне известно) мы не можем на 100% проверить, является ли email действительным, используя регулярное выражение.
export const returnError = (status: number, message: string): RequestHandlerOutput => {
return {
status,
body: {
message
}
};
};
В любом случае, давайте убедимся, что email
или username
еще не используются.
const check_user = await admin
.from('users')
.select()
.or(`email.eq.${body.email},username.eq.${body.username}`)
.maybeSingle()
if (check_user.data) return returnError(405, 'User already exists');
Затем хешируем пароль пользователя (password
) и создаем новый user_id
(UUID) и refresh токен, который будет сохранен в нашей базе данных.
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(body.password, salt);
const user_id = randomUUID();
// import { randomUUID } from 'crypto';
const refresh_token = randomUUID();
const create_user = await admin.from('users').insert([
{
email: body.email,
username: body.username,
password: hash,
user_id,
refresh_token
}
]);
if (create_user.error) return returnError(500, create_user.statusText);
Наконец, сгенерируйте новый JWT токен. Обязательно выберите что-нибудь случайное для ключа. Убедитесь, что вы установили безопасный режим (Secure
) только в том случае, если вы находитесь режиме разработки (localhost - это http, а не https).
const user = {
username: body.username,
user_id,
email: body.email
};
const secure = dev ? '' : ' Secure;';
// import * as jwt from 'jsonwebtoken';
// expires in 15 minutes
const token = jwt.sign(user, key, { expiresIn: `${15 * 60 * 1000}` });
return {
status: 200,
headers: {
// import { dev } from '$app/env';
// const secure = dev ? '' : ' Secure;';
'set-cookie': [
// expires in 90 days
`refresh_token=${refresh_token}; Max-Age=${30 * 24 * 60 * 60}; Path=/; ${secure} HttpOnly`,
`token=${token}; Max-Age=${15 * 60}; Path=/;${secure} HttpOnly`
]
}
};
На нашей странице регистрации мы можем вызвать POST
-запрос и перенаправить нашего пользователя, если он пройдет успешно. Обязательно используйте window.location.href
вместо goto()
, иначе изменение (установка cookie
) не будет применено.
const signUp = async () => {
const response = await fetch('/api/create-user', {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
email,
username,
password
})
});
if (response.ok) {
window.location.href = '/';
}
};
IV. Вход
Мы обработаем вход в /api/signin.ts
. На этот раз мы разрешим пользователю использовать либо свое имя пользователя (username
), либо адрес электронной почты (email
). Чтобы сделать это, мы можем проверить, является ли это действительным именем пользователя или адресом электронной почты, и проверить, существует ли такое же имя пользователя или адрес электронной почты
export const post: RequestHandler = async (event) => {
const body = (await event.request.json()) as Body;
if (!body.email_username || !body.password) return returnError(400, 'Invalid request');
const valid_email = body.email_username.includes('@') && validateEmail(body.email_username);
const valid_username = !body.email_username.includes('@') && body.email_username.length > 3;
if ((!valid_email && !valid_username) || body.password.length < 6)
return returnError(400, 'Bad request');
const getUser = await admin
.from('users')
.select()
.or(`username.eq.${body.email_username},email.eq.${body.email_username}`)
.maybeSingle()
if (!getUser.data) return returnError(405, 'User does not exist');
}
Далее мы сравним введенный и сохраненный пароль.
const user_data = getUser.data as Users_Table;
const authenticated = await bcrypt.compare(body.password, user_data.password);
if (!authenticated) return returnError(401, 'Incorrect password');
И, наконец, сделайте то же самое, что и при создании новой учетной записи.
const refresh_token = user_data.refresh_token;
const user = {
username: user_data.username,
user_id: user_data.user_id,
email: user_data.email
};
const token = jwt.sign(user, key, { expiresIn: `${expiresIn * 60 * 1000}` });
return {
status: 200,
headers: {
'set-cookie': [
`refresh_token=${refresh_token}; Max-Age=${refresh_token_expiresIn * 24 * 60 * 60}; Path=/; ${secure} HttpOnly`,
`token=${token}; Max-Age=${15 * 60}; Path=/;${secure} HttpOnly`
]
}
};
V. Аутентификация пользователей
Хотя мы и можем использовать хуки для чтения JWT токена (как в этой статье, которую написал автор), мы не сможем сгенерировать (и установить) новый JWT токен с их помощью. Итак, мы вызовем эндпоинт, который прочитает cookie
и проверит их, а также вернет данные пользователя, если они существуют. Этот эндпоинт также будет обрабатывать сеансы обновления (refreshing sessions
). Этот эндпоинт будет называться /api/auth.ts
.
Мы можем получить cookie
, и если они действительны - вернуть данные пользователя. Если они недействительны, функция verify()
выдаст сообщение об ошибке.
export const get: RequestHandler = async (event) => {
const { token, refresh_token } = cookie.parse(event.request.headers.get('cookie') || '');
try {
const user = jwt.verify(token, key) as Record<any, any>;
return {
status: 200,
body: user
};
} catch {
// invalid or expired token
}
}
Если срок действия JWT токена истек, мы можем проверить refresh токен с помощью токена в нашей базе данных. Если они равны, то мы можем создать новый JWT токен.
if (!refresh_token) return returnError(401, 'Unauthorized user');
const getUser = await admin.from('users').select().eq("refresh_token", refresh_token).maybeSingle()
if (!getUser.data) {
// remove invalid refresh token
return {
status: 401,
headers: {
'set-cookie': [
`refresh_token=; Max-Age=0; Path=/;${secure} HttpOnly`
]
},
}
}
const user_data = getUser.data as Users_Table;
const new_user = {
username: user_data.username,
user_id: user_data.user_id,
email: user_data.email
};
const token = jwt.sign(new_user, key, { expiresIn: `${15 * 60 * 1000}` });
return {
status: 200,
headers: {
'set-cookie': [
`token=${token}; Max-Age=${15 * 60}; Path=/;${secure} HttpOnly`
]
},
};
VI. Авторизация пользователей
Чтобы авторизовать пользователя, мы можем проверить отправку запроса из /api/auth
в load
функции.
// index.sve;te
// inside <script context="module" lang="ts"/>
export const load: Load = async (input) => {
const response = await input.fetch('/api/auth');
const user = (await response.json()) as Session;
if (!user.user_id) {
// user doesn't exist
return {
status: 302,
redirect: '/signin'
};
}
return {
props: {
user
}
};
};
VII. Выход пользователя из системы
Чтобы выйти из системы, просто удалите JWT токен и refresh токен.
// /api/signout.ts
export const post : RequestHandler = async () => {
return {
status: 200,
headers: {
'set-cookie': [
`refresh_token=; Max-Age=0; Path=/; ${secure} HttpOnly`,
`token=; Max-Age=0; Path=/;${secure} HttpOnly`
]
}
};
};
VIII. Отзыв доступа у пользователя
Чтобы отозвать доступ у пользователя, просто измените refresh токен пользователя в базе данных. Имейте в виду, что пользователь будет оставаться в системе до 15 минут (срок действия JWT).
const new_refresh_token = randomUUID();
await admin.from('users').update({ refresh_token: new_refresh_token }).eq("refresh_token", refresh_token);
Это основы, но если вы поняли это, реализация обновлений профиля и других функций должна быть довольно простой.
Rsa97
Сейчас у вас если злоумышленник перехватил пару токенов, то он может оставаться в системе как угодно долго, поскольку рефреш-токен у вас никогда не меняется.
Логика работы должна быть более сложной.
При аутентификации пользователя генерируется пара токенов — короткоживущий основной (например, на 15 минут) и рефреш на длительный срок (например, сутки). Рефреш-токен записывается в отдельную таблицу с привязкой к пользователю. На каждое устройство/сеанс генерируется своя пара.
При запросе с просроченным основным токеном сервер возвращает требование обновления токена. Клиент в ответ присылает рефреш-токен.
Если такого рефреш-токена нет в таблице, то отправляем требование аутентификации через логин/пароль.
Если рефреш-токен есть в таблице, но помечен как использованный, значит где-то произошла утечка. В этом случае удаляем все рефреш-токены данного пользователя и на всех устройствах/во всех сеансах ему надо будет войти через логин/пароль.
Иначе отмечаем рефреш-токен как использованный.
Проверяем корректность рефреш-токена и срок его действия. Если рефреш-токен недействительный или просроченный, то отправляем требование аутентификации через логин/пароль.
Если с рефреш-токеном всё в порядке, то генерируем новую пару из основного и рефреш-токенов и отправляем клиенту. Не забываем записать рефреш-токен в таблицу.
Периодически чистим таблицу от сильно (например, больше недели) просроченных рефреш-токенов.
NikitaFedorov Автор
Круто!)
В статье речь скорее о базовых реалиях работы с токенами, без углубления в секьюрность