From 7f5f1bae2f917bce3c911d3b41209b3764f3c3ce Mon Sep 17 00:00:00 2001 From: phoenix Date: Sat, 5 Apr 2025 19:26:58 +0000 Subject: [PATCH] Register endpoint (#16) Reviewed-on: https://git.kundeng.us/phoenix/icarus_auth/pulls/16 Co-authored-by: phoenix Co-committed-by: phoenix --- .gitea/workflows/tag_release.yml | 2 +- .gitea/workflows/workflow.yml | 11 +-- Cargo.toml | 8 +- migrations/20250402221858_init_migrate.sql | 15 ++- src/callers/register.rs | 92 ++++++++++++++---- src/hashing/mod.rs | 17 +++- src/lib.rs | 1 - src/main.rs | 34 ++++--- src/models/common.rs | 14 --- src/models/mod.rs | 1 - src/repo/mod.rs | 106 ++++++++++++++++++--- 11 files changed, 230 insertions(+), 71 deletions(-) delete mode 100644 src/models/common.rs delete mode 100644 src/models/mod.rs diff --git a/.gitea/workflows/tag_release.yml b/.gitea/workflows/tag_release.yml index 0ef5ff7..ca7ddef 100644 --- a/.gitea/workflows/tag_release.yml +++ b/.gitea/workflows/tag_release.yml @@ -19,7 +19,7 @@ jobs: - name: Install Rust uses: actions-rs/toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 components: cargo - name: Extract Version from Cargo.toml diff --git a/.gitea/workflows/workflow.yml b/.gitea/workflows/workflow.yml index 087d74e..557a245 100644 --- a/.gitea/workflows/workflow.yml +++ b/.gitea/workflows/workflow.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 - run: | mkdir -p ~/.ssh echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key @@ -53,7 +53,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 # --- Add this step for explicit verification --- - name: Verify Docker Environment run: | @@ -68,7 +68,6 @@ jobs: echo "Docker environment check complete." # NOTE: Do NOT use continue-on-error here. # If Docker isn't working as expected, the job SHOULD fail here. - # --- Optional but Recommended: Database Migrations Step --- - name: Run tests env: # Define DATABASE_URL for tests to use @@ -94,7 +93,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 - run: rustup component add rustfmt - run: | mkdir -p ~/.ssh @@ -113,7 +112,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 - run: rustup component add clippy - run: | mkdir -p ~/.ssh @@ -132,7 +131,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 - run: | mkdir -p ~/.ssh echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key diff --git a/Cargo.toml b/Cargo.toml index 7cbd4f0..59fd2d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "icarus_auth" -version = "0.1.1" +version = "0.2.0" edition = "2024" +rust-version = "1.86" [dependencies] axum = { version = "0.8.3" } @@ -11,12 +12,13 @@ tokio = { version = "1.44.1", features = ["rt-multi-thread"] } tracing-subscriber = { version = "0.3.19" } tower = { version = "0.5.2" } hyper = { version = "1.6.0" } -sqlx = { version = "0.8.3", features = ["postgres", "runtime-tokio-native-tls", "uuid"] } +sqlx = { version = "0.8.3", features = ["postgres", "runtime-tokio-native-tls", "time", "uuid"] } dotenvy = { version = "0.15.7" } uuid = { version = "1.16.0", features = ["v4", "serde"] } argon2 = { version = "0.5.3", features = ["std"] } # Use the latest 0.5.x version rand = { version = "0.9" } -icarus_models = { git = "ssh://git@git.kundeng.us/phoenix/icarus_models.git", tag = "v0.3.0" } +time = { version = "0.3.41", features = ["macros", "serde"] } +icarus_models = { git = "ssh://git@git.kundeng.us/phoenix/icarus_models.git", tag = "v0.4.0" } [dev-dependencies] http-body-util = { version = "0.1.3" } diff --git a/migrations/20250402221858_init_migrate.sql b/migrations/20250402221858_init_migrate.sql index 16796d7..8fe068c 100644 --- a/migrations/20250402221858_init_migrate.sql +++ b/migrations/20250402221858_init_migrate.sql @@ -5,5 +5,18 @@ CREATE TABLE IF NOT EXISTS "user" ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username TEXT NOT NULL, password TEXT NOT NULL, - date_created TIMESTAMPTZ NOT NULL DEFAULT NOW() + email TEXT NOT NULL, + phone TEXT NOT NULL, + firstname TEXT NOT NULL, + lastname TEXT NOT NULL, + email_verified BOOL NOT NULL, + date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL, + last_login TIMESTAMPTZ 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 ); diff --git a/src/callers/register.rs b/src/callers/register.rs index 1462583..b9d07e4 100644 --- a/src/callers/register.rs +++ b/src/callers/register.rs @@ -1,46 +1,104 @@ use axum::{Json, http::StatusCode}; -use crate::models; +use crate::hashing; use crate::repo; -mod response { +pub mod request { use serde::{Deserialize, Serialize}; - use crate::models; + #[derive(Default, Deserialize, Serialize)] + pub struct Request { + #[serde(skip_serializing_if = "String::is_empty")] + pub username: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub password: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub email: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub phone: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub firstname: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub lastname: String, + } +} + +pub mod response { + use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] pub struct Response { pub message: String, - pub data: models::common::User, + pub data: Vec, } } pub async fn register_user( axum::Extension(pool): axum::Extension, - Json(payload): Json, + Json(payload): Json, ) -> (StatusCode, Json) { - let mut user = models::common::User { + let mut user = icarus_models::user::User { id: uuid::Uuid::nil(), username: payload.username.clone(), password: payload.password.clone(), + email: payload.email.clone(), + phone: payload.phone.clone(), + firstname: payload.firstname.clone(), + lastname: payload.lastname.clone(), + status: String::from("Active"), + email_verified: true, + date_created: Some(time::OffsetDateTime::now_utc()), + last_login: None, + salt_id: uuid::Uuid::nil(), }; - match repo::user::insert(&pool, &user).await { - Ok(id) => { - user.id = id; - ( - StatusCode::CREATED, - Json(response::Response { - message: String::from("User inserted"), - data: user, - }), - ) + match repo::user::exists(&pool, &user.username).await { + Ok(res) => { + if res { + ( + StatusCode::NOT_FOUND, + Json(response::Response { + message: String::from("Error"), + data: vec![user], + }), + ) + } else { + let salt_string = hashing::generate_salt().unwrap(); + let mut salt = icarus_models::user::salt::Salt::default(); + let generated_salt = salt_string; + salt.salt = generated_salt.to_string(); + salt.id = repo::salt::insert(&pool, &salt).await.unwrap(); + user.salt_id = salt.id; + let hashed_password = + hashing::hash_password(&user.password, &generated_salt).unwrap(); + user.password = hashed_password; + + match repo::user::insert(&pool, &user).await { + Ok(id) => { + user.id = id; + ( + StatusCode::CREATED, + Json(response::Response { + message: String::from("User created"), + data: vec![user], + }), + ) + } + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(response::Response { + message: err.to_string(), + data: vec![user], + }), + ), + } + } } Err(err) => ( StatusCode::BAD_REQUEST, Json(response::Response { message: err.to_string(), - data: user, + data: vec![user], }), ), } diff --git a/src/hashing/mod.rs b/src/hashing/mod.rs index eb7ed39..1386d0c 100644 --- a/src/hashing/mod.rs +++ b/src/hashing/mod.rs @@ -8,12 +8,18 @@ use argon2::{ }, }; -pub fn hash_password(password: &String) -> Result { - let password_bytes = password.as_bytes(); - +pub fn generate_salt() -> Result { // Generate a random salt // SaltString::generate uses OsRng internally for cryptographic security let salt = SaltString::generate(&mut OsRng); + Ok(salt) +} + +pub fn hash_password( + password: &String, + salt: &SaltString, +) -> Result { + let password_bytes = password.as_bytes(); // Create an Argon2 instance with default parameters (recommended) // You could customize parameters here if needed, but defaults are strong @@ -22,7 +28,7 @@ pub fn hash_password(password: &String) -> Result match verify_password(&some_password, p.clone()) { Ok(res) => { assert_eq!(res, true); diff --git a/src/lib.rs b/src/lib.rs index 891aac4..1e67995 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,6 @@ pub mod callers; pub mod config; pub mod hashing; -pub mod models; pub mod repo; pub mod keys { diff --git a/src/main.rs b/src/main.rs index dfe28f5..da52960 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,16 +65,9 @@ mod tests { http::{Request, StatusCode}, }; use http_body_util::BodyExt; - use serde::{Deserialize, Serialize}; use serde_json::json; use tower::ServiceExt; // for `call`, `oneshot`, and `ready` - #[derive(Deserialize, Serialize)] - struct Response { - pub message: String, - pub data: icarus_auth::models::common::User, - } - mod db_mgr { use std::str::FromStr; @@ -191,14 +184,22 @@ mod tests { let app = init::routes().await.layer(axum::Extension(pool)); - let usr = icarus_auth::models::common::CreateUser { + let usr = icarus_auth::callers::register::request::Request { username: String::from("somethingsss"), password: String::from("Raindown!"), + email: String::from("dev@null.com"), + phone: String::from("1234567890"), + firstname: String::from("Bob"), + lastname: String::from("Smith"), }; let payload = json!({ "username": &usr.username, "password": &usr.password, + "email": &usr.email, + "phone": &usr.phone, + "firstname": &usr.firstname, + "lastname": &usr.lastname, }); let response = app @@ -214,16 +215,27 @@ mod tests { match response { Ok(resp) => { - assert_eq!(resp.status(), StatusCode::CREATED, "Message: {:?}", resp); + assert_eq!( + resp.status(), + StatusCode::CREATED, + "Message: {:?} {:?}", + resp, + usr.username + ); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); - let parsed_body: Response = serde_json::from_slice(&body).unwrap(); + let parsed_body: callers::register::response::Response = + serde_json::from_slice(&body).unwrap(); + let returned_usr = &parsed_body.data[0]; + + assert_eq!(false, returned_usr.id.is_nil(), "Id is not populated"); assert_eq!( - usr.username, parsed_body.data.username, + usr.username, returned_usr.username, "Usernames do not match" ); + assert!(returned_usr.date_created.is_some(), "Date Created is empty"); } Err(err) => { assert!(false, "Error: {:?}", err.to_string()); diff --git a/src/models/common.rs b/src/models/common.rs deleted file mode 100644 index 7b978d5..0000000 --- a/src/models/common.rs +++ /dev/null @@ -1,14 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Deserialize, Serialize)] -pub struct CreateUser { - pub username: String, - pub password: String, -} - -#[derive(Deserialize, Serialize)] -pub struct User { - pub id: uuid::Uuid, - pub username: String, - pub password: String, -} diff --git a/src/models/mod.rs b/src/models/mod.rs deleted file mode 100644 index 34994bf..0000000 --- a/src/models/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod common; diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 95d3c7a..049a840 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -1,20 +1,104 @@ pub mod user { - use crate::models; + use sqlx::Row; + + #[derive(Debug, serde::Serialize, sqlx::FromRow)] + pub struct InsertedData { + pub id: uuid::Uuid, + pub date_created: Option, + } + + pub async fn exists(pool: &sqlx::PgPool, username: &String) -> Result { + let result = sqlx::query( + r#" + SELECT 1 FROM "user" WHERE username = $1 + "#, + ) + .bind(username) + .fetch_optional(pool) + .await; + + match result { + Ok(r) => Ok(r.is_some()), + Err(e) => Err(e), + } + } pub async fn insert( pool: &sqlx::PgPool, - user: &models::common::User, + user: &icarus_models::user::User, ) -> Result { - let insert_sql = "INSERT INTO \"user\" (username, password) VALUES ($1, $2) RETURNING id"; - - match sqlx::query_scalar(insert_sql) - .bind(&user.username) // Bind the input message securely + let row = sqlx::query( + r#" + INSERT INTO "user" (username, password, email, phone, firstname, lastname, email_verified, status, salt_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, date_created; + "#) + .bind(&user.username) .bind(&user.password) - .fetch_one(pool) // Execute and expect exactly ONE row with ONE column back - .await - { - Ok(o) => Ok(o), - Err(err) => Err(err), // _ => uuid::Uuid::nil(), + .bind(&user.email) + .bind(&user.phone) + .bind(&user.firstname) + .bind(&user.lastname) + .bind(user.email_verified) + .bind(&user.status) + .bind(user.salt_id) + .fetch_one(pool) + .await + .map_err(|e| { + eprintln!("Error inserting item: {}", e); + e + })?; + + let result = InsertedData { + id: row.try_get("id").map_err(|_e| sqlx::Error::RowNotFound)?, + date_created: row + .try_get("date_created") + .map_err(|_e| sqlx::Error::RowNotFound)?, + }; + + if !result.id.is_nil() { + Ok(result.id) + } else { + Err(sqlx::Error::RowNotFound) + } + } +} + +pub mod salt { + use sqlx::Row; + + #[derive(Debug, serde::Serialize, sqlx::FromRow)] + pub struct InsertedData { + pub id: uuid::Uuid, + } + + pub async fn insert( + pool: &sqlx::PgPool, + salt: &icarus_models::user::salt::Salt, + ) -> Result { + let row = sqlx::query( + r#" + INSERT INTO "salt" (salt) + VALUES ($1) + RETURNING id; + "#, + ) + .bind(&salt.salt) + .fetch_one(pool) + .await + .map_err(|e| { + eprintln!("Error inserting item: {}", e); + e + })?; + + let result = InsertedData { + id: row.try_get("id").map_err(|_e| sqlx::Error::RowNotFound)?, + }; + + if !result.id.is_nil() { + Ok(result.id) + } else { + Err(sqlx::Error::RowNotFound) } } }