diff --git a/.dockerignore.yml b/.dockerignore.yml deleted file mode 100644 index 4f436f8..0000000 --- a/.dockerignore.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Ignore build artifacts -target/ -pkg/ - -# Ignore git directory -.git/ - -.gitea/ - -# Ignore environment files (configure via docker-compose instead) -.env* - -# Ignore OS specific files -*.DS_Store - -# Add any other files/directories you don't need in the image -# e.g., logs/, tmp/ diff --git a/Cargo.lock b/Cargo.lock index c999c73..d4ab5d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "textsender_auth" -version = "0.1.14" +version = "0.1.15" dependencies = [ "argon2", "async-std", diff --git a/Cargo.toml b/Cargo.toml index 6128aaf..3cc1795 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textsender_auth" -version = "0.1.14" +version = "0.1.15" edition = "2024" rust-version = "1.95" diff --git a/src/callers/login.rs b/src/callers/login.rs new file mode 100644 index 0000000..83e6cff --- /dev/null +++ b/src/callers/login.rs @@ -0,0 +1,162 @@ +use crate::hashing; +use crate::repo; +use crate::token_stuff; + +pub mod request { + use serde::Deserialize; + #[derive(Default, Deserialize, utoipa::ToSchema)] + pub struct LoginRequest { + pub username: String, + pub password: String, + } +} + +pub mod response { + use serde::{Deserialize, Serialize}; + #[derive(Default, Deserialize, Serialize, utoipa::ToSchema)] + pub struct LoginResponse { + pub message: String, + pub data: Vec, + } + + pub async fn extract( + response: axum::response::Response, + ) -> Result { + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let _parsed_body: LoginResponse = serde_json::from_slice(&body).unwrap(); + todo!("Add code to convert axum::Response to this type"); + } +} + +/// Endpoint for a user login +#[utoipa::path( + post, + path = super::endpoints::LOGIN, + request_body( + content = request::LoginRequest, + description = "Data required for a user to lgoin", + content_type = "application/json" + ), + responses( + (status = 201, description = "User login successful", body = response::LoginResponse), + (status = 400, description = "Bad data", body = response::LoginResponse), + (status = 500, description = "Something went wrong", body = response::LoginResponse) + ) +)] +pub async fn user_login( + axum::Extension(pool): axum::Extension, + axum::Json(payload): axum::Json, +) -> (axum::http::StatusCode, axum::Json) { + if payload.username.is_empty() || payload.password.is_empty() { + let reason = if payload.username.is_empty() { + String::from("Username not provided") + } else { + String::from("Password not provided") + }; + + ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(response::LoginResponse { + message: reason, + data: Vec::new(), + }), + ) + } else { + match repo::user::exists(&pool, &payload.username).await { + Ok(exists) => { + if !exists { + println!("User does not exists"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::LoginResponse { + message: String::from("Unable to login"), + data: Vec::new(), + }), + ) + } else { + let user = match repo::user::get(&pool, &payload.username).await { + Ok(user) => user, + Err(_err) => { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::LoginResponse { + message: String::from("Unable to login"), + data: Vec::new(), + }), + ); + } + }; + let hashed_password = user.password.clone(); + + match hashing::verify_password(&payload.password, hashed_password) { + Ok(matches) => { + if matches { + // Create token + let key = textsender_models::envy::environment::get_secret_key() + .await + .value; + let (token_literal, duration) = + token_stuff::create_token(&key, &user.id).unwrap(); + + if token_stuff::verify_token(&key, &token_literal) { + let current_time = time::OffsetDateTime::now_utc(); + let _ = + repo::user::update_last_login(&pool, &user, ¤t_time) + .await; + + ( + axum::http::StatusCode::OK, + axum::Json(response::LoginResponse { + message: String::from("Successful"), + data: vec![textsender_models::token::LoginResult { + user_id: user.id, + access_token: token_literal, + token_type: String::from( + textsender_models::token::TOKEN_TYPE, + ), + issued_at: duration, + ..Default::default() + }], + }), + ) + } else { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::LoginResponse { + message: String::from("Invalid attempt"), + data: Vec::new(), + }), + ) + } + } else { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::LoginResponse { + message: String::from("Invalid attempt"), + data: Vec::new(), + }), + ) + } + } + Err(err) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::LoginResponse { + message: err.to_string(), + data: Vec::new(), + }), + ), + } + } + } + Err(err) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::LoginResponse { + message: err.to_string(), + data: Vec::new(), + }), + ), + } + } +} diff --git a/src/callers/mod.rs b/src/callers/mod.rs index ab14499..c561eea 100644 --- a/src/callers/mod.rs +++ b/src/callers/mod.rs @@ -1,8 +1,11 @@ pub mod common; +pub mod login; pub mod register; pub mod endpoints { pub const ROOT: &str = "/"; pub const REGISTER: &str = "/api/v1/register"; + /// Endpoint for a user to login + pub const LOGIN: &str = "/api/v1/login"; pub const DBTEST: &str = "/api/v1/test/db"; } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e4bd359 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,119 @@ +pub mod callers; +pub mod config; +pub mod db; +pub mod hashing; +pub mod repo; +pub mod token_stuff; + +pub mod init { + use axum::{ + Router, + routing::{get, post}, + }; + use utoipa::OpenApi; + + use super::callers; + use callers::common as common_callers; + use callers::login as login_caller; + use callers::register as register_caller; + use login_caller::response as login_responses; + use register_caller::response as register_responses; + + #[derive(utoipa::OpenApi)] + #[openapi( + paths( + common_callers::endpoint::db_ping, common_callers::endpoint::root, + register_caller::register_user, login_caller::user_login, + ), + components(schemas(common_callers::response::TestResult, + register_responses::Response, login_responses::LoginResponse)), + tags( + (name = "TextSender Auth API", description = "Auth API for TextSender API") + ) + )] + struct ApiDoc; + + mod cors { + pub async fn configure_cors() -> tower_http::cors::CorsLayer { + // Start building the CORS layer with common settings + let cors = tower_http::cors::CorsLayer::new() + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::DELETE, + ]) // Specify allowed methods:cite[2] + .allow_headers([ + axum::http::header::CONTENT_TYPE, + axum::http::header::AUTHORIZATION, + ]) // Specify allowed headers:cite[2] + .allow_credentials(true) // If you need to send cookies or authentication headers:cite[2] + .max_age(std::time::Duration::from_secs(3600)); // Cache the preflight response for 1 hour:cite[2] + + // Dynamically set the allowed origin based on the environment + match std::env::var(textsender_models::envy::keys::APP_ENV).as_deref() { + Ok("production") => { + let allowed_origins_env = + textsender_models::envy::environment::get_allowed_origins().await; + match textsender_models::envy::utility::delimitize(&allowed_origins_env) { + Ok(alwd) => { + let allowed_origins: Vec = alwd + .into_iter() + .map(|s| s.parse::().unwrap()) + .collect(); + cors.allow_origin(allowed_origins) + } + Err(err) => { + eprintln!( + "Could not parse out allowed origins from env: Error: {err:?}" + ); + std::process::exit(-1); + } + } + } + _ => { + // Development (default): Allow localhost origins + cors.allow_origin(vec![ + "http://localhost:4200".parse().unwrap(), + "http://127.0.0.1:4200".parse().unwrap(), + ]) + } + } + } + } + + pub async fn routes() -> Router { + // build our application with a route + Router::new() + .route( + callers::endpoints::DBTEST, + get(callers::common::endpoint::db_ping), + ) + .route( + callers::endpoints::ROOT, + get(callers::common::endpoint::root), + ) + .route( + callers::endpoints::REGISTER, + post(callers::register::register_user), + ) + .route(callers::endpoints::LOGIN, post(callers::login::user_login)) + .layer(cors::configure_cors().await) + } + + pub async fn app() -> Router { + let pool = super::db::init::create_pool() + .await + .expect("Failed to create pool"); + + super::db::init::migrations(&pool).await; + + routes() + .await + .merge( + utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") + .url("/api-docs/openapi.json", ApiDoc::openapi()), + ) + .layer(axum::Extension(pool)) + } +} diff --git a/src/main.rs b/src/main.rs index ca94b2a..026e22a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,129 +1,12 @@ -pub mod callers; -pub mod config; -pub mod db; -pub mod hashing; -pub mod repo; -pub mod token_stuff; - #[tokio::main] async fn main() { // initialize tracing tracing_subscriber::fmt::init(); - let app = init::app().await; + let app = textsender_auth::init::app().await; // run our app with hyper, listening globally on port 9080 - let url = config::get_full(); + let url = textsender_auth::config::get_full(); let listener = tokio::net::TcpListener::bind(url).await.unwrap(); axum::serve(listener, app).await.unwrap(); } - -mod init { - use axum::{ - Router, - routing::{get, post}, - }; - use utoipa::OpenApi; - - use super::callers; - use callers::common as common_callers; - use callers::register as register_caller; - use register_caller::response as register_responses; - - #[derive(utoipa::OpenApi)] - #[openapi( - paths( - common_callers::endpoint::db_ping, common_callers::endpoint::root, - register_caller::register_user, - ), - components(schemas(common_callers::response::TestResult, - register_responses::Response)), - tags( - (name = "TextSender Auth API", description = "Auth API for TextSender API") - ) - )] - struct ApiDoc; - - mod cors { - pub async fn configure_cors() -> tower_http::cors::CorsLayer { - // Start building the CORS layer with common settings - let cors = tower_http::cors::CorsLayer::new() - .allow_methods([ - axum::http::Method::GET, - axum::http::Method::POST, - axum::http::Method::PUT, - axum::http::Method::DELETE, - ]) // Specify allowed methods:cite[2] - .allow_headers([ - axum::http::header::CONTENT_TYPE, - axum::http::header::AUTHORIZATION, - ]) // Specify allowed headers:cite[2] - .allow_credentials(true) // If you need to send cookies or authentication headers:cite[2] - .max_age(std::time::Duration::from_secs(3600)); // Cache the preflight response for 1 hour:cite[2] - - // Dynamically set the allowed origin based on the environment - match std::env::var(textsender_models::envy::keys::APP_ENV).as_deref() { - Ok("production") => { - let allowed_origins_env = - textsender_models::envy::environment::get_allowed_origins().await; - match textsender_models::envy::utility::delimitize(&allowed_origins_env) { - Ok(alwd) => { - let allowed_origins: Vec = alwd - .into_iter() - .map(|s| s.parse::().unwrap()) - .collect(); - cors.allow_origin(allowed_origins) - } - Err(err) => { - eprintln!( - "Could not parse out allowed origins from env: Error: {err:?}" - ); - std::process::exit(-1); - } - } - } - _ => { - // Development (default): Allow localhost origins - cors.allow_origin(vec![ - "http://localhost:4200".parse().unwrap(), - "http://127.0.0.1:4200".parse().unwrap(), - ]) - } - } - } - } - - pub async fn routes() -> Router { - // build our application with a route - Router::new() - .route( - callers::endpoints::DBTEST, - get(callers::common::endpoint::db_ping), - ) - .route( - callers::endpoints::ROOT, - get(callers::common::endpoint::root), - ) - .route( - callers::endpoints::REGISTER, - post(callers::register::register_user), - ) - .layer(cors::configure_cors().await) - } - - pub async fn app() -> Router { - let pool = super::db::init::create_pool() - .await - .expect("Failed to create pool"); - - super::db::init::migrations(&pool).await; - - routes() - .await - .merge( - utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") - .url("/api-docs/openapi.json", ApiDoc::openapi()), - ) - .layer(axum::Extension(pool)) - } -} diff --git a/src/token_stuff/mod.rs b/src/token_stuff/mod.rs index 1ca1704..7eb3511 100644 --- a/src/token_stuff/mod.rs +++ b/src/token_stuff/mod.rs @@ -11,10 +11,6 @@ pub const MESSAGE: &str = "Something random"; pub const ISSUER: &str = "textsender_auth"; pub const AUDIENCE: &str = "textsender"; -pub fn get_issued() -> time::Result { - Ok(time::OffsetDateTime::now_utc()) -} - pub fn get_expiration(issued: &time::OffsetDateTime) -> Result { let duration_expire = time::Duration::hours(4); Ok(*issued + duration_expire) diff --git a/tests/tests.rs b/tests/tests.rs new file mode 100644 index 0000000..7bd28bd --- /dev/null +++ b/tests/tests.rs @@ -0,0 +1,275 @@ +use textsender_auth; +use textsender_auth::callers; +use textsender_auth::db; +use textsender_auth::init; + +mod db_mgr { + use std::str::FromStr; + + pub const LIMIT: usize = 6; + + pub async fn get_pool() -> Result { + let tm_db_url = textsender_models::envy::environment::get_db_url() + .await + .value; + let tm_options = sqlx::postgres::PgConnectOptions::from_str(&tm_db_url).unwrap(); + sqlx::PgPool::connect_with(tm_options).await + } + + pub async fn generate_db_name() -> String { + let db_name = + get_database_name().await.unwrap() + &"_" + &uuid::Uuid::new_v4().to_string()[..LIMIT]; + db_name + } + + pub async fn connect_to_db(db_name: &str) -> Result { + let db_url = textsender_models::envy::environment::get_db_url() + .await + .value; + let options = sqlx::postgres::PgConnectOptions::from_str(&db_url)?.database(db_name); + sqlx::PgPool::connect_with(options).await + } + + pub async fn create_database( + template_pool: &sqlx::PgPool, + db_name: &str, + ) -> Result<(), sqlx::Error> { + let create_query = format!("CREATE DATABASE {}", db_name); + match sqlx::query(&create_query).execute(template_pool).await { + Ok(_) => Ok(()), + Err(e) => Err(e), + } + } + + // Function to drop a database + pub async fn drop_database( + template_pool: &sqlx::PgPool, + db_name: &str, + ) -> Result<(), sqlx::Error> { + let drop_query = format!("DROP DATABASE IF EXISTS {} WITH (FORCE)", db_name); + sqlx::query(&drop_query).execute(template_pool).await?; + Ok(()) + } + + pub async fn get_database_name() -> Result> { + let database_url = textsender_models::envy::environment::get_db_url() + .await + .value; + + let parsed_url = url::Url::parse(&database_url)?; + if parsed_url.scheme() == "postgres" || parsed_url.scheme() == "postgresql" { + match parsed_url + .path_segments() + .and_then(|segments| segments.last().map(|s| s.to_string())) + { + Some(sss) => Ok(sss), + None => Err("Error parsing".into()), + } + } else { + // Handle other database types if needed + Err("Error parsing".into()) + } + } +} + +pub mod requests { + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + + pub async fn register_user( + app: &axum::Router, + ) -> Result { + let payload = serde_json::json!({ + "username": String::from(super::TEST_USERNAME), + "password": String::from(super::TEST_PASSWORD), + "phone_number": String::from(super::TEST_PHONE_NUMBER), + "firstname": String::from(super::TEST_FIRSTNAME), + "lastname": String::from(super::TEST_LASTNAME), + }); + let req = axum::http::Request::builder() + .method(axum::http::Method::POST) + .uri(super::callers::endpoints::REGISTER) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(payload.to_string())) + .unwrap(); + + app.clone().oneshot(req).await + } + + pub async fn login_user( + app: &axum::Router, + username: &str, + password: &str, + ) -> Result { + let payload = serde_json::json!({ + "username": username, + "password": password, + }); + let req = axum::http::Request::builder() + .method(axum::http::Method::POST) + .uri(super::callers::endpoints::LOGIN) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(payload.to_string())) + .unwrap(); + + app.clone().oneshot(req).await + } +} + +const TEST_FIRSTNAME: &str = "Billy"; +const TEST_LASTNAME: &str = "Bob"; +const TEST_USERNAME: &str = "BillyBob01"; +const TEST_PASSWORD: &str = "923ndcry392qryudx328qrdy328r"; +const TEST_PHONE_NUMBER: &str = "+10123456789"; + +async fn register_user( + app: &axum::Router, +) -> Result { + match requests::register_user(&app).await { + Ok(response) => { + if axum::http::StatusCode::CREATED != response.status() { + Err(std::io::Error::other(format!( + "Status code is off {:?}", + response.status() + ))) + } else { + match axum::body::to_bytes(response.into_body(), usize::MAX).await { + Ok(body) => { + let parsed_body: callers::register::response::Response = + serde_json::from_slice(&body).unwrap(); + Ok(parsed_body.data[0].clone()) + } + Err(err) => Err(std::io::Error::other(err.to_string())), + } + } + } + Err(err) => Err(std::io::Error::other(err.to_string())), + } +} + +async fn login_user( + app: &axum::Router, + username: &str, + password: &str, +) -> Result { + match requests::login_user(&app, username, password).await { + Ok(response) => { + if axum::http::StatusCode::OK != response.status() { + Err(std::io::Error::other(format!( + "Status code is off {:?}", + response.status() + ))) + } else { + match axum::body::to_bytes(response.into_body(), usize::MAX).await { + Ok(body) => { + let parsed_body: callers::login::response::LoginResponse = + serde_json::from_slice(&body).unwrap(); + Ok(parsed_body.data[0].clone()) + } + Err(err) => Err(std::io::Error::other(err.to_string())), + } + } + } + Err(err) => Err(std::io::Error::other(err.to_string())), + } +} + +#[tokio::test] +async fn test_register_user() { + let tm_pool = db_mgr::get_pool().await.unwrap(); + let db_name = db_mgr::generate_db_name().await; + + match db_mgr::create_database(&tm_pool, &db_name).await { + Ok(_) => { + println!("Success"); + } + Err(e) => { + assert!(false, "Error: {:?}", e.to_string()); + } + } + + let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); + + db::init::migrations(&pool).await; + + let app = init::routes().await.layer(axum::Extension(pool)); + + match requests::register_user(&app).await { + Ok(response) => { + assert_eq!( + axum::http::StatusCode::CREATED, + response.status(), + "Status does not match {:?}", + response.status() + ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let parsed_body: callers::register::response::Response = + serde_json::from_slice(&body).unwrap(); + assert!(parsed_body.data.len() > 0, "No data returned"); + + let returned_user = &parsed_body.data[0]; + + assert_eq!( + TEST_USERNAME, returned_user.username, + "Error with returned user" + ); + } + Err(err) => { + assert!(false, "Error: {err:?}"); + } + } + + match db_mgr::drop_database(&tm_pool, &db_name).await { + Ok(()) => {} + Err(err) => { + assert!(false, "Error: {err:?}"); + } + } +} + +#[tokio::test] +async fn test_login_user() { + let tm_pool = db_mgr::get_pool().await.unwrap(); + let db_name = db_mgr::generate_db_name().await; + + match db_mgr::create_database(&tm_pool, &db_name).await { + Ok(_) => { + println!("Success"); + } + Err(e) => { + assert!(false, "Error: {:?}", e.to_string()); + } + } + + let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); + + db::init::migrations(&pool).await; + + let app = init::routes().await.layer(axum::Extension(pool)); + + match register_user(&app).await { + Ok(user) => match login_user(&app, &user.username, TEST_PASSWORD).await { + Ok(login_result) => { + assert_eq!( + false, + login_result.access_token.is_empty(), + "Access token is empty when it should not be" + ); + } + Err(err) => { + assert!(false, "Error: {err:?}"); + } + }, + Err(err) => { + assert!(false, "Error: {err:?}"); + } + } + + match db_mgr::drop_database(&tm_pool, &db_name).await { + Ok(()) => {} + Err(err) => { + assert!(false, "Error: {err:?}"); + } + } +}