Привет, сегодня я продолжу свою статью и покажу реальный пример приложения на Actix web.

Немного лирики для начала.

Я буду писать, используя raw sql с помощью библиотеки sqlx, базой данных послужит Postgresql.
Сервисом будет примитивный мессенджер, только с личными сообщениями.
Приложение будет разбито на 2 модуля: Authentication и Messages.
Для аутентификации будут использованы jwt токены.
Приложение будет в monorepo, для запуска будет использоваться docker-compose
В этой статье будет создан модуль аутентификации, ссылка на готовый проект будет в второй части статьи

Подготовка

Создадим папку в которой будет все необходимое.

mkdir app && cd app
touch Cargo.toml
cargo init --bin auth
cargo init --bin messages

В Cargo.toml пропишем workspace информацию.

# Cargo.toml

[workspace]
resolver = "2"
members = [
  "auth",
  "messages"
]

Это нужно скорее для cargo и IDE, нежели для нас.

Добавим зависимости.

cd auth 
cargo add actix-web env_logger log jsonwebtoken bcrypt \
  chrono --features chrono/serde \
  serde --features serde/derive serde_json \ 
  uuid --features uuid/v4,uuid/serde \
  sqlx --features sqlx/runtime-tokio,sqlx/postgres,sqlx/chrono,sqlx/uuid

И немного пробежимся по ним.

Env_logger и log - логирование в приложении
Jsonwebtoken - создание JWT
Bcrypt - Хэширование (подробнее про bcrypt)
Chrono - библиотека для работы со временем
Serde - сериализация и десериализация из различных типов данных. В нашем случае serde_json
Uuid - уникальные идентификаторы (подробнее про uuid)
Sqlx - асинхронный sql toolkit

В sqlx обязательно нужно указывать датабазу и runtime (tokio или async-std).

Миграции

Для миграций будем использовать CLI инструмент от sqlx.

cargo install sqlx-cli 
# cargo скачает и сбилдит CLI, позже можно использовать при помощи
# sqlx <command> или cargo sqlx <command>

sqlx migrate add -r init
# sqlx создаст директорию migrations с файлами для создания и удаления миграции
-- /migrations/<creation_timestamp>_init.up.sql

-- Add up migration script here

-- Дополнение для автоматической генерации uuid
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Ибо это первая миграция, нам не нужны все таблицы, поэтому они дропаются
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS media;
DROP TABLE IF EXISTS tokens;

CREATE TABLE users (
  id uuid NOT NULL DEFAULT uuid_generate_v4(),
  -- username будет служить как логин, так и как публичное имя, не делайте так
  username varchar(255) NOT NULL,
  password text NOT NULL,
  creation_time timestamp NOT NULL DEFAULT NOW(),
  PRIMARY KEY (username, id),
  UNIQUE (username),
  UNIQUE (id)
);

CREATE TABLE messages (
    id uuid NOT NULL DEFAULT uuid_generate_v4(),
    sender uuid NOT NULL,
    receiver uuid NOT NULL,
    text text,
    creation_time timestamp NOT NULL DEFAULT NOW(),
    FOREIGN KEY(sender) REFERENCES users(id),
    FOREIGN KEY(receiver) REFERENCES users(id),
    UNIQUE (id)
);

CREATE TABLE media (
  blob BYTEA NOT NULL,
  message_id uuid NOT NULL,
  FOREIGN KEY(message_id) REFERENCES messages(id)
);

-- Тут будут храниться refresh tokens
CREATE TABLE tokens(
    token text NOT NULL,
    owner uuid NOT NULL,
    expires_at timestamp DEFAULT (now() AT TIME ZONE 'utc' + INTERVAL '30 days'),
    FOREIGN KEY(owner) REFERENCES users(id)
);

-- /migrations/<creation_timestamp>_init.down.sql

-- Add down migration script here
DROP TABLE IF EXISTS tokens;
DROP TABLE IF EXISTS media;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS users;

Далее нужно провести миграции

# Для того, чтобы sqlx работал, нужно в env поставить DATABASE_URL
# Linux: export DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres"
# Windows (PowerShell): $Env:DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres"

# docker run --rm --name pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres
# Я использую такую docker команду, для работы
# Примечание: контейнер удалится после выключения

sqlx migrate run # принятие всех не проведенных миграций 
sqlx migrate revert # отмена миграций по порядку 

Начнем же писать код

В первую очередь напишем аутентификацию

// auth/main.rs
// В целом ничего нового с прошлой статьи, поэтому опущу комментарии
use actix_web::middleware::Logger;
use actix_web::web::Data;
use actix_web::{App, HttpServer};
use log::info;
use sqlx::PgPool;


pub(crate) struct AppState {
    pg_pool: PgPool,
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));

    // docker run --rm --name pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres
    // Примечание: контейнер удалится после выключения
    let pg_pool = PgPool::connect("postgres://postgres:postgres@localhost:5432/")
        .await
        .unwrap();

    info!("Successfully connected to database");

    let app_state = Data::new(AppState { pg_pool });

    info!("Successfully started server");

    HttpServer::new(move || {
        App::new()
            .wrap(Logger::default())
            .app_data(app_state.clone())
    })
    .bind("0.0.0.0:8080")
    .unwrap()
    .run()
    .await
}

Добавим несколько файлов для базовых функций и utils.

// auth/main.rs

// Я решил, что файлы будут содержать одну функцию, которая задана их именем
mod utils; 
mod login;
mod reg;

// utils.rs

use actix_web::HttpResponse;
use bcrypt::{DEFAULT_COST, hash_with_salt};
use log::error;
use serde::Deserialize;
use sqlx::{PgPool, query};

// Этот struct нужен для обоих функций login и register, поэтому в utils
#[derive(Deserialize)]
pub(crate) struct User {
    // pub(crate) нужен чтобы получать доступ к username через .username
    pub(crate) username: String,
    password: String
}

// Внутренние функции User
impl User {
    pub(crate) fn hash_password(&self) -> String {
        // Определение функции hash_with_salt 
        // https://docs.rs/bcrypt/latest/bcrypt/fn.hash_with_salt.html
        // Cost в Bcrypt определяет количество итераций алгоритма
        // константа DEFAULT_COST = 12
        // Todo: Поменять способ передачи salt
        // Позже salt будет читаться из файла, который будет грузиться 
        // в runtime контейнера
        hash_with_salt(
          &self.password, 
          DEFAULT_COST, 
          // Разыменовывание (*) необходимо, т.к b"" дает тип &[u8; N]
          // А в параметр salt в этой функции принимает тип [u8;16]
          *b"insecurepassword"
        ).unwrap().to_string()
    }
}

pub(crate) async fn user_exists(username: &str, pg_pool: &PgPool) -> Result<bool, HttpResponse> {
    // Создание query, которую потом может использовать PgPool
    query("SELECT * FROM users WHERE username = $1")
        // Только в Postgresql и Sqlite нужно указывать через $N
        // В MySQL и MariaDB нужно указывать через ?
        // Привязка и sanitization переменной от SQL injections 
        // https://en.wikipedia.org/wiki/SQL_injection
        .bind(username)
        // функция вернет Vec<PgRow>, которые ей вернет база данных
        .fetch_all(pg_pool)
        .await
        // .map() в Result применяет функцию к Ok(T), но не трогает Err(E) 
        .map(|rows| !rows.is_empty())
        // .map_err() наоборот применяет функцию только к Err(E)
        .map_err(|e| {
            // Просто логи
            error!("Error: {}", e);
            HttpResponse::InternalServerError().finish()
        })
}

// auth/reg.rs

use actix_web::{HttpResponse, post};
use actix_web::web::{Data, Json};
use log::error;
use sqlx::{Error, query, Row};
use sqlx::postgres::PgRow;
use uuid::Uuid;
use crate::AppState;
use crate::utils::{User, user_exists};

#[post("/register")]
pub(crate) async fn register(app_state: Data<AppState>, user: Json<User>) -> HttpResponse {
    let exists = user_exists(
      &user.username, 
      &app_state.pg_pool
    ).await;

    match exists {
        Ok(exists) => {
            if exists {
                return HttpResponse::Conflict().body("User with provided username is already registered")
            }

            let row = match query(
                "INSERT INTO users(username,password) values($1, $2) RETURNING id"
            ).bind(&user.username).bind(user.hash_password())
              // Если база данных вернет не 1 row, то будет ошибка
                .fetch_one(&app_state.pg_pool).await  {
                Ok(r) => r,
                Err(e) => {
                    error!("{}" ,e);
                    return HttpResponse::InternalServerError().finish()
                }
            };

            let id: Uuid = row.get("id");

            // Осталось только генерировать токены и сохранять их в базу данных
            HttpResponse::Ok().body("Successfully registered")
        }
        Err(res) => res
    }
}

// auth/login.rs

use actix_web::{HttpResponse, post};
use actix_web::web::{Data, Json};
use sqlx::{query, Row};
use uuid::Uuid;
use crate::AppState;
use crate::utils::{User, user_exists};

#[post("/login")]
pub(crate) async fn login(app_state: Data<AppState>, user: Json<User>) -> HttpResponse {
    let exists = user_exists(
      &user.username, 
      &app_state.pg_pool
    ).await;

    match exists {
        Ok(exists) => {
            if !exists {
                return HttpResponse::NotFound().body("User with provided username is not registered")
            }

            let Ok(row) = query(
                "SELECT id FROM users WHERE username = $1 AND password = $2"
            ).bind(&user.username).bind(user.hash_password())
                .fetch_one(&app_state.pg_pool).await else {
                return HttpResponse::BadRequest().body("Username or password is incorrect")
            };

            let id: Uuid = row.get("id");

            // Осталось только генерировать токены и сохранять их в базу данных

            HttpResponse::Ok().body("Successfully logged in")
        }
        Err(res) => res
    }
}

Теперь сделаем логику для токенов

// auth/main.rs
mod tokens;

// auth/tokens.rs

use actix_web::HttpResponse;
use bcrypt::{DEFAULT_COST, hash_with_salt};
use jsonwebtoken::{decode, DecodingKey, encode, EncodingKey, Header, Validation};
use log::error;
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, query};
use uuid::Uuid;

#[derive(Serialize, Deserialize)]
pub(crate) struct JwtClaims {
    // https://serde.rs/field-attrs.html
    #[serde(rename = "sub")]
    subject: Uuid,
    #[serde(rename = "iat")]
    issued_at: i64,
    #[serde(rename = "exp")]
    expires: i64,
    #[serde(rename = "exi")]
    expires_in: i64,
}

pub(crate) enum TokenKind {
    Refresh,
    Access
}

impl JwtClaims {
    pub(crate) fn encode(subject: Uuid, token_kind: TokenKind) -> String {
        let expires_in = match token_kind {
            // Месяц
            TokenKind::Refresh => 60 * 60 * 24 * 30,
            // 15 минут
            TokenKind::Access => 900
        };

        let current_time = chrono::Utc::now().timestamp();

        let claims = Self {
            subject,
            issued_at: current_time,
            expires: current_time + expires_in,
            expires_in,
        };

        // Определение функции https://docs.rs/jsonwebtoken/latest/jsonwebtoken/fn.encode.html
        // Todo: поменять способ передачи ключа
        encode(
            // https://docs.rs/jsonwebtoken/latest/jsonwebtoken/struct.Header.html
            &Header::default(),
            &claims,
            // https://docs.rs/jsonwebtoken/latest/jsonwebtoken/struct.EncodingKey.html
            &EncodingKey::from_secret(b"insecurekey")
        ).unwrap()
    }

    pub(crate) fn decode(token: &str) -> Result<Self, HttpResponse> {
        // Определение функции https://docs.rs/jsonwebtoken/latest/jsonwebtoken/fn.decode.html
        // Todo: поменять способ передачи ключа
        match decode::<Self>(
            token,
            // https://docs.rs/jsonwebtoken/latest/jsonwebtoken/struct.DecodingKey.html
            &DecodingKey::from_secret(b"insecurekey"),
            // https://docs.rs/jsonwebtoken/latest/jsonwebtoken/struct.Validation.html
            &Validation::default()
        ) {
            Ok(data) => {
                Ok(data.claims)
            }
            Err(e) => {
                error!("{}" ,e);
                Err(HttpResponse::BadRequest().body("Authentication token is invalid"))
            }
        }
    }

    pub(crate) fn generate_tokens(id: Uuid) -> (String, String) {
        let refresh_token = Self::encode(id, TokenKind::Refresh);
        let access_token = Self::encode(id, TokenKind::Access);

        (refresh_token, access_token)
    }
}

Напишем в функцию в utils для записи токена в базу данных и допишем функции register и login

// auth/utils.rs
use uuid::Uuid;

pub(crate) async fn insert_token(token: &String, id: Uuid, pg_pool: &PgPool) -> Result<(), HttpResponse> {
    // Todo: изменить способ получения salt
    let hashed_token = hash_with_salt(
      token, 
      DEFAULT_COST, 
      *b"insecurepassword"
    ).unwrap().to_string();

    if let Err(e) = query("INSERT INTO tokens VALUES($1,$2)").bind(hashed_token).bind(id).execute(pg_pool).await {
        error!("{}" ,e);
        return Err(HttpResponse::InternalServerError().finish())
    }
    Ok(())
}

// auth/reg.rs

use crate::utils::insert_token;
use crate::tokens::JwtClaims;
use serde_json::json;

#[post("/register")]
pub(crate) async fn register(app_state: Data<AppState>, user: Json<User>) -> HttpResponse {
  // Прежний код

  let id: Uuid = row.get("id");

  let (refresh_token, access_token) = JwtClaims::generate_tokens(id);

  if let Err(res) = insert_token(&refresh_token, id, &app_state.pg_pool).await {
    return res
  }

  HttpResponse::Ok().json(json!({
    "refresh_token": refresh_token,
    "access_token": access_token
  }))
  // Прежний код, без прошлого Ok ответа
}

// auth/login.rs
use crate::utils::insert_token;
use crate::tokens::JwtClaims;
use serde_json::json;

#[post("/login")]
pub(crate) async fn login(app_state: Data<AppState>, user: Json<User>) -> HttpResponse {
  // Прежний код

  let id: Uuid = row.get("id");

  let (refresh_token, access_token) = JwtClaims::generate_tokens(id);

  if let Err(res) = insert_token(&refresh_token, id, &app_state.pg_pool).await {
    return res
  }

  HttpResponse::Ok().json(json!({
    "refresh_token": refresh_token,
    "access_token": access_token
  }))
  // Прежний код, без прошлого Ok ответа
}

Теперь напишем логику для генерации Access токенов

// auth/tokens.rs

//Прежний код

use crate::AppState;
use actix_web::web::{Data, Json};
use actix_web::post;

#[derive(Deserialize)]
struct Token {
    token: String
}

#[post("/token")]
pub(crate) async fn get_access_token(app_state: Data<AppState>, token: Json<Token>) -> HttpResponse {
    let claims = match JwtClaims::decode(token.token.as_str()) {
        Ok(claims) => claims,
        Err(res) => {
            return res
        }
    };

    let hashed_token = hash_with_salt(
      token.0.token, 
      DEFAULT_COST, 
      *b"insecurepassword"
    ).unwrap().to_string();

    let rows = match query("SELECT * FROM tokens WHERE token = $1").bind(hashed_token).fetch_all(&app_state.pg_pool).await {
        Ok(r) => r,
        Err(e) => {
            error!("{}" ,e);
            return HttpResponse::InternalServerError().finish()
        }
    };

    if rows.is_empty() {
        return HttpResponse::Unauthorized().body("Please re-login")
    }

    HttpResponse::Ok().body(
        JwtClaims::encode(
            claims.subject, TokenKind::Access
        )
    )
}

Добавим handlers и сделаем нормальный способ получения секретов, вместо вписывания их в Git репозитории.

// auth/main.rs

// https://doc.rust-lang.org/rust-by-example/attribute/cfg.html
// Этот mod будет если будет указан флаг --release
#[cfg(not(debug_assertions))]
mod secrets {
    use std::fs::{read, read_to_string};
    // Стабильно после Rust 1.80
    use std::sync::LazyLock;
    // https://doc.rust-lang.org/stable/std/sync/struct.LazyLock.html

    pub(crate) static SIGN_SECRET: LazyLock<String> =
        LazyLock::new(|| read_to_string("/etc/sign").unwrap_or("333".to_string()));

    pub(crate) static PASSWORD_SALT: LazyLock<[u8; 16]> = LazyLock::new(|| {
        <[u8; 16]>::try_from(read("/etc/password_salt").unwrap_or("1234567812345678".into())).unwrap()
    });

    pub(crate) static TOKEN_SALT: LazyLock<[u8; 16]> = LazyLock::new(|| {
        <[u8; 16]>::try_from(read("/etc/token_salt").unwrap_or("1234567812345678".into())).unwrap()
    });
}

// А этот мод будет для разработки
#[cfg(debug_assertions)]
mod secrets {
    use std::sync::LazyLock;

    pub(crate) static SIGN_SECRET: LazyLock<String> =
        LazyLock::new(|| "dev_env".to_string() );

    pub(crate)static PASSWORD_SALT: LazyLock<[u8; 16]> = LazyLock::new(|| {
        *b"dev_env_12345678"
    });

    pub(crate)static TOKEN_SALT: LazyLock<[u8; 16]> = LazyLock::new(|| {
        *b"dev_env_12345678"
    });
}

use crate::reg::register;
use crate::tokens::get_access_token;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Прежний код
    HttpServer::new(move || {
        App::new()
            .wrap(Logger::default())
            .app_data(app_state.clone())
            .service(login::login)
            .service(register)
            .service(get_access_token)
    })
    // Прежний код
}

// А теперь меняем везде на нужные static. Безопасность, блин

// auth/token.rs

use crate::secrets::{SIGN_SECRET, TOKEN_SALT};

// Прежний код

impl JwtClaims {
  pub(crate) fn encode(sub: Uuid, token_kind: TokenKind) -> String {
    // Прежний код
    encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(SIGN_SECRET.as_bytes())
    ).unwrap()
  }
  pub(crate) fn decode(token: &str) -> Result<Self, HttpResponse> {
    match decode::<Self>(
      token,
      &DecodingKey::from_secret(SIGN_SECRET.as_bytes()),
      &Validation::default()
    ) {
      // Прежний код
    }
  }
  // Прежний код
}
#[post("/token")]
pub(crate) async fn get_access_token(app_state: Data<AppState>, token: Json<Token>) -> HttpResponse {
  // Прежний код

  let hashed_token = hash_with_salt(
    token.0.token, 
    DEFAULT_COST, 
    *TOKEN_SALT
  ).unwrap().to_string();

  // Прежний код
}

// auth/utils.rs

use crate::secrets::{PASSWORD_SALT, TOKEN_SALT};

impl User {
    pub(crate) fn hash_password(&self) -> String {
        hash_with_salt(
          &self.password, 
          DEFAULT_COST, 
          *PASSWORD_SALT
        ).unwrap().to_string()
    }
}

pub(crate) async fn insert_token(token: &String, id: Uuid, pg_pool: &PgPool) -> Result<(), HttpResponse> {
    let hashed_token = hash_with_salt(
      token, 
      DEFAULT_COST, 
      *TOKEN_SALT
    ).unwrap().to_string();

    // Прежний код
}

Усе! Базовый модуль для авторизации написан, в следующей статье сделаем сами переписки.

Спасибо за прочтение, удачи в освоение нового!

Комментарии (6)


  1. csl
    28.08.2024 23:12

    Будет ли в проекте рассмотрена проблема отката миграций, как, например, у @Akina тут ?


    1. csl
      28.08.2024 23:12

      Offtop: можете в следующих постах прятать часть кода под спойлер


  1. domix32
    28.08.2024 23:12
    +2

    Это нужно скорее для IDE, нежели для нас.

    Это нужно в том числе и cargo.

    *b"insecurepassword"

    а зачем разыменовывать строчку? префиск b сам его превратит в нормальный массив. Для LazyLock-то понятно, оно его вычислять так будет.

    // Issued at
    iat: i64,
    // Expires at
    exp: i64,
    // Expires in
    exi: i64

    Да назовите вы эти поля по человечески вместо дурацких комментариев. Лучше rename для сериализации указать, если хочется коротких имён.

    Ну и хотелось бы некоторого единообразия в цепочках вызов. Где-то оно у вас красиво в нескольк строчек, а где-то за 200 символов в одну строчку сделаны.


    1. Persona36LQ Автор
      28.08.2024 23:12

      Спасибо за подробный комментарий, ошибки с cargo.toml, сериализацией и форматированием исправил.

      В функции hash_with_salt() вот такой параметр salt: [u8;16], в то время как b делает тип &[u8;N]


      1. domix32
        28.08.2024 23:12

        В функции hash_with_salt() вот такой параметр salt: [u8;16], в то время как b делает тип &[u8;N]

        Ручками таким заниматься нет смысла - компилятор самостоятельно сможет вывести нужный тип.


        1. Persona36LQ Автор
          28.08.2024 23:12
          +1

          Нет, не сможет

          let hashed_token = hash_with_salt(
                  token.0.token,
                  DEFAULT_COST,
                  b"1234567812345678"
              ).unwrap().to_string();
          Скрытый текст

          error[E0308]: mismatched types
          --> auth\src\tokens.rs:105:9
          |
          102 | let hashed_token = hash_with_salt(
          | -------------- arguments to this function are incorrect
          ...
          105 | b"1234567812345678"
          | ^^^^^^^^^^^^^^^^^^^ expected [u8; 16], found &[u8; 16]
          |
          note: function defined here
          --> C:\Users\Emil.cargo\registry\src\index.crates.io-6f17d22bba15001f\bcrypt-0.15.1\src\lib.rs:193:8
          |
          193 | pub fn hash_with_salt<P: AsRef<[u8]>>(
          | ^^^^^^^^^^^^^^
          help: consider dereferencing the borrow
          |
          105 | *b"1234567812345678"
          | +

          For more information about this error, try rustc --explain E0308.