Продолжаем работать с 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 или 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();
// Прежний код
}
Усе! Базовый модуль для авторизации написан, в следущей статье сделаем сами переписки.
Спасибо за прочтение, удачи в освоение нового!