Pull to refresh

Туториал: SvelteKit JWT авторизация

Reading time 6 min
Views 5.9K
Original author: pilcrowOnPaper

Здравствуйте, в этой статье рассказывается о том, как внедрить аутентификацию в ваш SvelteKit проект. Это будет JWT аутентификация с использованием refresh токенов для дополнительной безопасности. Мы будем использовать Supabase в качестве базы данных (PostgreSQL), но основы должны быть теми же.

Github repository

Как это будет работать?

Когда пользователь регистрируется, мы сохраняем информацию о пользователе и пароль в нашей базе данных. Также мы сгенерируем 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: Для создания JWT

  • cookie: Для парсинга cookie с сервера

II. Supabase

Создайте новый проект. Теперь создайте новую таблицу users (всё non-null).

  • id : int8, unique, isIdentity

  • email : varchar, unique

  • password : text

  • username : varchar, unique

  • user_id : uuid, unique

  • refresh_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);

Это основы, но если вы поняли это, реализация обновлений профиля и других функций должна быть довольно простой.

Tags:
Hubs:
+4
Comments 2
Comments Comments 2

Articles