diff --git a/server/Cargo.lock b/server/Cargo.lock index 4a6c987..ee4f6c7 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -47,6 +47,18 @@ dependencies = [ "libc", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "atoi" version = "2.0.0" @@ -192,6 +204,15 @@ dependencies = [ "serde", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1191,6 +1212,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1642,6 +1674,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", ] [[package]] @@ -1722,6 +1755,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -1759,6 +1793,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -1783,6 +1818,7 @@ dependencies = [ "sqlx-core", "tracing", "url", + "uuid", ] [[package]] @@ -2096,6 +2132,10 @@ name = "uuid" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4" +dependencies = [ + "getrandom 0.2.15", + "rand 0.8.5", +] [[package]] name = "validator" @@ -2426,6 +2466,7 @@ dependencies = [ name = "wrbapp_server" version = "0.1.0" dependencies = [ + "argon2", "axum", "axum-extra", "chrono", diff --git a/server/Cargo.toml b/server/Cargo.toml index 4c01c95..655928b 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -8,20 +8,21 @@ edition = "2021" axum = { version = "0.8", features = [ "macros", "json" ] } axum-extra = { version = "0.10.0", features = [ "typed-header" ] } tokio = { version = "1.43", features = [ "rt-multi-thread", "macros" ] } -sqlx = { version = "0.8", features = [ "runtime-tokio", "postgres" ] } +sqlx = { version = "0.8", features = [ "runtime-tokio", "postgres", "uuid" ] } # Secondary crates csv = { version = "1.3" } serde = "1.0" dotenvy = "0.15.7" validator = { version = "0.19.0", features = [ "derive" ] } +argon2 = "0.5" # Tertiary crates tracing = "0.1" tracing-subscriber = "0.3" chrono = "0.4.39" -uuid = "1.12.0" +uuid = { version = "1.12.0", features = ["v4", "fast-rng"] } serde_json = "1.0.137" rand = "0.9" thiserror = { version = "2.0" } diff --git a/server/src/auth.rs b/server/src/auth.rs index 4f55ba8..3911404 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -1,5 +1,9 @@ use std::collections::HashSet; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, + Argon2, +}; use axum::{extract::FromRequestParts, http::request::Parts, RequestPartsExt}; use axum_extra::{ headers::{authorization::Bearer, Authorization}, @@ -8,6 +12,7 @@ use axum_extra::{ }; use bearer::verify_bearer; pub use error::AuthError; +use tokio::task; mod bearer; mod error; @@ -46,3 +51,24 @@ where Err(AuthError::Unexpected.into()) } } + +pub async fn generate_password_hash( + password: String, +) -> Result { + let password_hash: Result = + task::spawn_blocking(move || { + let salt = SaltString::generate(&mut OsRng); + + let argon2 = Argon2::default(); + + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt)? + .to_string(); + + Ok(password_hash) + }) + .await + .unwrap(); + + Ok(password_hash?) +} diff --git a/server/src/database/model.rs b/server/src/database/model.rs index 2c64782..17c301f 100644 --- a/server/src/database/model.rs +++ b/server/src/database/model.rs @@ -3,3 +3,5 @@ pub mod session; pub mod user; pub use member::Member; +pub use user::User; +pub use user::UserMember; diff --git a/server/src/database/model/member.rs b/server/src/database/model/member.rs index e4e8367..ae3d397 100644 --- a/server/src/database/model/member.rs +++ b/server/src/database/model/member.rs @@ -2,7 +2,7 @@ use rand::distr::{Alphanumeric, SampleString}; use sqlx::{PgPool, Postgres, QueryBuilder}; use validator::Validate; -#[derive(Debug, Validate)] +#[derive(Debug, Validate, sqlx::FromRow)] pub struct Member { #[validate(length(equal = 7))] pub member_id: String, @@ -14,7 +14,40 @@ pub struct Member { pub groups: Vec, } +pub struct SearchMember { + pub registration_tokens: Option>, +} + impl Member { + pub async fn search( + transaction: &PgPool, + search: SearchMember, + ) -> Result, sqlx::Error> { + if let None = search.registration_tokens { + return Err(sqlx::Error::RowNotFound); + } + + let mut query = QueryBuilder::new("SELECT * from members WHERE 1=1"); + + if let Some(registration_tokens) = search.registration_tokens { + if registration_tokens.len() == 0 { + return Err(sqlx::Error::RowNotFound); + } + + query.push(" AND registration_token = ANY("); + query.push_bind(registration_tokens); + query.push(")"); + } + + let members: Vec = query.build_query_as().fetch_all(transaction).await?; + + if members.len() == 0 { + return Err(sqlx::Error::RowNotFound); + } + + Ok(members) + } + pub async fn get_many(transaction: &PgPool, members: Vec) -> Result<(), sqlx::Error> { Ok(()) } diff --git a/server/src/database/model/user.rs b/server/src/database/model/user.rs index e88c096..a77cfc5 100644 --- a/server/src/database/model/user.rs +++ b/server/src/database/model/user.rs @@ -1,8 +1,68 @@ +use sqlx::Postgres; + #[derive(validator::Validate)] -struct User { - pub id: uuid::Uuid, +pub struct User { + pub user_id: uuid::Uuid, #[validate(email)] pub email: String, pub password: String, pub admin: bool, } + +impl User { + pub async fn insert( + transaction: &mut sqlx::Transaction<'_, Postgres>, + email: &str, + password: &str, + ) -> Result { + let user_id = uuid::Uuid::new_v4(); + + sqlx::query!( + " + INSERT INTO users ( + user_id, email, password, admin + ) + VALUES ( + $1, $2, $3, $4 + ); + ", + &user_id, + email, + password, + false + ) + .execute(&mut **transaction) + .await?; + + Ok(user_id) + } +} + +#[derive(Debug)] +pub struct UserMember { + user_id: uuid::Uuid, + member_id: String, +} + +impl UserMember { + pub async fn insert_many( + transaction: &mut sqlx::Transaction<'_, Postgres>, + user_ids: &Vec, + member_ids: &Vec, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO users_members ( + user_id, member_id + ) + SELECT * FROM UNNEST($1::uuid[], $2::varchar[]) + ", + &user_ids[..], + &member_ids[..] + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } +} diff --git a/server/src/main.rs b/server/src/main.rs index a84fcd6..0322d08 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -38,7 +38,7 @@ async fn main() { }; // Serve app - let app = Router::new().merge(routes()).with_state(app_state); + let app = Router::new().nest("/v1", routes()).with_state(app_state); let listener = TcpListener::bind("127.0.0.1:3000") .await diff --git a/server/src/routes.rs b/server/src/routes.rs index 4e9b0b5..faa1270 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -1,12 +1,5 @@ -use axum::{ - extract::State, - http::StatusCode, - routing::{get, post}, - Router, -}; -use member::migrate::{migrate_confirm, migrate_request}; - use crate::{auth::Permissions, AppState}; +use axum::{extract::State, http::StatusCode, routing::get, Router}; pub mod auth; pub mod member; @@ -16,8 +9,8 @@ pub fn routes() -> Router { Router::new() .route("/", get(root)) // .route("/member/:id", get()) - .route("/members/migrate_request", post(migrate_request)) - .route("/members/migrate_confirm", post(migrate_confirm)) + .merge(member::routes()) + .merge(auth::routes()) } async fn root( diff --git a/server/src/routes/auth.rs b/server/src/routes/auth.rs index 8b13789..d5321ed 100644 --- a/server/src/routes/auth.rs +++ b/server/src/routes/auth.rs @@ -1 +1,67 @@ +use axum::{extract::State, routing::post, Json, Router}; +use crate::database::model::member::SearchMember; +use crate::database::model::Member as DbMember; +use crate::database::model::User as DbUser; +use crate::database::model::UserMember as DbUserMember; +use crate::{ + auth::{generate_password_hash, Permissions}, + AppState, +}; + +pub fn routes() -> Router { + Router::new() + .route("/auth/login", post(login)) + .route("/auth/register", post(register)) +} + +pub async fn login<'a>( + State(state): State, + permissions: Permissions<'a>, + body: String, +) -> Result<(), crate::Error> { + Ok(()) +} + +#[derive(serde::Deserialize)] +pub struct AuthRequest { + email: String, + password: String, + registration_tokens: Vec, +} + +pub async fn register<'a>( + State(state): State, + permissions: Permissions<'a>, + Json(auth_request): Json, +) -> Result<(), crate::Error> { + // Get all the members to link with the user + let members = DbMember::search( + &state.pool, + SearchMember { + registration_tokens: Some(auth_request.registration_tokens), + }, + ) + .await?; + + let member_ids: Vec = members.into_iter().map(|m| m.member_id).collect(); + + // Hash password + let password_hash = match generate_password_hash(auth_request.password).await { + Ok(hash) => hash, + Err(_err) => return Err(crate::Error::Auth(crate::auth::AuthError::InvalidToken)), + }; + + let mut transaction = state.pool.begin().await?; + + // Insert the user to the database + let user_id = DbUser::insert(&mut transaction, &auth_request.email, &password_hash).await?; + + // Link the user to the members + let user_ids: Vec = vec![user_id; member_ids.len()]; + DbUserMember::insert_many(&mut transaction, &user_ids, &member_ids).await?; + + transaction.commit().await?; + + Ok(()) +} diff --git a/server/src/routes/member.rs b/server/src/routes/member.rs index be99d40..e621c87 100644 --- a/server/src/routes/member.rs +++ b/server/src/routes/member.rs @@ -1 +1,11 @@ +use axum::{routing::post, Router}; + +use crate::AppState; + pub mod migrate; + +pub fn routes() -> Router { + Router::new() + .route("/members/migrate_request", post(migrate::migrate_request)) + .route("/members/migrate_confirm", post(migrate::migrate_confirm)) +}