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

от автора

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

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

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

Напишем в функцию в 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.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<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();      // Прежний код }

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/839376/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *