From bb2b70ccac69e402e573083793986afabfed54a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Kaan=20G=C3=9CM=C3=9C=C5=9E?= <96421894+Tahinli@users.noreply.github.com> Date: Sat, 11 Jan 2025 17:29:18 +0300 Subject: [PATCH] feat: :sparkles: one time password feat: :sparkles: json web token feat: :sparkles: mail template --- Cargo.toml | 3 +- configs/server_config.toml | 5 +- mail_templates/one_time_password.toml | 3 + migrations/20250110224901_login.down.sql | 2 + migrations/20250110224901_login.up.sql | 6 + src/database.rs | 1 + src/database/login.rs | 124 +++++++++++++++++++ src/error.rs | 25 ++++ src/feature.rs | 2 + src/feature/auth.rs | 62 ++++++++++ src/feature/login.rs | 77 ++++++++++++ src/lib.rs | 15 +++ src/mail.rs | 57 ++++++++- src/routing.rs | 5 +- src/routing/login.rs | 148 +++++++++++++++++++++++ src/server.rs | 10 +- 16 files changed, 536 insertions(+), 9 deletions(-) create mode 100644 mail_templates/one_time_password.toml create mode 100644 migrations/20250110224901_login.down.sql create mode 100644 migrations/20250110224901_login.up.sql create mode 100644 src/database/login.rs create mode 100644 src/feature/auth.rs create mode 100644 src/feature/login.rs create mode 100644 src/routing/login.rs diff --git a/Cargo.toml b/Cargo.toml index 3077fd6..cb7b021 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,8 @@ strip = "symbols" [dependencies] axum = "0.7.9" chrono = { version = "0.4.39", features = ["serde"] } -lettre = { version = "0.11.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] } +jwt-simple = { version = "0.12.11", default-features = false, features = ["pure-rust"] } +lettre = { version = "0.11.11", default-features = false, features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"] } serde = { version = "1.0.216", features = ["derive"] } serde_json = "1.0.133" sqlx = { version = "0.8.2", features = ["chrono", "macros", "postgres", "runtime-tokio-rustls"] } diff --git a/configs/server_config.toml b/configs/server_config.toml index bf0431a..b7c16ec 100644 --- a/configs/server_config.toml +++ b/configs/server_config.toml @@ -1,2 +1,5 @@ [server_config] -address = "localhost:2344" \ No newline at end of file +address = "localhost:2344" +otp_time_limit = 15 +login_token_time_limit = 15 +concurrency_limit = -1 \ No newline at end of file diff --git a/mail_templates/one_time_password.toml b/mail_templates/one_time_password.toml new file mode 100644 index 0000000..6cdadf6 --- /dev/null +++ b/mail_templates/one_time_password.toml @@ -0,0 +1,3 @@ +[one_time_password] +subject = "Your One Time Password" +body = "Dear *, \nHere is your One Time Password = * \n Best Wishes" \ No newline at end of file diff --git a/migrations/20250110224901_login.down.sql b/migrations/20250110224901_login.down.sql new file mode 100644 index 0000000..3d72a2f --- /dev/null +++ b/migrations/20250110224901_login.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE IF EXISTS "login"; \ No newline at end of file diff --git a/migrations/20250110224901_login.up.sql b/migrations/20250110224901_login.up.sql new file mode 100644 index 0000000..f0fa763 --- /dev/null +++ b/migrations/20250110224901_login.up.sql @@ -0,0 +1,6 @@ +-- Add up migration script here +CREATE TABLE IF NOT EXISTS "login"( + user_id BIGSERIAL NOT NULL REFERENCES "user"(id), + token VARCHAR(1024) NOT NULL, + PRIMARY KEY (user_id, token) +); \ No newline at end of file diff --git a/src/database.rs b/src/database.rs index 83d7671..5add61b 100644 --- a/src/database.rs +++ b/src/database.rs @@ -2,6 +2,7 @@ pub mod comment; pub mod comment_interaction; pub mod contact; pub mod interaction; +pub mod login; pub mod permission; pub mod post; pub mod post_interaction; diff --git a/src/database/login.rs b/src/database/login.rs new file mode 100644 index 0000000..9577ab0 --- /dev/null +++ b/src/database/login.rs @@ -0,0 +1,124 @@ +use sqlx::{Pool, Postgres}; + +use crate::feature::login::Login; + +pub async fn create( + user_id: &i64, + token: &String, + database_connection: &Pool, +) -> Result { + sqlx::query_as!( + Login, + r#" + INSERT INTO "login"(user_id, token) + VALUES ($1, $2) + RETURNING * + "#, + user_id, + token, + ) + .fetch_one(database_connection) + .await +} + +pub async fn read( + user_id: &i64, + token: &String, + database_connection: &Pool, +) -> Result { + sqlx::query_as!( + Login, + r#" + SELECT * FROM "login" WHERE "user_id" = $1 AND "token" = $2 + "#, + user_id, + token + ) + .fetch_one(database_connection) + .await +} + +pub async fn update( + user_id: &i64, + token: &String, + database_connection: &Pool, +) -> Result { + sqlx::query_as!( + Login, + r#" + UPDATE "login" SET "token" = $2 WHERE "user_id" = $1 + RETURNING * + "#, + user_id, + token, + ) + .fetch_one(database_connection) + .await +} + +pub async fn delete( + user_id: &i64, + token: &String, + database_connection: &Pool, +) -> Result { + sqlx::query_as!( + Login, + r#" + DELETE FROM "login" WHERE "user_id" = $1 AND "token" = $2 + RETURNING * + "#, + user_id, + token, + ) + .fetch_one(database_connection) + .await +} + +pub async fn read_all_for_user( + user_id: &i64, + database_connection: &Pool, +) -> Result, sqlx::Error> { + sqlx::query_as!( + Login, + r#" + SELECT * FROM "login" WHERE "user_id" = $1 + "#, + user_id, + ) + .fetch_all(database_connection) + .await +} + +pub async fn delete_all_for_user( + user_id: &i64, + database_connection: &Pool, +) -> Result, sqlx::Error> { + sqlx::query_as!( + Login, + r#" + DELETE FROM "login" WHERE "user_id" = $1 + RETURNING * + "#, + user_id, + ) + .fetch_all(database_connection) + .await +} + +pub async fn count_all_for_user( + user_id: &i64, + database_connection: &Pool, +) -> Result { + sqlx::query!( + r#" + SELECT COUNT(user_id) FROM "login" WHERE "user_id" = $1 + "#, + user_id, + ) + .fetch_one(database_connection) + .await? + .count + .map_or(0, |count| count) + .try_into() + .or(Ok(0)) +} diff --git a/src/error.rs b/src/error.rs index 53db109..2be81ce 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,3 +26,28 @@ impl std::error::Error for ForumInputError { self.source() } } + +#[derive(Debug, Serialize, Deserialize)] +pub enum ForumMailError { + TemplateHeader, + TemplateLackOfParameter, + Send(String), +} + +impl std::fmt::Display for ForumMailError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ForumMailError::TemplateHeader => write!(f, "Template Header is Wrong"), + ForumMailError::TemplateLackOfParameter => { + write!(f, "Template Parameters Are Not Enough") + } + ForumMailError::Send(error) => write!(f, "Sending | {}", error), + } + } +} + +impl std::error::Error for ForumMailError { + fn cause(&self) -> Option<&dyn std::error::Error> { + self.source() + } +} diff --git a/src/feature.rs b/src/feature.rs index 83428c8..549472e 100644 --- a/src/feature.rs +++ b/src/feature.rs @@ -1,7 +1,9 @@ +pub mod auth; pub mod comment; pub mod comment_interaction; pub mod contact; pub mod interaction; +pub mod login; pub mod permission; pub mod post; pub mod post_interaction; diff --git a/src/feature/auth.rs b/src/feature/auth.rs new file mode 100644 index 0000000..55bd888 --- /dev/null +++ b/src/feature/auth.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use crate::{ + error::ForumMailError, + mail::{MailFieldsOneTimePassword, MailTemplate}, + ONE_TIME_PASSWORDS, +}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct OneTimePassword { + pub user_id: i64, + pub one_time_password: String, +} + +impl OneTimePassword { + pub fn init() -> RwLock> { + RwLock::new(vec![]) + } + + pub async fn new( + user_id: &i64, + user_name: &String, + user_email: &String, + ) -> Result<(), ForumMailError> { + let one_time_password = "123".to_owned(); + let new_self = Self { + user_id: *user_id, + one_time_password, + }; + + let mail_template = + MailTemplate::OneTimePassword(MailFieldsOneTimePassword::new(user_name, &new_self)); + + mail_template.send_mail(user_email).await?; + + let mut one_time_passwords = ONE_TIME_PASSWORDS.write().await; + one_time_passwords.push(new_self); + one_time_passwords.sort_by_key(|one_time_password| one_time_password.user_id); + drop(one_time_passwords); + + Ok(()) + } + + pub async fn verify(one_time_password: &OneTimePassword) -> bool { + let one_time_password_search = ONE_TIME_PASSWORDS + .read() + .await + .binary_search_by(|one_time_password_| one_time_password_.cmp(one_time_password)); + match one_time_password_search { + Ok(one_time_password_index) => { + let mut one_time_passwords = ONE_TIME_PASSWORDS.write().await; + one_time_passwords.swap_remove(one_time_password_index); + one_time_passwords.sort_by_key(|one_time_password| one_time_password.user_id); + drop(one_time_passwords); + + true + } + Err(_) => false, + } + } +} diff --git a/src/feature/login.rs b/src/feature/login.rs new file mode 100644 index 0000000..d6a750a --- /dev/null +++ b/src/feature/login.rs @@ -0,0 +1,77 @@ +use jwt_simple::{ + claims::Claims, + common::VerificationOptions, + prelude::{HS256Key, MACLike}, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Postgres}; + +use crate::{database::login, SERVER_CONFIG}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Login { + pub user_id: i64, + pub token: String, +} + +impl Login { + pub async fn create( + user_id: &i64, + database_connection: &Pool, + ) -> Result { + let key = HS256Key::generate(); + let claims = Claims::create(jwt_simple::prelude::Duration::from_mins( + SERVER_CONFIG.login_token_time_limit as u64, + )); + let mut verification_options = VerificationOptions::default(); + verification_options.time_tolerance = Some(jwt_simple::prelude::Duration::from(0)); + + let token = key.authenticate(claims).unwrap(); + + login::create(user_id, &token, database_connection).await + } + + pub async fn read( + user_id: &i64, + token: &String, + database_connection: &Pool, + ) -> Result { + login::read(user_id, token, database_connection).await + } + + pub async fn update( + user_id: &i64, + token: &String, + database_connection: &Pool, + ) -> Result { + login::update(user_id, token, database_connection).await + } + pub async fn delete( + user_id: &i64, + token: &String, + database_connection: &Pool, + ) -> Result { + login::delete(user_id, token, database_connection).await + } + + pub async fn read_all_for_user( + user_id: &i64, + database_connection: &Pool, + ) -> Result, sqlx::Error> { + login::read_all_for_user(user_id, database_connection).await + } + + pub async fn delete_all_for_user( + user_id: &i64, + database_connection: &Pool, + ) -> Result, sqlx::Error> { + login::delete_all_for_user(user_id, database_connection).await + } + + pub async fn count_all_for_user( + user_id: &i64, + database_connection: &Pool, + ) -> Result { + login::count_all_for_user(user_id, database_connection).await + } +} diff --git a/src/lib.rs b/src/lib.rs index c1ec548..f5d5727 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,9 +6,17 @@ pub mod routing; pub mod server; pub mod utils; +use std::sync::LazyLock; + +use feature::auth::OneTimePassword; use sqlx::{Pool, Postgres}; +use tokio::sync::RwLock; use utils::naive_toml_parser; +pub static SERVER_CONFIG: LazyLock = LazyLock::new(ServerConfig::default); +pub static ONE_TIME_PASSWORDS: LazyLock>> = + LazyLock::new(OneTimePassword::init); + const DATABASE_CONFIG_FILE_LOCATION: &str = "./configs/database_config.toml"; const SERVER_CONFIG_FILE_LOCATION: &str = "./configs/server_config.toml"; @@ -43,15 +51,22 @@ impl Default for DatabaseConfig { #[derive(Debug)] pub struct ServerConfig { pub address: String, + pub otp_time_limit: usize, + pub login_token_time_limit: usize, + pub concurrency_limit: usize, } impl Default for ServerConfig { fn default() -> Self { let (header, mut server_configs) = naive_toml_parser(SERVER_CONFIG_FILE_LOCATION); + let value_or_max = |value: String| value.parse().map_or(usize::MAX, |value| value); if header == "[server_config]" { Self { address: server_configs.pop_front().unwrap().parse().unwrap(), + otp_time_limit: value_or_max(server_configs.pop_front().unwrap()), + login_token_time_limit: value_or_max(server_configs.pop_front().unwrap()), + concurrency_limit: value_or_max(server_configs.pop_front().unwrap()), } } else { panic!("Server Config File Must Include [server_config] at the First Line") diff --git a/src/mail.rs b/src/mail.rs index 167602c..69f9980 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -4,9 +4,11 @@ use lettre::{ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, }; -use crate::utils::naive_toml_parser; +use crate::{error::ForumMailError, feature::auth::OneTimePassword, utils::naive_toml_parser}; const MAIL_CONFIG_FILE_LOCATION: &str = "./configs/mail_config.toml"; +const ONE_TIME_PASSWORD_MAIL_TEMPLATE_FILE_LOCATION: &str = + "./mail_templates/one_time_password.toml"; pub struct MailConfig { name: String, @@ -38,7 +40,58 @@ impl Default for MailConfig { } } -pub async fn send_mail( +pub struct MailFieldsOneTimePassword { + receiver_name: String, + one_time_password: OneTimePassword, +} + +impl MailFieldsOneTimePassword { + pub fn new(receiver_name: &String, one_time_password: &OneTimePassword) -> Self { + Self { + receiver_name: receiver_name.to_owned(), + one_time_password: one_time_password.to_owned(), + } + } +} +pub enum MailTemplate { + OneTimePassword(MailFieldsOneTimePassword), +} + +impl MailTemplate { + pub async fn send_mail( + &self, + receiver: &String, + ) -> Result { + match self { + MailTemplate::OneTimePassword(mail_fields) => { + let mut mail_template_from_file = + naive_toml_parser(ONE_TIME_PASSWORD_MAIL_TEMPLATE_FILE_LOCATION); + if mail_template_from_file.0 == "one_time_password" { + let subject = match mail_template_from_file.1.pop_front() { + Some(subject) => subject, + None => return Err(ForumMailError::TemplateLackOfParameter), + }; + let body = match mail_template_from_file.1.pop_front() { + Some(body) => body, + None => return Err(ForumMailError::TemplateLackOfParameter), + }; + + let body = body.replacen('*', &mail_fields.receiver_name, 1); + let body = + body.replacen('*', &mail_fields.one_time_password.one_time_password, 1); + + send_mail(receiver, &subject, &body) + .await + .map_err(|error| ForumMailError::Send(error.to_string())) + } else { + Err(ForumMailError::TemplateHeader) + } + } + } + } +} + +async fn send_mail( receiver: &String, subject: &String, body: &String, diff --git a/src/routing.rs b/src/routing.rs index af9e294..68e8847 100644 --- a/src/routing.rs +++ b/src/routing.rs @@ -2,6 +2,7 @@ pub mod comment; pub mod comment_interaction; pub mod contact; pub mod interaction; +pub mod login; pub mod permission; pub mod post; pub mod post_interaction; @@ -18,7 +19,7 @@ use tower_http::cors::CorsLayer; use crate::{database, AppState}; -pub async fn route(State(app_state): State) -> Router { +pub async fn route(concurrency_limit: &usize, State(app_state): State) -> Router { Router::new() .route("/", get(alive)) .nest( @@ -70,7 +71,7 @@ pub async fn route(State(app_state): State) -> Router { routing_permission::route(axum::extract::State(app_state.clone())), ) .layer(CorsLayer::permissive()) - .layer(ConcurrencyLimitLayer::new(100)) + .layer(ConcurrencyLimitLayer::new(*concurrency_limit)) .with_state(app_state) } diff --git a/src/routing/login.rs b/src/routing/login.rs new file mode 100644 index 0000000..1e118f8 --- /dev/null +++ b/src/routing/login.rs @@ -0,0 +1,148 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, patch, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + feature::{auth::OneTimePassword, login::Login}, + AppState, +}; + +#[derive(Debug, Serialize, Deserialize)] +struct CreateLogin { + pub one_time_password: OneTimePassword, +} + +#[derive(Debug, Serialize, Deserialize)] +struct UpdateLogin { + pub user_id: i64, + pub token: String, +} + +pub fn route(State(app_state): State) -> Router { + Router::new() + .route("/", post(create)) + .route("/users/:user_id/token/:token", get(read)) + .route("/", patch(update)) + .route("/users/:user_id/token/:token", delete(delete_)) + .route("/users/:user_id", get(read_all_for_user)) + .route("/users/:user_id", delete(delete_all_for_user)) + .route("/count/users/:user_id", get(count_all_for_user)) + .with_state(app_state) +} + +async fn create( + State(app_state): State, + Json(create_login): Json, +) -> impl IntoResponse { + match OneTimePassword::verify(&create_login.one_time_password).await { + true => { + match Login::create( + &create_login.one_time_password.user_id, + &app_state.database_connection, + ) + .await + { + Ok(login) => (StatusCode::CREATED, Json(serde_json::json!(login))), + Err(err_val) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!(err_val.to_string())), + ), + } + } + false => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!( + "One Time Password Authentication Failed".to_string() + )), + ), + } +} + +async fn read( + State(app_state): State, + Path((user_id, token)): Path<(i64, String)>, +) -> impl IntoResponse { + match Login::read(&user_id, &token, &app_state.database_connection).await { + Ok(login) => (StatusCode::OK, Json(serde_json::json!(login))), + Err(err_val) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!(err_val.to_string())), + ), + } +} + +async fn update( + State(app_state): State, + Json(update_role): Json, +) -> impl IntoResponse { + match Login::update( + &update_role.user_id, + &update_role.token, + &app_state.database_connection, + ) + .await + { + Ok(login) => (StatusCode::ACCEPTED, Json(serde_json::json!(login))), + Err(err_val) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!(err_val.to_string())), + ), + } +} + +async fn delete_( + State(app_state): State, + Path((user_id, token)): Path<(i64, String)>, +) -> impl IntoResponse { + match Login::delete(&user_id, &token, &app_state.database_connection).await { + Ok(login) => (StatusCode::NO_CONTENT, Json(serde_json::json!(login))), + Err(err_val) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!(err_val.to_string())), + ), + } +} + +async fn read_all_for_user( + State(app_state): State, + Path(user_id): Path, +) -> impl IntoResponse { + match Login::read_all_for_user(&user_id, &app_state.database_connection).await { + Ok(logins) => (StatusCode::OK, Json(serde_json::json!(logins))), + Err(err_val) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!(err_val.to_string())), + ), + } +} + +async fn delete_all_for_user( + State(app_state): State, + Path(user_id): Path, +) -> impl IntoResponse { + match Login::delete_all_for_user(&user_id, &app_state.database_connection).await { + Ok(logins) => (StatusCode::OK, Json(serde_json::json!(logins))), + Err(err_val) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!(err_val.to_string())), + ), + } +} + +async fn count_all_for_user( + State(app_state): State, + Path(user_id): Path, +) -> impl IntoResponse { + match Login::count_all_for_user(&user_id, &app_state.database_connection).await { + Ok(login_count) => (StatusCode::OK, Json(serde_json::json!(login_count))), + Err(err_val) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!(err_val.to_string())), + ), + } +} diff --git a/src/server.rs b/src/server.rs index 8d46e11..187be6f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,11 +1,15 @@ use tokio::net::TcpListener; -use crate::{AppState, ServerConfig}; +use crate::{AppState, SERVER_CONFIG}; pub async fn start_server(app_state: AppState) { - let server_config = ServerConfig::default(); + let server_config = &SERVER_CONFIG; - let router = crate::routing::route(axum::extract::State(app_state)).await; + let router = crate::routing::route( + &server_config.concurrency_limit, + axum::extract::State(app_state), + ) + .await; let listener = TcpListener::bind(&server_config.address).await.unwrap(); println!("\n\thttp://{}", server_config.address); axum::serve(listener, router).await.unwrap()