feat: one time password

feat:  json web token
feat:  mail template
This commit is contained in:
Ahmet Kaan GÜMÜŞ 2025-01-11 17:29:18 +03:00
parent c7863c806a
commit bb2b70ccac
16 changed files with 536 additions and 9 deletions

View file

@ -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"] }

View file

@ -1,2 +1,5 @@
[server_config]
address = "localhost:2344"
address = "localhost:2344"
otp_time_limit = 15
login_token_time_limit = 15
concurrency_limit = -1

View file

@ -0,0 +1,3 @@
[one_time_password]
subject = "Your One Time Password"
body = "Dear *, \nHere is your One Time Password = * \n Best Wishes"

View file

@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE IF EXISTS "login";

View file

@ -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)
);

View file

@ -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;

124
src/database/login.rs Normal file
View file

@ -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<Postgres>,
) -> Result<Login, sqlx::Error> {
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<Postgres>,
) -> Result<Login, sqlx::Error> {
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<Postgres>,
) -> Result<Login, sqlx::Error> {
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<Postgres>,
) -> Result<Login, sqlx::Error> {
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<Postgres>,
) -> Result<Vec<Login>, 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<Postgres>,
) -> Result<Vec<Login>, 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<Postgres>,
) -> Result<u64, sqlx::Error> {
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))
}

View file

@ -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()
}
}

View file

@ -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;

62
src/feature/auth.rs Normal file
View file

@ -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<Vec<OneTimePassword>> {
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,
}
}
}

77
src/feature/login.rs Normal file
View file

@ -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<Postgres>,
) -> Result<Login, sqlx::Error> {
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<Postgres>,
) -> Result<Login, sqlx::Error> {
login::read(user_id, token, database_connection).await
}
pub async fn update(
user_id: &i64,
token: &String,
database_connection: &Pool<Postgres>,
) -> Result<Login, sqlx::Error> {
login::update(user_id, token, database_connection).await
}
pub async fn delete(
user_id: &i64,
token: &String,
database_connection: &Pool<Postgres>,
) -> Result<Login, sqlx::Error> {
login::delete(user_id, token, database_connection).await
}
pub async fn read_all_for_user(
user_id: &i64,
database_connection: &Pool<Postgres>,
) -> Result<Vec<Login>, 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<Postgres>,
) -> Result<Vec<Login>, 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<Postgres>,
) -> Result<u64, sqlx::Error> {
login::count_all_for_user(user_id, database_connection).await
}
}

View file

@ -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<ServerConfig> = LazyLock::new(ServerConfig::default);
pub static ONE_TIME_PASSWORDS: LazyLock<RwLock<Vec<OneTimePassword>>> =
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")

View file

@ -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<smtp::response::Response, ForumMailError> {
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,

View file

@ -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<AppState>) -> Router {
pub async fn route(concurrency_limit: &usize, State(app_state): State<AppState>) -> Router {
Router::new()
.route("/", get(alive))
.nest(
@ -70,7 +71,7 @@ pub async fn route(State(app_state): State<AppState>) -> 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)
}

148
src/routing/login.rs Normal file
View file

@ -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<AppState>) -> Router<AppState> {
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<AppState>,
Json(create_login): Json<CreateLogin>,
) -> 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<AppState>,
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<AppState>,
Json(update_role): Json<UpdateLogin>,
) -> 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<AppState>,
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<AppState>,
Path(user_id): Path<i64>,
) -> 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<AppState>,
Path(user_id): Path<i64>,
) -> 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<AppState>,
Path(user_id): Path<i64>,
) -> 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())),
),
}
}

View file

@ -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()