Register endpoint #16

Merged
phoenix merged 56 commits from register_endpoint-init into devel 2025-04-05 19:26:59 +00:00
11 changed files with 230 additions and 71 deletions

View File

@@ -19,7 +19,7 @@ jobs:
- name: Install Rust - name: Install Rust
uses: actions-rs/toolchain@v1 uses: actions-rs/toolchain@v1
with: with:
toolchain: 1.85.0 toolchain: 1.86.0
components: cargo components: cargo
- name: Extract Version from Cargo.toml - name: Extract Version from Cargo.toml

View File

@@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
toolchain: 1.85.0 toolchain: 1.86.0
- run: | - run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key
@@ -53,7 +53,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
toolchain: 1.85.0 toolchain: 1.86.0
# --- Add this step for explicit verification --- # --- Add this step for explicit verification ---
- name: Verify Docker Environment - name: Verify Docker Environment
run: | run: |
@@ -68,7 +68,6 @@ jobs:
echo "Docker environment check complete." echo "Docker environment check complete."
# NOTE: Do NOT use continue-on-error here. # NOTE: Do NOT use continue-on-error here.
# If Docker isn't working as expected, the job SHOULD fail here. # If Docker isn't working as expected, the job SHOULD fail here.
# --- Optional but Recommended: Database Migrations Step ---
- name: Run tests - name: Run tests
env: env:
# Define DATABASE_URL for tests to use # Define DATABASE_URL for tests to use
@@ -94,7 +93,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
toolchain: 1.85.0 toolchain: 1.86.0
- run: rustup component add rustfmt - run: rustup component add rustfmt
- run: | - run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
@@ -113,7 +112,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
toolchain: 1.85.0 toolchain: 1.86.0
- run: rustup component add clippy - run: rustup component add clippy
- run: | - run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
@@ -132,7 +131,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
toolchain: 1.85.0 toolchain: 1.86.0
- run: | - run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key

View File

@@ -1,7 +1,8 @@
[package] [package]
name = "icarus_auth" name = "icarus_auth"
version = "0.1.1" version = "0.2.0"
edition = "2024" edition = "2024"
rust-version = "1.86"
[dependencies] [dependencies]
axum = { version = "0.8.3" } axum = { version = "0.8.3" }
@@ -11,12 +12,13 @@ tokio = { version = "1.44.1", features = ["rt-multi-thread"] }
tracing-subscriber = { version = "0.3.19" } tracing-subscriber = { version = "0.3.19" }
tower = { version = "0.5.2" } tower = { version = "0.5.2" }
hyper = { version = "1.6.0" } 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" } dotenvy = { version = "0.15.7" }
uuid = { version = "1.16.0", features = ["v4", "serde"] } uuid = { version = "1.16.0", features = ["v4", "serde"] }
argon2 = { version = "0.5.3", features = ["std"] } # Use the latest 0.5.x version argon2 = { version = "0.5.3", features = ["std"] } # Use the latest 0.5.x version
rand = { version = "0.9" } 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] [dev-dependencies]
http-body-util = { version = "0.1.3" } http-body-util = { version = "0.1.3" }

View File

@@ -5,5 +5,18 @@ CREATE TABLE IF NOT EXISTS "user" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL, username TEXT NOT NULL,
password 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
); );

View File

@@ -1,46 +1,104 @@
use axum::{Json, http::StatusCode}; use axum::{Json, http::StatusCode};
use crate::models; use crate::hashing;
use crate::repo; use crate::repo;
mod response { pub mod request {
use serde::{Deserialize, Serialize}; 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)] #[derive(Deserialize, Serialize)]
pub struct Response { pub struct Response {
pub message: String, pub message: String,
pub data: models::common::User, pub data: Vec<icarus_models::user::User>,
} }
} }
pub async fn register_user( pub async fn register_user(
axum::Extension(pool): axum::Extension<sqlx::PgPool>, axum::Extension(pool): axum::Extension<sqlx::PgPool>,
Json(payload): Json<models::common::CreateUser>, Json(payload): Json<request::Request>,
) -> (StatusCode, Json<response::Response>) { ) -> (StatusCode, Json<response::Response>) {
let mut user = models::common::User { let mut user = icarus_models::user::User {
id: uuid::Uuid::nil(), id: uuid::Uuid::nil(),
username: payload.username.clone(), username: payload.username.clone(),
password: payload.password.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 { match repo::user::exists(&pool, &user.username).await {
Ok(id) => { Ok(res) => {
user.id = id; if res {
( (
StatusCode::CREATED, StatusCode::NOT_FOUND,
Json(response::Response { Json(response::Response {
message: String::from("User inserted"), message: String::from("Error"),
data: user, 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) => ( Err(err) => (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(response::Response { Json(response::Response {
message: err.to_string(), message: err.to_string(),
data: user, data: vec![user],
}), }),
), ),
} }

View File

@@ -8,12 +8,18 @@ use argon2::{
}, },
}; };
pub fn hash_password(password: &String) -> Result<String, argon2::password_hash::Error> { pub fn generate_salt() -> Result<SaltString, argon2::Error> {
let password_bytes = password.as_bytes();
// Generate a random salt // Generate a random salt
// SaltString::generate uses OsRng internally for cryptographic security // SaltString::generate uses OsRng internally for cryptographic security
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
Ok(salt)
}
pub fn hash_password(
password: &String,
salt: &SaltString,
) -> Result<String, argon2::password_hash::Error> {
let password_bytes = password.as_bytes();
// Create an Argon2 instance with default parameters (recommended) // Create an Argon2 instance with default parameters (recommended)
// You could customize parameters here if needed, but defaults are strong // You could customize parameters here if needed, but defaults are strong
@@ -22,7 +28,7 @@ pub fn hash_password(password: &String) -> Result<String, argon2::password_hash:
// Hash the password with the salt // Hash the password with the salt
// The output is a PasswordHash string format that includes algorithm, version, // The output is a PasswordHash string format that includes algorithm, version,
// parameters, salt, and the hash itself. // parameters, salt, and the hash itself.
let password_hash = argon2.hash_password(password_bytes, &salt)?.to_string(); let password_hash = argon2.hash_password(password_bytes, salt)?.to_string();
Ok(password_hash) Ok(password_hash)
} }
@@ -56,7 +62,8 @@ mod tests {
#[test] #[test]
fn test_hash_password() { fn test_hash_password() {
let some_password = String::from("somethingrandom"); let some_password = String::from("somethingrandom");
match hash_password(&some_password) { let salt = generate_salt().unwrap();
match hash_password(&some_password, &salt) {
Ok(p) => match verify_password(&some_password, p.clone()) { Ok(p) => match verify_password(&some_password, p.clone()) {
Ok(res) => { Ok(res) => {
assert_eq!(res, true); assert_eq!(res, true);

View File

@@ -1,7 +1,6 @@
pub mod callers; pub mod callers;
pub mod config; pub mod config;
pub mod hashing; pub mod hashing;
pub mod models;
pub mod repo; pub mod repo;
pub mod keys { pub mod keys {

View File

@@ -65,16 +65,9 @@ mod tests {
http::{Request, StatusCode}, http::{Request, StatusCode},
}; };
use http_body_util::BodyExt; use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tower::ServiceExt; // for `call`, `oneshot`, and `ready` 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 { mod db_mgr {
use std::str::FromStr; use std::str::FromStr;
@@ -191,14 +184,22 @@ mod tests {
let app = init::routes().await.layer(axum::Extension(pool)); 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"), username: String::from("somethingsss"),
password: String::from("Raindown!"), 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!({ let payload = json!({
"username": &usr.username, "username": &usr.username,
"password": &usr.password, "password": &usr.password,
"email": &usr.email,
"phone": &usr.phone,
"firstname": &usr.firstname,
"lastname": &usr.lastname,
}); });
let response = app let response = app
@@ -214,16 +215,27 @@ mod tests {
match response { match response {
Ok(resp) => { 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) let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await .await
.unwrap(); .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!( assert_eq!(
usr.username, parsed_body.data.username, usr.username, returned_usr.username,
"Usernames do not match" "Usernames do not match"
); );
assert!(returned_usr.date_created.is_some(), "Date Created is empty");
} }
Err(err) => { Err(err) => {
assert!(false, "Error: {:?}", err.to_string()); assert!(false, "Error: {:?}", err.to_string());

View File

@@ -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,
}

View File

@@ -1 +0,0 @@
pub mod common;

View File

@@ -1,20 +1,104 @@
pub mod user { 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<time::OffsetDateTime>,
}
pub async fn exists(pool: &sqlx::PgPool, username: &String) -> Result<bool, sqlx::Error> {
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( pub async fn insert(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
user: &models::common::User, user: &icarus_models::user::User,
) -> Result<uuid::Uuid, sqlx::Error> { ) -> Result<uuid::Uuid, sqlx::Error> {
let insert_sql = "INSERT INTO \"user\" (username, password) VALUES ($1, $2) RETURNING id"; let row = sqlx::query(
r#"
match sqlx::query_scalar(insert_sql) INSERT INTO "user" (username, password, email, phone, firstname, lastname, email_verified, status, salt_id)
.bind(&user.username) // Bind the input message securely VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, date_created;
"#)
.bind(&user.username)
.bind(&user.password) .bind(&user.password)
.fetch_one(pool) // Execute and expect exactly ONE row with ONE column back .bind(&user.email)
.await .bind(&user.phone)
{ .bind(&user.firstname)
Ok(o) => Ok(o), .bind(&user.lastname)
Err(err) => Err(err), // _ => uuid::Uuid::nil(), .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<uuid::Uuid, sqlx::Error> {
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)
} }
} }
} }