diff --git a/.gitea/workflows/workflow.yml b/.gitea/workflows/workflow.yml index c2cc7d7..a926d91 100644 --- a/.gitea/workflows/workflow.yml +++ b/.gitea/workflows/workflow.yml @@ -35,7 +35,7 @@ jobs: # --- Add database service definition --- services: postgres: - image: postgres:18.3-alpine + image: postgres:18.4-alpine env: # Use secrets for DB init, with fallbacks for flexibility POSTGRES_USER: ${{ secrets.DB_TEST_USER || 'testuser' }} diff --git a/Cargo.lock b/Cargo.lock index 3375204..bdcb6ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "textsender_auth" -version = "0.1.16" +version = "0.1.17" dependencies = [ "argon2", "async-std", diff --git a/Cargo.toml b/Cargo.toml index 9f6484a..d942b84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textsender_auth" -version = "0.1.16" +version = "0.1.17" edition = "2024" rust-version = "1.95" diff --git a/migrations/schema.sql b/migrations/schema.sql deleted file mode 100644 index 05395e1..0000000 --- a/migrations/schema.sql +++ /dev/null @@ -1,32 +0,0 @@ --- CREATE EXTENSION IF NOT EXISTS pgcrypto; --- CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; --- --- DROP TABLE IF EXISTS "user" CASCADE; --- DROP TABLE IF EXISTS "salt" CASCADE; --- DROP TABLE IF EXISTS "service_user" CASCADE; --- --- CREATE TABLE IF NOT EXISTS "user" ( --- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), --- first_name TEXT NOT NULL, --- last_name TEXT NOT NULL, --- phone_number TEXT NOT NULL, --- username TEXT NOT NULL, --- password TEXT NOT NULL, --- created TIMESTAMPTZ NOT NULL DEFAULT NOW(), --- last_login TIMESTAMPTZ NOT NULL DEFAULT NOW(), --- salt_id UUID NOT NULL --- ); --- --- --- CREATE TABLE IF NOT EXISTS "salt" ( --- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), --- salt TEXT NOT NULL --- ); --- --- CREATE TABLE IF NOT EXISTS "service_user" ( --- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), --- username TEXT NOT NULL, --- passphrase TEXT NOT NULL, --- created TIMESTAMPTZ NOT NULL DEFAULT NOW(), --- last_login timestamptz NULL --- ); diff --git a/src/callers/login.rs b/src/callers/login.rs index 83e6cff..443c2ae 100644 --- a/src/callers/login.rs +++ b/src/callers/login.rs @@ -9,6 +9,29 @@ pub mod request { pub username: String, pub password: String, } + + #[derive(Deserialize, utoipa::ToSchema)] + pub struct ServiceUserLoginRequest { + pub username: String, + pub passphrase: String, + } + + impl ServiceUserLoginRequest { + 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.username.is_empty() { + (true, Some(String::from("Passphrase is empty"))) + } else { + (false, None) + } + } + } } pub mod response { @@ -19,6 +42,12 @@ pub mod response { pub data: Vec, } + #[derive(Default, Deserialize, Serialize, utoipa::ToSchema)] + pub struct ServiceUserLoginResponse { + pub message: String, + pub data: Vec, + } + pub async fn extract( response: axum::response::Response, ) -> Result { @@ -160,3 +189,151 @@ pub async fn user_login( } } } + +/// Endpoint for service user login +#[utoipa::path( + post, + path = super::endpoints::LOGIN_SERVICE_USER, + request_body( + content = request::ServiceUserLoginRequest, + description = "Data required for service user to lgoin", + content_type = "application/json" + ), + responses( + (status = 201, description = "Service uuser login successful", body = response::ServiceUserLoginResponse), + (status = 400, description = "Bad data", body = response::ServiceUserLoginResponse), + (status = 500, description = "Something went wrong", body = response::ServiceUserLoginResponse) + ) +)] +pub async fn service_user_login( + axum::Extension(pool): axum::Extension, + axum::Json(payload): axum::Json, +) -> ( + axum::http::StatusCode, + axum::Json, +) { + if payload.username.is_empty() || payload.passphrase.is_empty() { + let reason = if payload.username.is_empty() { + String::from("Username not provided") + } else { + String::from("Passphrase not provided") + }; + + ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(response::ServiceUserLoginResponse { + message: reason, + data: Vec::new(), + }), + ) + } else { + match repo::service::exists(&pool, &payload.username).await { + Ok(exists) => { + if !exists { + println!("User does not exists"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::ServiceUserLoginResponse { + message: String::from("Unable to login"), + data: Vec::new(), + }), + ) + } else { + println!("Good to create"); + let service_user = + match repo::service::get_with_username(&pool, &payload.username).await { + Ok(service_user) => service_user, + Err(err) => { + eprintln!("Error: {err:?}"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::ServiceUserLoginResponse { + message: String::from("Unable to login"), + data: Vec::new(), + }), + ); + } + }; + println!("Service user: {service_user:?}"); + println!("Payload: {:?}", payload.passphrase); + let hashed_password = service_user.passphrase.clone(); + println!("Hash password: {hashed_password:?}"); + + match hashing::verify_password(&payload.passphrase, hashed_password) { + Ok(matches) => { + if matches { + // Create token + println!("Creating token"); + let key = textsender_models::envy::environment::get_secret_key() + .await + .value; + let (token_literal, duration) = + token_stuff::create_token(&key, &service_user.id).unwrap(); + + if token_stuff::verify_token(&key, &token_literal) { + let current_time = time::OffsetDateTime::now_utc(); + let _ = repo::service::update_last_login( + &pool, + &service_user, + ¤t_time, + ) + .await; + + ( + axum::http::StatusCode::OK, + axum::Json(response::ServiceUserLoginResponse { + message: String::from( + super::messages::SUCCESSFUL_MESSAGE, + ), + data: vec![textsender_models::token::LoginResult { + user_id: service_user.id, + access_token: token_literal, + token_type: String::from( + textsender_models::token::TOKEN_TYPE, + ), + issued_at: duration, + ..Default::default() + }], + }), + ) + } else { + eprintln!("Invalid token"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::ServiceUserLoginResponse { + message: String::from("Invalid attempt"), + data: Vec::new(), + }), + ) + } + } else { + eprintln!("No match"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::ServiceUserLoginResponse { + message: String::from("Invalid attempt"), + data: Vec::new(), + }), + ) + } + } + Err(err) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::ServiceUserLoginResponse { + message: err.to_string(), + data: Vec::new(), + }), + ), + } + } + } + Err(err) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response::ServiceUserLoginResponse { + message: err.to_string(), + data: Vec::new(), + }), + ), + } + } +} diff --git a/src/callers/mod.rs b/src/callers/mod.rs index 0de7e9b..a8b5883 100644 --- a/src/callers/mod.rs +++ b/src/callers/mod.rs @@ -11,4 +11,6 @@ pub mod endpoints { pub const DBTEST: &str = "/api/v1/test/db"; /// Endpoint constant for service user registration pub const REGISTER_SERVICE_USER: &str = "/api/v1/service/register"; + /// Endpoint constant for service login user + pub const LOGIN_SERVICE_USER: &str = "/api/v1/service/login"; } diff --git a/src/callers/register.rs b/src/callers/register.rs index e602e49..3474d75 100644 --- a/src/callers/register.rs +++ b/src/callers/register.rs @@ -252,7 +252,7 @@ pub async fn register_service_user( 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) + passphrase: hashing::hash_password(&payload.passphrase, &generate_salt) .unwrap(), salt_id: salt.id, ..Default::default() @@ -261,9 +261,10 @@ pub async fn register_service_user( println!("Creating user"); match repo::service::insert(&pool, &service_user).await { - Ok(created) => { + Ok((service_user_id, created)) => { resp.message = String::from(super::messages::SUCCESSFUL_MESSAGE); service_user.created = Some(created); + service_user.id = service_user_id; resp.data.push(service_user); (axum::http::StatusCode::CREATED, axum::Json(resp)) } diff --git a/src/lib.rs b/src/lib.rs index c820ae5..af64ea5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -102,6 +102,10 @@ pub mod init { post(callers::register::register_service_user), ) .route(callers::endpoints::LOGIN, post(callers::login::user_login)) + .route( + callers::endpoints::LOGIN_SERVICE_USER, + post(callers::login::service_user_login), + ) .layer(cors::configure_cors().await) } diff --git a/src/repo/service.rs b/src/repo/service.rs index 1f98dcd..5617d98 100644 --- a/src/repo/service.rs +++ b/src/repo/service.rs @@ -54,17 +54,27 @@ pub async fn get_with_username( username: &String, ) -> Result { match sqlx::query( - r#"SELECT id, username, passphrase, created, last_login FROM "service_user" WHERE username = $1"# + r#"SELECT id, username, passphrase, created, last_login, salt_id FROM "service_user" WHERE username = $1"# ).bind(username) .fetch_one(pool).await { Ok(row) => { + let last_login: Option = match row.try_get("last_login") { + Ok(login) => { + Some(login) + } + Err(err) => { + eprintln!("Error: {err:?}"); + None + } + }; + 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")? + last_login, + salt_id: row.try_get("salt_id")?, }; Ok(service_user) @@ -75,14 +85,47 @@ pub async fn get_with_username( } } +pub async fn update_last_login( + pool: &sqlx::PgPool, + service_user: &textsender_models::user::ServiceUser, + time: &time::OffsetDateTime, +) -> Result { + let result = sqlx::query( + r#" + UPDATE "service_user" SET last_login = $1 WHERE id = $2 RETURNING last_login + "#, + ) + .bind(time) + .bind(service_user.id) + .fetch_optional(pool) + .await + .map_err(|e| { + eprintln!("Error updating time: {e}"); + e + }); + + match result { + Ok(row) => match row { + Some(r) => { + let last_login: time::OffsetDateTime = r + .try_get("last_login") + .map_err(|_e| sqlx::Error::RowNotFound)?; + Ok(last_login) + } + None => Err(sqlx::Error::RowNotFound), + }, + Err(err) => Err(err), + } +} + pub async fn insert( pool: &sqlx::PgPool, service_user: &textsender_models::user::ServiceUser, -) -> Result { +) -> Result<(uuid::Uuid, time::OffsetDateTime), sqlx::Error> { match sqlx::query( r#"INSERT INTO "service_user" (username, passphrase, salt_id) VALUES ($1, $2, $3) - RETURNING created + RETURNING id, created "#, ) .bind(&service_user.username) @@ -92,8 +135,9 @@ pub async fn insert( .await { Ok(row) => { + let id: uuid::Uuid = row.try_get("id")?; let created: time::OffsetDateTime = row.try_get("created")?; - Ok(created) + Ok((id, created)) } Err(err) => Err(err), } diff --git a/tests/tests.rs b/tests/tests.rs index 4753c91..bd2cba1 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -133,6 +133,25 @@ pub mod requests { app.clone().oneshot(req).await } + + pub async fn login_service_user( + app: &axum::Router, + username: &str, + passphrase: &str, + ) -> Result { + let payload = serde_json::json!({ + "username": username, + "passphrase": passphrase, + }); + let req = axum::http::Request::builder() + .method(axum::http::Method::POST) + .uri(super::callers::endpoints::LOGIN_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 @@ -175,13 +194,16 @@ async fn register_user( 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()) + match convert_response::(response).await { + Ok(response) => { + if response.data.len() > 0 { + let user = response.data[0].clone(); + Ok(user) + } else { + Err(std::io::Error::other("No data returned")) + } } - Err(err) => Err(std::io::Error::other(err.to_string())), + Err(err) => Err(err), } } } @@ -202,13 +224,82 @@ async fn login_user( 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()) + match convert_response::(response).await { + Ok(response) => { + if response.data.len() > 0 { + let user = response.data[0].clone(); + Ok(user) + } else { + Err(std::io::Error::other("No data returned")) + } } - Err(err) => Err(std::io::Error::other(err.to_string())), + Err(err) => Err(err), + } + } + } + Err(err) => Err(std::io::Error::other(err.to_string())), + } +} + +async fn register_service_user( + app: &axum::Router, +) -> Result { + match requests::register_service_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 convert_response::( + response, + ) + .await + { + Ok(response) => { + if response.data.len() > 0 { + let service_user = response.data[0].clone(); + Ok(service_user) + } else { + Err(std::io::Error::other("No data returned")) + } + } + Err(err) => Err(err), + } + } + } + Err(err) => Err(std::io::Error::other(err.to_string())), + } +} + +async fn login_service_user( + app: &axum::Router, + username: &str, + passphrase: &str, +) -> Result { + match requests::login_service_user(&app, username, passphrase).await { + Ok(response) => { + if axum::http::StatusCode::OK != response.status() { + Err(std::io::Error::other(format!( + "Status code is off {:?}", + response.status() + ))) + } else { + match convert_response::( + response, + ) + .await + { + Ok(response) => { + if response.data.len() > 0 { + let login_result = response.data[0].clone(); + Ok(login_result) + } else { + Err(std::io::Error::other("No data returned")) + } + } + Err(err) => Err(err), } } } @@ -354,3 +445,56 @@ async fn test_register_service_user() { } } } + +#[tokio::test] +async fn test_login_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 register_service_user(&app).await { + Ok(user) => { + assert_eq!( + false, + user.id.is_nil(), + "The service user id should not be nil" + ); + match login_service_user(&app, TEST_SERVICE_USERNAME, TEST_SERVICE_PASSPHRASE).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:?}"); + } + } +}