Построение системы аутентификации в Rust с использованием сеансовых маркеров

Большинство веб-сайтов имеют какую-либо пользовательскую систему. Но реализация аутентификации может быть немного сложной. Для этого требуется несколько вещей, работающих вместе.

Убедиться в том, что система безопасна, очень сложно. Откуда нам знать, что другие не смогут легко войти в учетные записи и вносить правки от имени других людей? А создание систем с контролем состояния — сложная задача.

Сегодня мы рассмотрим минимальную реализацию на языке Rust. Для этой демонстрации мы не будем использовать конкретную библиотеку аутентификации, а напишем ее с нуля, используя собственную базу данных и API бэкенда.

Мы рассмотрим реализацию системы, включая фронтенд для взаимодействия с ней. Мы будем использовать Axum для маршрутизации и другой логики обработки. Исходный код этого учебника можно найти здесь. Затем мы развернем код на shuttle, который будет работать с сервером и предоставит нам доступ к серверу Postgres.

Чтобы этот пост не растянулся на час, некоторые вещи будут пропущены (например, обработка ошибок) и поэтому могут не совпадать один в один с учебником. Этот пост также предполагает базовые знания HTML, веб-серверов, баз данных и Rust.

Это не проверено на безопасность, используйте это на свой страх и риск!!!

Давайте начнем

Сначала мы установим shuttle для создания проекта (и позже для развертывания). Если у вас его еще нет, вы можете установить его с помощью cargo install cargo-shuttle. Сначала мы перейдем в новый каталог для нашего проекта и создадим новое приложение Axum с помощью cargo shuttle init --axum.

Вы должны увидеть следующее в src/lib.rs:

use axum::{routing::get, Router};
use sync_wrapper::SyncWrapper;

async fn hello_world() -> &'static str {
    "Hello, world!"
}

#[shuttle_service::main]
async fn axum() -> shuttle_service::ShuttleAxum {
    let router = Router::new().route("/hello", get(hello_world));
    let sync_wrapper = SyncWrapper::new(router);

    Ok(sync_wrapper)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Шаблоны

Для генерации HTML мы будем использовать Tera, поэтому мы можем добавить его с помощью cargo add tera. Мы будем хранить все наши шаблоны в директории template в корне проекта.

Нам нужен общий макет для нашего сайта, поэтому мы создадим базовый макет. В базовый макет мы можем добавить специфические теги, которые будут применяться ко всем страницам, например, шрифт Google. В этом макете все содержимое будет вставляться вместо {% block content %}{% endblock content %}:

<!-- in "templates/base.html" -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Title</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Karla:wght@500&display=swap" rel="stylesheet">
    <link href="/styles.css" rel="stylesheet">
</head>
<body>
    {% block content %}{% endblock content %}
</body>
</html>
Вход в полноэкранный режим Выход из полноэкранного режима

А теперь мы можем создать нашу первую страницу, которая будет отображаться по пути /.

<!-- in "templates/index.html" -->
{% extends "base.html" %}
{% block content %}
<h1>Hello world</h1>
{% endblock content %}
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь у нас есть наш шаблон, нам нужно зарегистрировать его в экземпляре Tera. Tera имеет хорошую систему регистрации на основе файловой системы, но мы будем использовать макрос include_str!, чтобы содержимое было в бинарном файле. Таким образом, нам не придется иметь дело со сложностями файловой системы во время выполнения. Мы регистрируем оба шаблона, чтобы страница index знала о base.html.

let mut tera = Tera::default();
tera.add_raw_templates(vec![
    ("base.html", include_str!("../templates/base.html")),
    ("index", include_str!("../templates/index.html")),
])
.unwrap();
Вход в полноэкранный режим Выход из полноэкранного режима

Добавляем его через расширение (обернутое в Arc, чтобы клонирование расширения не привело к глубокому клонированию всех шаблонов)

#[shuttle_service::main]
async fn axum() -> shuttle_service::ShuttleAxum {
    let mut tera = Tera::default();
    tera.add_raw_templates(vec![
        ("base.html", include_str!("../templates/base.html")),
        ("index", include_str!("../templates/index.html")),
    ])
    .unwrap();

    let router = Router::new()
        .route("/hello", get(hello_world))
        .layer(Extension(Arc::new(tera)));

    let sync_wrapper = SyncWrapper::new(router);
    Ok(sync_wrapper)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Рендеринг представлений

Теперь, когда мы создали наш экземпляр Tera, мы хотим, чтобы он был доступен для наших методов get. Чтобы сделать это в Axum, мы добавляем расширение в качестве параметра в нашу функцию. В Axum расширение — это единица struct. Вместо того чтобы использовать .0 для доступа к полям, мы используем деструктуризацию в параметре (если вы подумали, что этот синтаксис выглядит странно).

async fn index(
    Extension(templates): Extension<Templates>,
) -> impl IntoResponse {
    Html(templates.render("index", &Context::new()).unwrap())
}
Вход в полноэкранный режим Выход из полноэкранного режима

Обслуживание активов

Мы можем создать файл public/styles.css.

body {
    font-family: 'Karla', sans-serif;
    font-size: 12pt;
}
Вход в полноэкранный режим Выйти из полноэкранного режима

И легко создать новую конечную точку, с которой он будет обслуживаться:

async fn styles() -> impl IntoResponse {
    Response::builder()
        .status(http::StatusCode::OK)
        .header("Content-Type", "text/css")
        .body(include_str!("../public/styles.css").to_owned())
        .unwrap()
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Здесь мы снова используем include_str!, чтобы не беспокоиться о файловой системе во время выполнения. ServeDir — это альтернатива, если у вас есть файловая система во время выполнения. Вы можете использовать этот метод для других статических активов, таких как JavaScript и фавиконы.

Запуск

Мы добавим наши два новых маршрута в маршрутизатор (и удалим стандартный маршрут «hello world»), чтобы получить:

let router = Router::new()
    .route("/", get(index))
    .route("/styles.css", get(styles))
    .layer(Extension(Arc::new(tera)));
Вход в полноэкранный режим Выход из полноэкранного режима

С нашим основным сервисом мы можем теперь протестировать его локально с помощью cargo shuttle run.

Отлично!

Добавление пользователей

Мы начнем с таблицы пользователей в SQL. (она определена в файле schema.sql).

CREATE TABLE users (
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    username text NOT NULL UNIQUE,
    password text NOT NULL
);
Вход в полноэкранный режим Выход из полноэкранного режима

id генерируется базой данных с помощью последовательности. id — это первичный ключ, который мы будем использовать для ссылки на пользователей. Лучше использовать поле с фиксированным значением для идентификации, чем что-то вроде поля username, потому что вы можете добавить возможность изменять имена пользователей, что может привести к тому, что все будет указывать не на те места.

Регистрация нашей базы данных

Прежде чем наше приложение сможет использовать базу данных, мы должны добавить sqlx с некоторыми функциями: cargo add sqlx -F postgres runtime-tokio-native-tls. Мы также включим функцию Postgres для шаттла с помощью cargo add shuttle-service -F sqlx-postgres.

Теперь вернемся в код и добавим параметр с #[shared::Postgres] pool: Database. Аннотация #[shared::Postgres] указывает шаттлу предоставить базу данных Postgres, используя инфраструктуру из дизайна кода!

type Database = sqlx::PgPool;

#[shuttle_service::main]
async fn axum(
    #[shared::Postgres] pool: Database
) -> ShuttleAxum {
    // Build tera as before

    let router = Router::new()
        .route("/", get(index))
        .route("/styles.css", get(styles))
        .layer(Extension(Arc::new(tera)))
        .layer(pool);

    // Wrap and return router as before
}
Вход в полноэкранный режим Выход из полноэкранного режима

Регистрация

Для получения пользователей в нашу базу данных мы создадим обработчик сообщений. В нашем обработчике мы будем разбирать данные с помощью multipart. Я написал простой парсер для multipart, который мы будем использовать здесь. Приведенный ниже пример содержит некоторую обработку ошибок, которую мы пока проигнорируем.

async fn post_signup(
    Extension(database): Extension<Database>,
    multipart: Multipart,
) -> impl IntoResponse {
    let data = parse_multipart(multipart)
        .await
        .map_err(|err| error_page(&err))?;

    if let (Some(username), Some(password), Some(confirm_password)) = (
        data.get("username"),
        data.get("password"),
        data.get("confirm_password"),
    ) {
        if password != confirm_password {
            return Err(error_page(&SignupError::PasswordsDoNotMatch));
        }

        let user_id = create_user(username, password, database);

        Ok(todo!())
    } else {
        Err(error_page(&SignupError::MissingDetails))
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Создание пользователей и безопасность хранения паролей

При хранении паролей в базе данных по соображениям безопасности мы не хотим, чтобы они были в точном формате обычного текста. Чтобы преобразовать их из формата простого текста, мы будем использовать криптографическую хэш-функцию из pbkdf2 (cargo add pbkdf2):

fn create_user(username: &str, password: &str, database: &Database) -> Result<i32, SignupError> {
    let salt = SaltString::generate(&mut OsRng);
    // Hash password to PHC string ($pbkdf2-sha256$...)
    let hashed_password = Pbkdf2.hash_password(password.as_bytes(), &salt).unwrap().to_string();

    // ...
}
Вход в полноэкранный режим Выход из полноэкранного режима

Благодаря хэшированию, если кто-то получит значение в поле пароля, он не сможет узнать фактическое значение пароля. Единственное, что позволяет это значение, — соответствует ли пароль в виде обычного текста этому значению. А при хешировании разные имена кодируются по-разному. Здесь все эти пароли были зарегистрированы как «password», но в базе данных они имеют разные значения из-за высаливания.

postgres=> select * from users;
 id | username |                                            password
----+----------+------------------------------------------------------------------------------------------------
  1 | user1    | $pbkdf2-sha256$i=10000,l=32$uC5/1ngPBs176UkRjDbrJg$mPZhv4FfC6HAfdCVHW/djgOT9xHVAlbuHJ8Lqu7R0eU
  2 | user2    | $pbkdf2-sha256$i=10000,l=32$4mHGcEhTCT7SD48EouZwhg$A/L3TuK/Osq6l41EumohoZsVCknb/wiaym57Og0Oigs
  3 | user3    | $pbkdf2-sha256$i=10000,l=32$lHJfNN7oJTabvSHfukjVgA$2rlvCjQKjs94ZvANlo9se+1ChzFVu+B22im6f2J0W9w
(3 rows)
Вход в полноэкранный режим Выход из полноэкранного режима

С помощью следующего простого запроса к базе данных и нашего хэшированного пароля мы можем вставлять пользователей.

fn create_user(username: &str, password: &str, database: &Database) -> Result<i32, SignupError> {
    // ...

    const INSERT_QUERY: &str =
        "INSERT INTO users (username, password) VALUES ($1, $2) RETURNING id;";

    let fetch_one = sqlx::query_as(INSERT_QUERY)
        .bind(username)
        .bind(hashed_password)
        .fetch_one(database)
        .await;

    // ...
}
Вход в полноэкранный режим Выход из полноэкранного режима

А обработать ответ и получить новый идентификатор пользователя можно следующим образом:

fn create_user(username: &str, password: &str, database: &Database) -> Result<i32, SignupError> {
    // ...

    match fetch_one {
        Ok((user_id,)) => Ok(user_id),
        Err(sqlx::Error::Database(database))
            if database.constraint() == Some("users_username_key") =>
        {
            return Err(SignupError::UsernameExists);
        }
        Err(err) => {
            return Err(SignupError::InternalError);
        }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Отлично, теперь у нас есть обработчик регистрации, давайте создадим способ вызвать его в пользовательском интерфейсе.

Использование HTML-форм

Для вызова конечной точки с помощью multipart мы будем использовать HTML-форму.

<!-- in "templates/signup.html" -->
{% extends "base.html" %}
{% block content %}
<form action="/signup" enctype="multipart/form-data" method="post">
    <label for="username">Username</label>
    <input type="text" name="username" id="username" minlength="1" maxlength="20" pattern="[0-9a-z]+" required>
    <label for="password">Password</label>
    <input type="password" name="password" id="password" required>
    <label for="confirm_password">Confirm Password</label>
    <input type="password" name="confirm_password" id="confirm_password" required>
    <input type="submit" value="Signup">
</form>
{% endblock content %}
Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание на действие и метод, которые соответствуют маршруту, который мы только что добавили. Заметьте также, что enctype является multipart, что соответствует тому, что мы разбираем в обработчике. В приведенном выше примере есть несколько атрибутов для выполнения проверки на стороне клиента, но в полной версии демо-версии она также обрабатывается на сервере.

Мы создадим обработчик для этой разметки таким же образом, как это было сделано для нашего индекса:

async fn get_signup(
    Extension(templates): Extension<Templates>,
) -> impl IntoResponse {
    Html(templates.render("signup", &Context::new()).unwrap())
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Мы можем добавить signup к экземпляру Tera, а затем добавить обработчики get и post к маршрутизатору, добавив его в цепочку:

.route("/signup", get(get_signup).post(post_signup))
Войти в полноэкранный режим Выйти из полноэкранного режима

Сессии

После регистрации мы хотим сохранить состояние входа в систему. Мы не хотим, чтобы пользователь отправлял свое имя пользователя и пароль при каждом запросе.

Cookies и маркеры сессий

Cookies помогают сохранить состояние между запросами браузера. Когда ответ отправляется с Set-Cookie, все последующие запросы браузера/клиента будут отправлять информацию о cookie. Затем мы можем извлечь эту информацию из заголовков запросов на сервере.

Опять же, они должны быть безопасными. Мы не хотим столкновений/дубликатов. Мы хотим, чтобы это было трудно угадать. По этим причинам мы будем представлять ее как 128-битное целое число без знака. Оно имеет 2^128 вариантов, поэтому вероятность коллизии очень мала.

Мы хотим сгенерировать «токен сессии». Мы хотим, чтобы токены были криптографически безопасными. Учитывая идентификатор сессии, мы не хотим, чтобы пользователи могли найти следующий. Простое глобальное увеличение u128 не будет безопасным, потому что если я знаю, что у меня есть сессия 10, я могу посылать запросы с сессией 11 для пользователя, который вошел в систему позже. При использовании криптографически защищенного генератора не существует отличительного шаблона между последующими токенами. Мы будем использовать алгоритм/крейт ChaCha (мы добавим cargo add rand_core rand_chacha). Мы видим, что он реализует криптографический признак, подтверждающий его пригодность для криптографических сценариев.

Это отличается от генераторов псевдослучайных чисел, где вы можете предсказать следующее случайное число, задав начальную точку и алгоритм. Это может стать проблемой, если у нас есть наш токен, мы можем легко получить токен сессии человека, который вошел в систему после нас, и таким образом выдать себя за него.

Для инициализации генератора случайных чисел мы используем SeedableRng::from_seed. Семя в данном случае — это начальное состояние генератора. Здесь мы используем OsRng.next_u64(), который получает случайность из операционной системы, а не из семени. Мы будем делать нечто похожее на создание экземпляра Tera. Мы должны обернуть его в дугу и мьютекс, потому что генерация новых идентификаторов требует мутабельного доступа. Теперь у нас есть следующая основная функция:

#[shuttle_service::main]
async fn axum(
    #[shared::Postgres] pool: Database
) -> ShuttleAxum {
    // Build tera as before

    let random = ChaCha8Rng::seed_from_u64(OsRng.next_u64())

    let router = Router::new()
        .route("/", get(index))
        .route("/styles.css", get(styles))
        .route("/signup", get(get_signup).post(post_signup))
        .layer(Extension(Arc::new(tera)))
        .layer(pool)
        .layer(Extension(Arc::new(Mutex::new(random))));

    // Wrap and return router as before
}
Вход в полноэкранный режим Выход из полноэкранного режима

Добавление сессий при регистрации

Помимо создания пользователя при регистрации, мы создадим токен сессии для только что зарегистрировавшегося пользователя. Мы поместим его в таблицу с нашим user_id.

type Random = Arc<Mutex<ChaCha8Rng>>;

pub(crate) async fn new_session(
    database: &Database, 
    random: Random, 
    user_id: i32
) -> String {
    const QUERY: &str = "INSERT INTO sessions (session_token, user_id) VALUES ($1, $2);";

    let mut u128_pool = [0u8; 16];
    random.lock().unwrap().fill_bytes(&mut u128_pool);

    // endian doesn't matter here
    let session_token = u128::from_le_bytes(u128_pool);

    let _result = sqlx::query(QUERY)
        .bind(&session_token.to_le_bytes().to_vec())
        .bind(user_id)
        .execute(database)
        .await
        .unwrap();

    session_token
}
Вход в полноэкранный режим Выход из полноэкранного режима

В полной версии демонстрации мы используем новый шаблон типа u128, чтобы сделать это проще, но здесь мы будем придерживаться типа u128.

Теперь у нас есть наш токен, и нам нужно упаковать его в значение cookie. Мы сделаем это самым простым способом, используя .to_string(). Мы отправим ответ, который сделает две вещи: установит это новое значение и вернет/перенаправит нас обратно на индексную страницу. Для этого мы создадим служебную функцию:

fn set_cookie(session_token: &str) -> impl IntoResponse {
    http::Response::builder()
        .status(http::StatusCode::SEE_OTHER)
        .header("Location", "/")
        .header("Set-Cookie", format!("session_token={}; Max-Age=999999", session_token))
        .body(http_body::Empty::new())
        .unwrap()
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь мы можем завершить наш обработчик регистрации, добавив random в качестве параметра и вернув наш ответ с установленным cookie.

async fn post_signup(
    Extension(database): Extension<Database>,
    Extension(random): Extension<Random>,
    multipart: Multipart,
) -> impl IntoResponse {
    let data = parse_multipart(multipart)
        .await
        .map_err(|err| error_page(&err))?;

    if let (Some(username), Some(password), Some(confirm_password)) = (
        data.get("username"),
        data.get("password"),
        data.get("confirm_password"),
    ) {
        if password != confirm_password {
            return Err(error_page(&SignupError::PasswordsDoNotMatch));
        }

        let user_id = create_user(username, password, &database);

        let session_token = new_session(database, random, user_id);

        Ok(set_cookie(&session_token))
    } else {
        Err(error_page(&SignupError::MissingDetails))
    }
}
let session_token = new_session(database, random, user_id);
Вход в полноэкранный режим Выход из полноэкранного режима

Использование маркера сессии

Отлично, теперь у нас есть токен/идентификатор сессии. Теперь мы можем использовать его в качестве ключа для получения информации о пользователях.

Мы можем извлечь значение cookie, используя следующую последовательность итераторов и опций:

let session_token = req
    .headers()
    .get_all("Cookie")
    .iter()
    .filter_map(|cookie| {
        cookie
            .to_str()
            .ok()
            .and_then(|cookie| cookie.parse::<cookie::Cookie>().ok())
    })
    .find_map(|cookie| {
        (cookie.name() == USER_COOKIE_NAME).then(move || cookie.value().to_owned())
    })
    .and_then(|cookie_value| cookie_value.parse::<u128>().ok());
Войти в полноэкранный режим Выйти из полноэкранного режима

Auth промежуточное ПО

В прошлом посте мы подробно рассмотрели промежуточное ПО. Вы можете прочитать об этом более подробно там.

В нашем промежуточном ПО мы немного пофантазируем и сделаем вытягивание пользователя ленивым. Это делается для того, чтобы запросы, которым не нужны данные пользователя, не совершали путешествие по базе данных. Вместо того чтобы добавлять пользователя прямо в запрос, мы разделим его на части. Сначала мы создаем AuthState, который содержит токен сессии, базу данных и заполнитель для нашего пользователя (Option<User>).

#[derive(Clone)]
pub(crate) struct AuthState(Option<(u128, Option<User>, Database)>);

pub(crate) async fn auth<B>(
    mut req: http::Request<B>,
    next: axum::middleware::Next<B>,
    database: Database,
) -> axum::response::Response {
    let session_token = /* cookie logic from above */;

    req.extensions_mut()
        .insert(AuthState(session_token.map(|v| (v, None, database))));

    next.run(req).await
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Затем мы создаем метод на AuthState, который делает запрос к базе данных.

Теперь у нас есть токен пользователя, и нам нужно получить его информацию. Мы можем сделать это с помощью SQL-соединений

impl AuthState {
    pub async fn get_user(&mut self) -> Option<&User> {
        let (session_token, store, database) = self.0.as_mut()?;
        if store.is_none() {
            const QUERY: &str =
                "SELECT id, username FROM users JOIN sessions ON user_id = id WHERE session_token = $1;";

            let user: Option<(i32, String)> = sqlx::query_as(QUERY)
                .bind(&session_token.to_le_bytes().to_vec())
                .fetch_optional(&*database)
                .await
                .unwrap();

            if let Some((_id, username)) = user {
                *store = Some(User { username });
            }
        }
        store.as_ref()
    }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Здесь мы кэшируем пользователя внутри системы с помощью опции Option. Благодаря кэшированию, если другое промежуточное ПО получит пользователя, а затем другой обработчик попытается получить пользователя, то это приведет к одному запросу к базе данных, а не к двум!

Мы можем добавить промежуточное ПО в нашу цепочку с помощью:

#[shuttle_service::main]
async fn axum(
    #[shared::Postgres] pool: Database
) -> ShuttleAxum {
    // tera and random creation as before

    let middleware_database = database.clone();

    let router = Router::new()
        .route("/", get(index))
        .route("/styles.css", get(styles))
        .route("/signup", get(get_signup).post(post_signup))
        .layer(axum::middleware::from_fn(move |req, next| {
            auth(req, next, middleware_database.clone())
        }))
        .layer(Extension(Arc::new(tera)))
        .layer(pool)
        .layer(Extension(Arc::new(Mutex::new(random))));

    // Wrap and return router as before
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Получение промежуточного ПО и отображение информации о пользователе

Изменив наш шаблон index Tera, мы можем добавить блок «if», чтобы показать статус, если пользователь вошел в систему.

<!-- in "templates/index.html" -->
{% extends "base.html" %}
{% block content %}
<h1>Hello world</h1>
{% if username %}
    <h3>Logged in: {{ username }}</h3>
{% endif %}
{% endblock content %}
Вход в полноэкранный режим Выход из полноэкранного режима

Использовать наше промежуточное ПО в запросах легко в Axum, включив ссылку на него в параметры. Затем мы добавляем имя пользователя в контекст, чтобы оно было отображено на странице.

async fn index(
    Extension(current_user): Extension<AuthState>,
    Extension(templates): Extension<Templates>,
) -> impl IntoResponse {
    let mut context = Context::new();
    if let Some(user) = current_user.get_user().await {
        context.insert("username", &user.username);
    }
    Html(templates.render("index", &context).unwrap())
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вход в систему и выход из нее

Отлично, мы можем зарегистрироваться, и теперь мы находимся в сеансе. Мы можем захотеть выйти из сеанса и завершить его. Это очень просто сделать, вернув ответ с cookie Max-Age, установленным на 0.

pub(crate) async fn logout_response() -> impl axum::response::IntoResponse {
    Response::builder()
        .status(http::StatusCode::SEE_OTHER)
        .header("Location", "/")
        .header("Set-Cookie", "session_token=_; Max-Age=0")
        .body(Empty::new())
        .unwrap()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Для входа в систему мы используем логику, очень похожую на логику регистрации с извлечением многокомпонентной информации из почтового запроса. В отличие от регистрации, мы не хотим создавать нового пользователя. Мы хотим проверить, что у строки с этим именем пользователя есть пароль, который совпадает. Если учетные данные совпадают, мы создаем новую сессию:

async fn post_login(
    Extension(database): Extension<Database>,
    multipart: Multipart,
) -> impl IntoResponse {
    let data = parse_multipart(multipart)
        .await
        .map_err(|err| error_page(&err))?;

    if let (Some(username), Some(password)) = (data.get("username"), data.get("password")) {
        const LOGIN_QUERY: &str = "SELECT id, password FROM users WHERE users.username = $1;";

        let row: Option<(i32, String)> = sqlx::query_as(LOGIN_QUERY)
            .bind(username)
            .fetch_optional(database)
            .await
            .unwrap();

        let (user_id, hashed_password) = if let Some(row) = row {
            row
        } else {
            return Err(LoginError::UserDoesNotExist);
        };

        // Verify password against PHC string
        let parsed_hash = PasswordHash::new(&hashed_password).unwrap();
        if let Err(_err) = Pbkdf2.verify_password(password.as_bytes(), &parsed_hash) {
            return Err(LoginError::WrongPassword);
        }

        let session_token = new_session(database, random, user_id);


        Ok(set_cookie(&session_token))
    } else {
        Err(error_page(&LoginError::NoData))
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Затем мы возвращаемся к разделу регистрации и воспроизводим ту же HTML-форму и обработчик, который отображает шаблон Tera, как было показано ранее, но для экрана входа. В конце мы можем добавить два новых маршрута с тремя обработчиками, завершающими демонстрацию:

#[shuttle_service::main]
async fn axum(
    #[shared::Postgres] pool: Database
) -> ShuttleAxum {
    // tera, middleware and random creation as before

    let router = Router::new()
        // ...
        .route("/logout", post(logout_response))
        .route("/login", get(get_login).post(post_login))
        // ...

    // Wrap and return router as before
}
Вход в полноэкранный режим Выход из полноэкранного режима

Развертывание

Все отлично, теперь у нас есть сайт с функцией регистрации и входа. Но у нас нет пользователей, наши друзья не могут войти на наш локальный хост. Мы хотим, чтобы сайт работал в интернете. К счастью, мы используем шаттл, поэтому все просто:

cargo shuttle deploy

Благодаря нашей аннотации #[shuttle_service::main] и поддержке Axum из коробки наше развертывание не требует предварительной настройки, оно мгновенно запускается!

Теперь вы можете продолжить эти концепции и добавить функциональность для добавления и удаления пользователей. Полная демонстрация реализует эти функции, если вы ищете подсказки.

Мысли о создании учебника и другие идеи о том, куда его можно направить

Эта демонстрация включает в себя минимум необходимого для аутентификации. Надеюсь, концепции и фрагменты будут полезны для встраивания в существующий сайт или для создания сайта, нуждающегося в аутентификации. Если вы захотите продолжить, это будет так же просто, как добавление дополнительных полей к объекту пользователя или построение отношений с полем id в таблице пользователя. Я оставлю здесь некоторые свои мысли и мнения по поводу создания сайта, а также то, чем вы можете попробовать его расширить.

Для создания шаблонов Tera подходит отлично. Мне нравится разделение разметки по внешним файлам, а не связывание ее в src/lib.rs. Его API прост в использовании и хорошо документирован. Однако это довольно простая система. У меня было несколько ошибок, когда я переименовывал или удалял шаблоны, а поскольку подборщик шаблонов для рендеринга использует карту, он может запаниковать во время выполнения, если шаблона не существует. Было бы неплохо, если бы система позволяла проверять существование шаблонов во время компиляции. Отправка данных работает на сериализации serde, что требует немного больше вычислительных затрат, чем мне бы хотелось. Она также не поддерживает потоковую передачу. С потоковой передачей мы могли бы сначала отправить фрагмент HTML, который не зависит от значений базы данных, а затем добавить больше контента, когда транзакция базы данных пройдет. Если бы он поддерживал потоковую передачу, мы могли бы избежать страниц «все или ничего» с паузами на белых страницах и начать подключение к таким службам, как Google Fonts, раньше. Дайте мне знать, какой ваш любимый шаблонизатор для Rust и поддерживает ли он эти функции!

Для работы с базой данных в sqlx есть типизированные макросы. Я не использовал их здесь, но для более сложных запросов вы можете предпочесть поведение с проверкой типов. Возможно, 16 байт для хранения маркеров сессий — это перебор. Вы также можете попробовать разделить эту таблицу, если у вас много сессий, или использовать хранилище ключевых значений (например, Redis), что может быть проще. Мы также не реализовали очистку таблицы сессий, если вы храните сессии с помощью Redis, вы можете использовать команду EXPIRE для автоматического удаления старых ключей.

Эта статья в блоге написана на платформе shuttle! Бессерверная платформа, созданная для Rust.

Shuttle: Stateful Serverless для Rust

Развертывание и управление вашими веб-приложениями на Rust может быть дорогим, беспокойным и трудоемким процессом.

Если вы хотите получить опыт работы с батареями и без операционной нагрузки, попробуйте Shuttle.


Оцените статью
devanswers.ru
Добавить комментарий