From 2385307fd339d3b672d310f95c373d65cd2b39fe Mon Sep 17 00:00:00 2001 From: phoenix Date: Thu, 11 Jun 2026 14:36:10 -0400 Subject: [PATCH] Register service user endpoint (#5) Reviewed-on: http://git.kundeng.us/phoenix/textsender-auth/pulls/5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- migrations/20260607214210_init.sql | 3 +- src/callers/messages.rs | 1 + src/callers/mod.rs | 3 + src/callers/register.rs | 139 ++++++++++++++++++++++++++++- src/lib.rs | 4 + src/repo/service.rs | 79 ++++++++++++++++ tests/tests.rs | 115 ++++++++++++++++++++---- 9 files changed, 324 insertions(+), 24 deletions(-) create mode 100644 src/callers/messages.rs diff --git a/Cargo.lock b/Cargo.lock index d4ab5d6..3375204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "textsender_auth" -version = "0.1.15" +version = "0.1.16" dependencies = [ "argon2", "async-std", diff --git a/Cargo.toml b/Cargo.toml index 3cc1795..9f6484a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textsender_auth" -version = "0.1.15" +version = "0.1.16" edition = "2024" rust-version = "1.95" diff --git a/migrations/20260607214210_init.sql b/migrations/20260607214210_init.sql index 86e6df9..6233efd 100644 --- a/migrations/20260607214210_init.sql +++ b/migrations/20260607214210_init.sql @@ -25,5 +25,6 @@ CREATE TABLE IF NOT EXISTS "service_user" ( username TEXT NOT NULL, passphrase TEXT NOT NULL, created TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_login timestamptz NULL + last_login timestamptz NULL, + salt_id UUID NOT NULL ); diff --git a/src/callers/messages.rs b/src/callers/messages.rs new file mode 100644 index 0000000..1b8aa21 --- /dev/null +++ b/src/callers/messages.rs @@ -0,0 +1 @@ +pub const SUCCESSFUL_MESSAGE: &str = "Successful"; diff --git a/src/callers/mod.rs b/src/callers/mod.rs index c561eea..0de7e9b 100644 --- a/src/callers/mod.rs +++ b/src/callers/mod.rs @@ -1,5 +1,6 @@ pub mod common; pub mod login; +pub mod messages; pub mod register; pub mod endpoints { @@ -8,4 +9,6 @@ pub mod endpoints { /// Endpoint for a user to login pub const LOGIN: &str = "/api/v1/login"; pub const DBTEST: &str = "/api/v1/test/db"; + /// Endpoint constant for service user registration + pub const REGISTER_SERVICE_USER: &str = "/api/v1/service/register"; } diff --git a/src/callers/register.rs b/src/callers/register.rs index a16df74..e602e49 100644 --- a/src/callers/register.rs +++ b/src/callers/register.rs @@ -18,6 +18,29 @@ pub mod request { #[serde(skip_serializing_if = "Option::is_none")] pub lastname: Option, } + + #[derive(Default, Deserialize, Serialize, utoipa::ToSchema)] + pub struct RegisterServiceUserRequest { + pub username: String, + pub passphrase: String, + } + + impl RegisterServiceUserRequest { + pub fn is_empty(&self) -> (bool, Option) { + if self.username.is_empty() && self.passphrase.is_empty() { + ( + true, + Some(String::from("Username and Passphrase are empty")), + ) + } else if self.username.is_empty() { + (true, Some(String::from("Username is empty"))) + } else if self.passphrase.is_empty() { + (true, Some(String::from("Passphrase is empty"))) + } else { + (false, None) + } + } + } } pub mod response { @@ -28,6 +51,21 @@ pub mod response { pub message: String, pub data: Vec, } + + #[derive(Default, Deserialize, Serialize, utoipa::ToSchema)] + pub struct RegisterServiceUserResponse { + pub message: String, + pub data: Vec, + } +} + +fn generate_the_salt() -> ( + argon2::password_hash::SaltString, + textsender_models::user::Salt, +) { + let salt_string = hashing::generate_salt().unwrap(); + let salt = textsender_models::user::Salt::default(); + (salt_string, salt) } /// Endpoint to register a user @@ -91,10 +129,8 @@ pub async fn register_user( } else { println!("Good to create"); println!("Generate salt string"); - let salt_string = hashing::generate_salt().unwrap(); - let mut salt = textsender_models::user::Salt::default(); - let generated_salt = salt_string; - salt.salt = generated_salt.to_string(); + + let (generated_salt, mut salt) = generate_the_salt(); println!("Creating salt"); salt.id = repo::salt::insert(&pool, &salt).await.unwrap(); user.salt_id = salt.id; @@ -160,3 +196,98 @@ async fn is_registration_enabled() -> Result { )) } } + +/// Endpoint to register a service user +#[utoipa::path( + post, + path = super::endpoints::REGISTER_SERVICE_USER, + request_body( + content = request::RegisterServiceUserRequest, + description = "Data required to register service user", + content_type = "application/json" + ), + responses( + (status = 201, description = "Service user created", body = response::RegisterServiceUserResponse), + (status = 400, description = "Issue creating service user", body = response::RegisterServiceUserResponse), + (status = 406, description = "Cannot create service user", body = response::RegisterServiceUserResponse), + (status = 500, description = "Issue creating service user", body = response::RegisterServiceUserResponse), + ) +)] +pub async fn register_service_user( + axum::Extension(pool): axum::Extension, + Json(payload): Json, +) -> ( + axum::http::StatusCode, + axum::Json, +) { + let mut resp = response::RegisterServiceUserResponse { + ..Default::default() + }; + + let registration_enabled = match is_registration_enabled().await { + Ok(value) => value, + Err(err) => { + eprintln!("Error: {err:?}"); + resp.message = String::from("Registration check failed"); + return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(resp)); + } + }; + + let (res, msg) = payload.is_empty(); + if res { + resp.message = msg.unwrap(); + (axum::http::StatusCode::BAD_REQUEST, axum::Json(resp)) + } else { + if registration_enabled { + match repo::service::exists(&pool, &payload.username).await { + Ok(exists) => { + if exists { + resp.message = String::from("Invalid"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(resp), + ) + } else { + let (generate_salt, mut salt) = generate_the_salt(); + salt.id = repo::salt::insert(&pool, &salt).await.unwrap(); + let mut service_user = textsender_models::user::ServiceUser { + username: payload.username.clone(), + passphrase: hashing::hash_password(&payload.username, &generate_salt) + .unwrap(), + salt_id: salt.id, + ..Default::default() + }; + + println!("Creating user"); + + match repo::service::insert(&pool, &service_user).await { + Ok(created) => { + resp.message = String::from(super::messages::SUCCESSFUL_MESSAGE); + service_user.created = Some(created); + resp.data.push(service_user); + (axum::http::StatusCode::CREATED, axum::Json(resp)) + } + Err(err) => { + resp.message = err.to_string(); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(resp), + ) + } + } + } + } + Err(err) => { + resp.message = err.to_string(); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(resp), + ) + } + } + } else { + resp.message = String::from("Registration is not enabled"); + (axum::http::StatusCode::NOT_ACCEPTABLE, Json(resp)) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index e4bd359..c820ae5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,6 +97,10 @@ pub mod init { callers::endpoints::REGISTER, post(callers::register::register_user), ) + .route( + callers::endpoints::REGISTER_SERVICE_USER, + post(callers::register::register_service_user), + ) .route(callers::endpoints::LOGIN, post(callers::login::user_login)) .layer(cors::configure_cors().await) } diff --git a/src/repo/service.rs b/src/repo/service.rs index 14ced65..1f98dcd 100644 --- a/src/repo/service.rs +++ b/src/repo/service.rs @@ -48,3 +48,82 @@ pub async fn get_passphrase( Err(err) => Err(err), } } + +pub async fn get_with_username( + pool: &sqlx::PgPool, + username: &String, +) -> Result { + match sqlx::query( + r#"SELECT id, username, passphrase, created, last_login FROM "service_user" WHERE username = $1"# + ).bind(username) + .fetch_one(pool).await { + Ok(row) => { + let service_user = textsender_models::user::ServiceUser { + id: row.try_get("id")?, + username: row.try_get("username")?, + passphrase: row.try_get("passphrase")?, + created: row.try_get("created")?, + last_login: row.try_get("last_login")?, + salt_id: row.try_get("salt_id")? + }; + + Ok(service_user) + } + Err(err) => { + Err(err) + } + } +} + +pub async fn insert( + pool: &sqlx::PgPool, + service_user: &textsender_models::user::ServiceUser, +) -> Result { + match sqlx::query( + r#"INSERT INTO "service_user" (username, passphrase, salt_id) + VALUES ($1, $2, $3) + RETURNING created + "#, + ) + .bind(&service_user.username) + .bind(&service_user.passphrase) + .bind(service_user.salt_id) + .fetch_one(pool) + .await + { + Ok(row) => { + let created: time::OffsetDateTime = row.try_get("created")?; + Ok(created) + } + Err(err) => Err(err), + } +} + +pub async fn exists(pool: &sqlx::PgPool, service_username: &String) -> Result { + let result = sqlx::query( + r#" + SELECT 1 FROM "service_user" WHERE username = $1 + "#, + ) + .bind(service_username) + .fetch_optional(pool) + .await; + + match result { + Ok(r) => match r { + Some(row) => { + if row.is_empty() { + Ok(false) + } else { + Ok(true) + } + } + None => Ok(false), + }, + Err(e) => { + eprintln!("What??"); + eprintln!("Error: {e:?}"); + Err(e) + } + } +} diff --git a/tests/tests.rs b/tests/tests.rs index 7bd28bd..4753c91 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -75,6 +75,7 @@ mod db_mgr { pub mod requests { use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + /// Function to call register user endpoint pub async fn register_user( app: &axum::Router, ) -> Result { @@ -95,6 +96,7 @@ pub mod requests { app.clone().oneshot(req).await } + /// Function to call login user endpoint pub async fn login_user( app: &axum::Router, username: &str, @@ -113,14 +115,55 @@ pub mod requests { app.clone().oneshot(req).await } + + /// Function to call register service user endpoint + pub async fn register_service_user( + app: &axum::Router, + ) -> Result { + let payload = serde_json::json!({ + "username": String::from(super::TEST_SERVICE_USERNAME), + "passphrase": String::from(super::TEST_SERVICE_PASSPHRASE), + }); + let req = axum::http::Request::builder() + .method(axum::http::Method::POST) + .uri(super::callers::endpoints::REGISTER_SERVICE_USER) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(payload.to_string())) + .unwrap(); + + app.clone().oneshot(req).await + } } +/// Test user firstname const TEST_FIRSTNAME: &str = "Billy"; +/// Test user lastname const TEST_LASTNAME: &str = "Bob"; +/// Test user username const TEST_USERNAME: &str = "BillyBob01"; +/// Test user password const TEST_PASSWORD: &str = "923ndcry392qryudx328qrdy328r"; +/// Test user phone number const TEST_PHONE_NUMBER: &str = "+10123456789"; +/// Test service username +const TEST_SERVICE_USERNAME: &str = "swoon"; +/// Test service passphrase +const TEST_SERVICE_PASSPHRASE: &str = "4n5cf349tfy34w857ty39wq45nfdq23"; + +async fn convert_response(response: axum::response::Response) -> Result +where + T: serde::de::DeserializeOwned, +{ + match axum::body::to_bytes(response.into_body(), usize::MAX).await { + Ok(body) => { + let resp: T = serde_json::from_slice(&body).unwrap(); + Ok(resp) + } + Err(err) => Err(std::io::Error::other(err.to_string())), + } +} + async fn register_user( app: &axum::Router, ) -> Result { @@ -193,23 +236,8 @@ async fn test_register_user() { 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]; - + match register_user(&app).await { + Ok(returned_user) => { assert_eq!( TEST_USERNAME, returned_user.username, "Error with returned user" @@ -273,3 +301,56 @@ async fn test_login_user() { } } } + +#[tokio::test] +async fn test_register_service_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_service_user(&app).await { + Ok(response) => { + match convert_response::( + response, + ) + .await + { + Ok(resp) => { + assert!(resp.data.len() > 0, "No service user was created"); + let service_user = &resp.data[0]; + assert_eq!( + TEST_SERVICE_USERNAME, service_user.username, + "Service username does not match" + ); + } + 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:?}"); + } + } +}