Продолжаем работать с Actix Web (часть 1)

fd609f87ad0838c44dd0541c757ec802.png

Привет, сегодня я продолжу свою статью и покажу реальный пример приложения на 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"
]

Это нужно скорее для 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  или cargo sqlx 

sqlx migrate add -r init
# sqlx создаст директорию migrations с файлами для создания и удаления миграции
-- /migrations/_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/_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"insecurepassword"
        ).unwrap().to_string()
    }
}

pub(crate) async fn user_exists(username: &String, pg_pool: &PgPool) -> Result {
    // Создание 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, которые ей вернет база данных
        .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, user: Json) -> 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 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, user: Json) -> 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 {
    // Кому принадлежит (Subject)
    sub: Uuid,
    // Issued at
    iat: i64,
    // Expires at
    exp: i64,
    // Expires in
    exi: i64
}

pub(crate) enum TokenKind {
    Refresh,
    Access
}

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

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

        let claims = Self {
            sub,
            iat: current_time,
            exp: current_time + exi,
            exi,
        };
        
        // Определение функции 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 {
        // Определение функции https://docs.rs/jsonwebtoken/latest/jsonwebtoken/fn.decode.html
        // Todo: поменять способ передачи ключа
        match decode::(
            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, user: Json) -> 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, user: Json) -> 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, token: Json) -> 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.sub, TokenKind::Access))
}

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

// auth/main.rs

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

static SIGN_SECRET: LazyLock = LazyLock::new(|| {
    read_to_string("/etc/sign").unwrap()
});

static PASSWORD_SALT: LazyLock<[u8; 16]> =
    LazyLock::new(|| <[u8; 16]>::try_from(read("/etc/password_salt").unwrap()).unwrap());

static TOKEN_SALT: LazyLock<[u8; 16]> =
    LazyLock::new(|| <[u8; 16]>::try_from(read("/etc/token_salt").unwrap()).unwrap());


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::{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 {
    match decode::(
      token,
      &DecodingKey::from_secret(SIGN_SECRET.as_bytes()),
      &Validation::default()
    ) {
      // Прежний код
    }
  }
  // Прежний код
}
#[post("/token")]
pub(crate) async fn get_access_token(app_state: Data, token: Json) -> HttpResponse {
  // Прежний код

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

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

// auth/utils.rs

use crate::{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();

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

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

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

© Habrahabr.ru