Continuing to work with Actix Web (part 1)

Hi, today I will continue my article and show a real example of an application on Actix web.

A little bit of poetry to begin with.

I will be writing using raw sql with sqlx librariesthe database will serve Postgresql.
The service will be a primitive messenger, with only personal messages.
The application will be divided into 2 modules: Authentication and Messages.
The following will be used for authentication: jwt tokens.
The application will be in monorepowill be used for launching docker compose
In this article, an authentication module will be created, a link to the finished project will be in the second part of the article

Preparation

Let's create a folder that will contain everything we need.

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

In Cargo.toml we will enter workspace information.

# Cargo.toml

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

This is more for the IDE's benefit than for us.

Let's add dependencies.

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

And let's run through them a little.

Env_logger And log – logging in the application
Jsonwebtoken – Creation JWT
Bcrypt – Hashing (more about bcrypt)
Chrono – library for working with time
Serde – serialization and deserialization from different data types. In our case serde_json
Uuid – unique identifiers (more about uuid)
Sqlx – asynchronous sql toolkit

In sqlx it is necessary to specify the database and runtime (tokio or async std).

Migrations

For migrations we will use CLI tool from 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;

Next, you need to carry out migrations

# Для того, чтобы 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 # отмена миграций по порядку 

Let's start writing code

First of all, let's write authentication

// 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
}

Let's add some files for basic functions and 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<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 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
    }
}

Now let's make logic for tokens

// 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<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)
    }
}

Let's write a function in utils to write a token to the database and add the register and login functions

// 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 ответа
}

Now let's write the logic for generating Access tokens

// 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.sub, TokenKind::Access))
}

Let's add handlers and make a normal way of getting secrets, instead of writing them into Git repositories.

// 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<String> = 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<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::{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();

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

That's it! The basic module for authorization is written, in the next article we will do the correspondence ourselves.

Thanks for reading, good luck in mastering something new!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *