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