Привет, сегодня я продолжу свою статью и покажу реальный пример приложения на 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"
]
Это нужно скорее для cargo и 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"" дает тип &[u8; N]
// А в параметр salt в этой функции принимает тип [u8;16]
*b"insecurepassword"
).unwrap().to_string()
}
}
pub(crate) async fn user_exists(username: &str, 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(username,password) 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 {
// https://serde.rs/field-attrs.html
#[serde(rename = "sub")]
subject: Uuid,
#[serde(rename = "iat")]
issued_at: i64,
#[serde(rename = "exp")]
expires: i64,
#[serde(rename = "exi")]
expires_in: i64,
}
pub(crate) enum TokenKind {
Refresh,
Access
}
impl JwtClaims {
pub(crate) fn encode(subject: Uuid, token_kind: TokenKind) -> String {
let expires_in = match token_kind {
// Месяц
TokenKind::Refresh => 60 * 60 * 24 * 30,
// 15 минут
TokenKind::Access => 900
};
let current_time = chrono::Utc::now().timestamp();
let claims = Self {
subject,
issued_at: current_time,
expires: current_time + expires_in,
expires_in,
};
// Определение функции 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.subject, TokenKind::Access
)
)
}
Добавим handlers и сделаем нормальный способ получения секретов, вместо вписывания их в Git репозитории.
// auth/main.rs
// https://doc.rust-lang.org/rust-by-example/attribute/cfg.html
// Этот mod будет если будет указан флаг --release
#[cfg(not(debug_assertions))]
mod secrets {
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
pub(crate) static SIGN_SECRET: LazyLock<String> =
LazyLock::new(|| read_to_string("/etc/sign").unwrap_or("333".to_string()));
pub(crate) static PASSWORD_SALT: LazyLock<[u8; 16]> = LazyLock::new(|| {
<[u8; 16]>::try_from(read("/etc/password_salt").unwrap_or("1234567812345678".into())).unwrap()
});
pub(crate) static TOKEN_SALT: LazyLock<[u8; 16]> = LazyLock::new(|| {
<[u8; 16]>::try_from(read("/etc/token_salt").unwrap_or("1234567812345678".into())).unwrap()
});
}
// А этот мод будет для разработки
#[cfg(debug_assertions)]
mod secrets {
use std::sync::LazyLock;
pub(crate) static SIGN_SECRET: LazyLock<String> =
LazyLock::new(|| "dev_env".to_string() );
pub(crate)static PASSWORD_SALT: LazyLock<[u8; 16]> = LazyLock::new(|| {
*b"dev_env_12345678"
});
pub(crate)static TOKEN_SALT: LazyLock<[u8; 16]> = LazyLock::new(|| {
*b"dev_env_12345678"
});
}
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::secrets::{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::secrets::{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();
// Прежний код
}
Усе! Базовый модуль для авторизации написан, в следующей статье сделаем сами переписки.
Спасибо за прочтение, удачи в освоение нового!
Комментарии (6)
domix32
28.08.2024 23:12+2Это нужно скорее для IDE, нежели для нас.
Это нужно в том числе и cargo.
*b"insecurepassword"
а зачем разыменовывать строчку? префиск
b
сам его превратит в нормальный массив. Для LazyLock-то понятно, оно его вычислять так будет.// Issued at
iat: i64,
// Expires at
exp: i64,
// Expires in
exi: i64Да назовите вы эти поля по человечески вместо дурацких комментариев. Лучше rename для сериализации указать, если хочется коротких имён.
Ну и хотелось бы некоторого единообразия в цепочках вызов. Где-то оно у вас красиво в нескольк строчек, а где-то за 200 символов в одну строчку сделаны.
Persona36LQ Автор
28.08.2024 23:12Спасибо за подробный комментарий, ошибки с cargo.toml, сериализацией и форматированием исправил.
В функции hash_with_salt() вот такой параметр salt: [u8;16], в то время как b делает тип &[u8;N]
domix32
28.08.2024 23:12В функции hash_with_salt() вот такой параметр salt: [u8;16], в то время как b делает тип &[u8;N]
Ручками таким заниматься нет смысла - компилятор самостоятельно сможет вывести нужный тип.
Persona36LQ Автор
28.08.2024 23:12+1Нет, не сможет
let hashed_token = hash_with_salt( token.0.token, DEFAULT_COST, b"1234567812345678" ).unwrap().to_string();
Скрытый текст
error[E0308]: mismatched types
--> auth\src\tokens.rs:105:9
|
102 | let hashed_token = hash_with_salt(
| -------------- arguments to this function are incorrect
...
105 | b"1234567812345678"
| ^^^^^^^^^^^^^^^^^^^ expected[u8; 16]
, found&[u8; 16]
|
note: function defined here
--> C:\Users\Emil.cargo\registry\src\index.crates.io-6f17d22bba15001f\bcrypt-0.15.1\src\lib.rs:193:8
|
193 | pub fn hash_with_salt<P: AsRef<[u8]>>(
| ^^^^^^^^^^^^^^
help: consider dereferencing the borrow
|
105 | *b"1234567812345678"
| +For more information about this error, try
rustc --explain E0308
.
csl
Будет ли в проекте рассмотрена проблема отката миграций, как, например, у @Akina тут ?
csl
Offtop: можете в следующих постах прятать часть кода под спойлер