diff --git a/.gitignore b/.gitignore index 96fab4f..aac004f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ yarn-error.log* # Misc .DS_Store *.pem +Notes \ No newline at end of file diff --git a/latent-backend/.env.example b/latent-backend/.env.example index a7399dc..2274625 100644 --- a/latent-backend/.env.example +++ b/latent-backend/.env.example @@ -1,5 +1,4 @@ DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432 -TWILIO_AUTH_TOKEN="<>" -TWILIO_ACCOUNT_SID="<>" -TWILIO_PHONE_NUMBER="<>" -ADMIN_JWT_PASSWORD="<>" \ No newline at end of file +GUPSHUP_URL="http://enterprise.smsgupshup.com/GatewayAPI/rest" +GUPSHUP_UID="" +GUPSHUP_PASS="" \ No newline at end of file diff --git a/latent-backend/README.md b/latent-backend/README.md index 53933b8..3e33897 100644 --- a/latent-backend/README.md +++ b/latent-backend/README.md @@ -2,6 +2,17 @@ - Clone the repo - Copy .env.example to .env - Start postgres locally + + ``` +docker run -d \ + --name postgres-container \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=mysecretpassword \ + -e POSTGRES_DB=latent \ + -p 5432:5432 \ + postgres:latest + + ``` - Migrate Postgres ``` psql "postgres://postgres:mysecretpassword@localhost:5432/" diff --git a/latent-backend/api/src/error.rs b/latent-backend/api/src/error.rs index f2b9cc9..eafa619 100644 --- a/latent-backend/api/src/error.rs +++ b/latent-backend/api/src/error.rs @@ -1,4 +1,7 @@ -use poem_openapi::{payload::Json, ApiResponse, Object}; +use poem_openapi::{ + payload::{self, Json}, + ApiResponse, Object, +}; #[derive(Debug, Object)] pub struct ErrorBody { @@ -30,6 +33,14 @@ pub enum AppError { /// Bad request (400) #[oai(status = 400)] BadRequest(Json), + + // Too Many Requests + #[oai(status = 429)] + RateLimitted(Json), + + // Any 3rd Party API Call Error + #[oai(status = 500)] + NetworkError(Json), } impl From for AppError { @@ -38,9 +49,26 @@ impl From for AppError { sqlx::Error::RowNotFound => AppError::NotFound(Json(ErrorBody { message: "Resource not found".to_string(), })), - _ => AppError::Database(Json(ErrorBody { - message: "Database error occurred".to_string(), + + e => AppError::Database(Json(ErrorBody { + // message: "Database error occurred".to_string(), + message: format!("{:?}", e.to_string()), })), } } } + +impl From for AppError { + fn from(err: reqwest::Error) -> Self { + if let Some(status) = err.status() { + if status != reqwest::StatusCode::OK { + return AppError::NetworkError(payload::Json(ErrorBody { + message: "Cannot send OTP now. Please try again later.".to_string(), + })); + } + } + AppError::InternalServerError(payload::Json(ErrorBody { + message: "Somthing went wrong".to_string(), + })) + } +} diff --git a/latent-backend/api/src/main.rs b/latent-backend/api/src/main.rs index 74b6e9e..df7d4db 100644 --- a/latent-backend/api/src/main.rs +++ b/latent-backend/api/src/main.rs @@ -4,18 +4,21 @@ use poem::{ EndpointExt, Route, Server, }; use poem_openapi::OpenApiService; +use services::sms_service::SmsService; use std::{env, sync::Arc}; mod error; mod routes; mod utils; mod middleware; +mod services; use db::Db; use dotenv::dotenv; #[derive(Clone)] pub struct AppState { + sms_service: Arc, db: Arc, } @@ -34,18 +37,17 @@ async fn main() -> Result<(), std::io::Error> { db.init().await.expect("Failed to initialize database"); let db = Arc::new(db); + // Injecting the sms service + let sms_service = Arc::new(SmsService::default()); + // API services - let user_api_service = OpenApiService::new(routes::user::user::UserApi, "Latent Booking API", "1.0") - .server(format!("{}/user", server_url)); + let user_api_service = OpenApiService::new(routes::user::user::UserApi, "Latent Booking API", "1.0"); - let admin_api_service = OpenApiService::new(routes::admin::admin::AdminApi, "Admin Latent Booking API", "1.0") - .server(format!("{}/admin", server_url)); + let admin_api_service = OpenApiService::new(routes::admin::admin::AdminApi, "Admin Latent Booking API", "1.0"); - let location_api_service = OpenApiService::new(routes::admin::location::LocationApi, "Location Latent Booking API", "1.0") - .server(format!("{}/location", server_url)); + let location_api_service = OpenApiService::new(routes::admin::location::LocationApi, "Location Latent Booking API", "1.0"); - let event_api_service = OpenApiService::new(routes::admin::event::EventApi, "Location Latent Booking API", "1.0") - .server(format!("{}/event", server_url)); + let event_api_service = OpenApiService::new(routes::admin::event::EventApi, "Location Latent Booking API", "1.0"); // Swagger UI for each API group let user_ui = user_api_service.swagger_ui(); @@ -74,7 +76,7 @@ async fn main() -> Result<(), std::io::Error> { } // middleware and shared state - let app = app.with(Cors::new()).data(AppState { db }); + let app = app.with(Cors::new()).data(AppState { sms_service, db }); println!("Server running at http://localhost:{}", port); println!("User API docs available at http://localhost:{}/docs/user", port); diff --git a/latent-backend/api/src/routes/admin/admin.rs b/latent-backend/api/src/routes/admin/admin.rs index 734684d..f361ed2 100644 --- a/latent-backend/api/src/routes/admin/admin.rs +++ b/latent-backend/api/src/routes/admin/admin.rs @@ -3,7 +3,7 @@ use std::env; use log::info; use poem::web::{Data, Json}; use poem_openapi::{OpenApi, Object, payload}; -use crate::{error::AppError, AppState, utils::{totp, twilio}}; +use crate::{error::AppError, AppState}; use serde::{Deserialize, Serialize}; use jsonwebtoken::{encode, Header}; @@ -54,15 +54,11 @@ impl AdminApi { let admin = state.db.create_admin(number.clone()).await?; // Generate and send OTP - let otp = totp::get_token(&number, "SUPERADMIN"); - if cfg!(not(debug_assertions)) { - twilio::send_message(&format!("Your OTP for signing up to Latent is {}", otp), &number) - .await - .map_err(|_| AppError::InternalServerError(payload::Json(crate::error::ErrorBody { - message: "Failed to send OTP".to_string(), - })))?; - } else { - println!("Development mode: OTP is {}", otp); + let otp = state.sms_service.generate_otp(&number, "AUTH").await; + if cfg!(not(debug_assertions)){ + let _ = state.sms_service.send_otp(state, number, otp).await?; + }else { + println!("Development Mode OTP is {}", otp) } let admin_secret = env::var("ADMIN_JWT_PASSWORD").unwrap_or_else(|_| "admin".to_string()); @@ -99,7 +95,7 @@ impl AdminApi { // Verify OTP if cfg!(not(debug_assertions)) { - if !totp::verify_token(&number, "AUTH", &otp) { + if !state.sms_service.verify_otp(&number, "AUTH", &otp).await { return Err(AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { message: "Invalid OTP".to_string(), }))); diff --git a/latent-backend/api/src/routes/user/user.rs b/latent-backend/api/src/routes/user/user.rs index 4c80319..316c067 100644 --- a/latent-backend/api/src/routes/user/user.rs +++ b/latent-backend/api/src/routes/user/user.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use poem::web::{Data, Json}; use poem_openapi::{OpenApi, Object, payload}; -use crate::{error::AppError, AppState, utils::{totp, twilio}}; +use crate::{error::AppError, AppState}; #[derive(Debug, Serialize, Deserialize, Object)] @@ -64,25 +64,13 @@ impl UserApi { let number = body.0.number; let user = state.db.create_user(number.clone()).await?; - // Generate and send OTP - let otp = totp::get_token(&number, "AUTH"); - if cfg!(not(debug_assertions)) { - twilio::send_message(&format!("Your OTP for signing up to Latent is {}", otp), &number) - .await - .map_err(|_| AppError::InternalServerError(payload::Json(crate::error::ErrorBody { - message: "Failed to send OTP".to_string(), - })))?; - } else { - println!("Development mode: OTP is {}", otp); + let otp = state.sms_service.generate_otp(&number, "AUTH").await; + if cfg!(not(debug_assertions)){ + let _ = state.sms_service.send_otp(state, number, otp).await?; + }else { + println!("Development Mode OTP is {}", otp) } - - - twilio::send_message(&format!("Your OTP for signing up to Latent is {}", otp), &number) - .await - .map_err(|_| AppError::InternalServerError(payload::Json(crate::error::ErrorBody { - message: "Failed to send OTP".to_string(), - })))?; - + Ok(payload::Json(CreateUserResponse { message: "User created successfully".to_string(), id: user.id.to_string(), @@ -100,7 +88,7 @@ impl UserApi { // Verify OTP if cfg!(not(debug_assertions)) { - if !totp::verify_token(&number, "AUTH", &otp) { + if !state.sms_service.verify_otp(&number, "AUTH", &otp).await { return Err(AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { message: "Invalid OTP".to_string(), }))); @@ -124,15 +112,11 @@ impl UserApi { let _user = state.db.get_user_by_number(&number).await?; // Generate and send OTP - let otp = totp::get_token(&number, "AUTH"); - if cfg!(not(debug_assertions)) { - twilio::send_message(&format!("Your OTP for signing in to Latent is {}", otp), &number) - .await - .map_err(|_| AppError::InternalServerError(payload::Json(crate::error::ErrorBody { - message: "Failed to send OTP".to_string(), - })))?; - } else { - println!("Development mode: OTP is {}", otp); + let otp = state.sms_service.generate_otp(&number, "AUTH").await; + if cfg!(not(debug_assertions)){ + let _ = state.sms_service.send_otp(state, number, otp).await?; + }else { + println!("Development Mode OTP is {}", otp) } Ok(payload::Json(SignInResponse { @@ -151,7 +135,7 @@ impl UserApi { // Verify OTP if cfg!(not(debug_assertions)) { - if !totp::verify_token(&number, "AUTH", &otp) { + if !state.sms_service.verify_otp(&number, "AUTH", &otp).await { return Err(AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { message: "Invalid OTP".to_string(), }))); diff --git a/latent-backend/api/src/services/mod.rs b/latent-backend/api/src/services/mod.rs new file mode 100644 index 0000000..02f91d8 --- /dev/null +++ b/latent-backend/api/src/services/mod.rs @@ -0,0 +1 @@ +pub mod sms_service; \ No newline at end of file diff --git a/latent-backend/api/src/services/sms_service.rs b/latent-backend/api/src/services/sms_service.rs new file mode 100644 index 0000000..0112f36 --- /dev/null +++ b/latent-backend/api/src/services/sms_service.rs @@ -0,0 +1,119 @@ +use chrono::{Duration, TimeZone, Utc}; +use poem::web::Data; +use poem_openapi::payload; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::{ + env, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::{error::AppError, AppState}; +use sqlx::Error; + +const TIME_STEP: u64 = 30; // 30 seconds + +#[derive(Serialize)] +struct SmsParams<'a> { + userid: &'a str, + password: &'a str, + send_to: &'a str, + msg: &'a str, + method: &'a str, + msg_type: &'a str, + format: &'a str, + auth_scheme: &'a str, + v: &'a str, +} + +pub struct SmsService { + client: reqwest::Client, + gupshup_url: String, + gupshup_uid: String, + gupshup_pass: String, +} + +impl Default for SmsService { + fn default() -> Self { + Self { + client: reqwest::Client::new(), + gupshup_url: env::var("GUPSHUP_URL").unwrap(), + gupshup_uid: env::var("GUPSHUP_UID").unwrap(), + gupshup_pass: env::var("GUPSHUP_PASS").unwrap(), + } + } +} + +impl SmsService { + pub async fn send_otp( + &self, + state: Data<&AppState>, + number: String, + otp: String, + ) -> Result<(), AppError> { + if !self.can_send_otp(&state, &number).await? { + return Err(AppError::RateLimitted(payload::Json( + crate::error::ErrorBody { + message: "Too Many Requests".to_string(), + }, + ))); + } + + let params = SmsParams { + userid: &self.gupshup_uid, + password: &self.gupshup_pass, + send_to: &number, + msg: &format!("Your OTP for the Latent app is {otp}"), + method: "sendMessage", + msg_type: "text", + format: "json", + auth_scheme: "plain", + v: "1.1", + }; + + let _ = self.client.post(&self.gupshup_url).form(¶ms).header("Content-Type", "application/x-www-form-urlencoded").send().await?; + let _ = state.db.update_otpc_by_number(&number).await?; + + Ok(()) + } + + pub async fn can_send_otp(&self, state: &Data<&AppState>, number: &str) -> Result { + let user = state.db.get_user_by_number(&number).await?; + let updated_at_utc = Utc.from_utc_datetime(&user.updated_at); + if user.otp_request_count > 4 + && Utc::now().signed_duration_since(updated_at_utc) < Duration::minutes(30) + { + return Ok(false); + } + Ok(true) + } + + pub async fn generate_otp(&self, key: &str, salt: &str) -> String { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let counter = timestamp / TIME_STEP; + + let input = format!("{}{}{}", key, salt, counter); + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + + let offset = (result[result.len() - 1] & 0xf) as usize; + let code = ((result[offset] & 0x7f) as u32) << 24 + | (result[offset + 1] as u32) << 16 + | (result[offset + 2] as u32) << 8 + | (result[offset + 3] as u32); + + format!("{:0>6}", code % 1_000_000) // Always returns 6 digits + } + + pub async fn verify_otp(&self, key: &str, salt: &str, token: &str) -> bool { + if token.len() != 6 { + return false; + } + let current = self.generate_otp(key, salt).await; + token == current + } +} \ No newline at end of file diff --git a/latent-backend/db/migrations/20240124_init.sql b/latent-backend/db/migrations/001_20240124_init.sql similarity index 100% rename from latent-backend/db/migrations/20240124_init.sql rename to latent-backend/db/migrations/001_20240124_init.sql diff --git a/latent-backend/db/migrations/002_20250201_opt_ratelimiter.sql b/latent-backend/db/migrations/002_20250201_opt_ratelimiter.sql new file mode 100644 index 0000000..bad052a --- /dev/null +++ b/latent-backend/db/migrations/002_20250201_opt_ratelimiter.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD COLUMN otp_request_count INT DEFAULT 0, +ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; \ No newline at end of file diff --git a/latent-backend/db/src/user.rs b/latent-backend/db/src/user.rs index 34732a8..f6fd890 100644 --- a/latent-backend/db/src/user.rs +++ b/latent-backend/db/src/user.rs @@ -1,16 +1,19 @@ use crate::Db; +use chrono::NaiveDateTime; use log::info; use serde::{Deserialize, Serialize}; use sqlx::Error; use sqlx::FromRow; use uuid::Uuid; -#[derive(FromRow, Serialize, Deserialize)] +#[derive(FromRow, Serialize, Deserialize, Debug)] pub struct User { pub id: Uuid, pub number: String, pub name: String, pub verified: bool, + pub otp_request_count: i32, + pub updated_at: NaiveDateTime } #[derive(sqlx::Type, Serialize, Deserialize)] @@ -89,4 +92,11 @@ impl Db { info!("Signin verified for user with id: {}", user.id); Ok(user.id.to_string()) } + + pub async fn update_otpc_by_number(&self, phone_number: &str) -> Result { + info!("Updating OTP count for number: {}", phone_number); + + let _ = sqlx::query!("UPDATE users SET otp_request_count = otp_request_count + 1, updated_at = CURRENT_TIMESTAMP WHERE number = $1", phone_number).execute(&self.client).await?; + Ok(true) + } }