diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml index e731d39..2e73646 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -5,9 +5,15 @@ edition = "2024" [dependencies] anyhow = "1.0.100" +argon2 = "0.5.3" axum = "0.8.7" -serde = "1.0.228" +password-hash = "0.5.0" +rand_core = { version = "0.6", features = ["getrandom"] } +serde = { version = "1.0.228", features = ["derive", "serde_derive"] } serde_json = "1.0.145" tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } +tower = { version = "0.5.2", features = ["tokio", "tracing"] } +tower-http = { version = "0.6.7", features = ["cors"] } tracing = { version = "0.1.43", features = ["max_level_debug"] } tracing-subscriber = "0.3.22" +validator = { version = "0.20.0", features = ["derive"] } diff --git a/src/backend/src/auth/mod.rs b/src/backend/src/auth/mod.rs new file mode 100644 index 0000000..31427f3 --- /dev/null +++ b/src/backend/src/auth/mod.rs @@ -0,0 +1,15 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; +use tracing::debug; + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt)? + .to_string(); + debug!("Hashed password {}", hash); + Ok(hash) +} diff --git a/src/backend/src/domain/mod.rs b/src/backend/src/domain/mod.rs new file mode 100644 index 0000000..22d12a3 --- /dev/null +++ b/src/backend/src/domain/mod.rs @@ -0,0 +1 @@ +pub mod user; diff --git a/src/backend/src/domain/user.rs b/src/backend/src/domain/user.rs new file mode 100644 index 0000000..cd6c53a --- /dev/null +++ b/src/backend/src/domain/user.rs @@ -0,0 +1,71 @@ +use std::fmt::Display; + +use axum::response::IntoResponse; +use serde::{Deserialize, Serialize}; + +use crate::auth; + +#[derive(Debug, Clone, Deserialize)] +pub struct NewUser { + username: String, + email: Option, + password: String, + first_name: Option, + last_name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct InternalUser { + username: String, + email: Option, + password_hash: String, + first_name: Option, + last_name: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct User { + username: String, + email: Option, + first_name: Option, + last_name: Option, +} + +#[derive(Debug)] +pub enum UserConversionError { + HashFailed(password_hash::Error), +} + +impl TryFrom for InternalUser { + type Error = UserConversionError; + fn try_from(value: NewUser) -> Result { + let password_hash = + auth::hash_password(&value.password).map_err(|e| UserConversionError::HashFailed(e))?; + Ok(Self { + username: value.username, + email: value.email, + password_hash: password_hash, + first_name: value.first_name, + last_name: value.last_name, + }) + } +} + +impl From for User { + fn from(value: InternalUser) -> Self { + Self { + username: value.username, + email: value.email, + first_name: value.first_name, + last_name: value.last_name, + } + } +} + +impl Display for UserConversionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UserConversionError::HashFailed(e) => write!(f, "failed to hash password: {e}"), + } + } +} diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index fcddb5c..8e257ab 100644 --- a/src/backend/src/lib.rs +++ b/src/backend/src/lib.rs @@ -1 +1,4 @@ +pub mod auth; +pub mod domain; pub mod router; +pub mod state; diff --git a/src/backend/src/main.rs b/src/backend/src/main.rs index 32a54ac..046b38d 100644 --- a/src/backend/src/main.rs +++ b/src/backend/src/main.rs @@ -1,5 +1,7 @@ +use std::sync::Arc; + use anyhow::{Ok, Result}; -use rustymine_daemon::router; +use rustymine_daemon::{router, state::AppState}; use tracing::Level; #[tokio::main] @@ -10,9 +12,12 @@ async fn main() -> Result<()> { tracing::subscriber::set_global_default(subscriber)?; - let app_result = router::init_router().await?; + let state = Arc::new(AppState::new()); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - axum::serve(listener, app_result).await.unwrap(); + let app_result = router::init_router(state.clone()).await; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; + + axum::serve(listener, app_result).await?; Ok(()) } diff --git a/src/backend/src/router/middleware.rs b/src/backend/src/router/middleware.rs index 63b0b88..63d816c 100644 --- a/src/backend/src/router/middleware.rs +++ b/src/backend/src/router/middleware.rs @@ -1,9 +1,24 @@ -use axum::{extract::Request, middleware::Next, response::IntoResponse}; +use axum::{ + Router, + extract::Request, + http::{Method, header::AUTHORIZATION}, + middleware::Next, + response::IntoResponse, +}; +use tower_http::cors::{Any, CorsLayer}; use tracing::{debug, error, info, warn}; -pub async fn auth_middleware(request: Request, next: Next) -> impl IntoResponse { +pub async fn auth(request: Request, next: Next) -> impl IntoResponse { debug!("auth_middleware entry"); let response = next.run(request).await; debug!("auth_middleware exit"); response } + +pub fn cors() -> CorsLayer { + debug!("Generating CorsLayer"); + CorsLayer::new() + .allow_origin(Any) + .allow_methods([Method::GET, Method::POST]) + .allow_headers([AUTHORIZATION]) +} diff --git a/src/backend/src/router/mod.rs b/src/backend/src/router/mod.rs index e62a411..6ae9a94 100644 --- a/src/backend/src/router/mod.rs +++ b/src/backend/src/router/mod.rs @@ -1,14 +1,33 @@ pub mod middleware; -use anyhow::Result; -use axum::{Json, Router, http::StatusCode, middleware::from_fn, routing::get}; -use serde_json::{Value, json}; +pub mod user_routes; -pub async fn init_router() -> Result { - let router = Router::new().route( - "/api/ping", - get(ping).layer(from_fn(middleware::auth_middleware)), - ); - Ok(router) +use axum::{ + Json, Router, + http::StatusCode, + routing::{get, post}, +}; +use serde_json::{Value, json}; +use std::sync::Arc; +use tower::{Layer, ServiceBuilder}; + +use crate::state::AppState; + +pub async fn init_router(app_state: Arc) -> Router { + Router::new() + .route( + "/api/ping", + get(ping).layer( + ServiceBuilder::new() + .layer(middleware::cors()) + .layer(axum::middleware::from_fn(middleware::auth)), + ), + ) + .route( + "/api/user/create", + post(user_routes::create) + .layer(ServiceBuilder::new().layer(middleware::cors())) + .with_state(app_state), + ) } async fn ping() -> Result, StatusCode> { diff --git a/src/backend/src/router/user_routes.rs b/src/backend/src/router/user_routes.rs new file mode 100644 index 0000000..507eee1 --- /dev/null +++ b/src/backend/src/router/user_routes.rs @@ -0,0 +1,24 @@ +use std::sync::Arc; + +use axum::{Json, extract::State, http::StatusCode}; +use serde_json::json; +use tracing::{debug, error, info, warn}; + +use crate::{ + domain::user::{InternalUser, NewUser, User}, + state::AppState, +}; + +pub async fn create( + State(state): State>, + Json(new_user): Json, +) -> Result, StatusCode> { + let internal = InternalUser::try_from(new_user).map_err(|e| { + error!("Conversion to InternalUser failed: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let user = User::from(internal); + + Ok(Json(user)) +} diff --git a/src/backend/src/state.rs b/src/backend/src/state.rs new file mode 100644 index 0000000..3ef98b2 --- /dev/null +++ b/src/backend/src/state.rs @@ -0,0 +1,15 @@ +use std::collections::HashMap; + +use crate::domain::user::InternalUser; + +pub struct AppState { + pub users: HashMap, +} + +impl AppState { + pub fn new() -> Self { + Self { + users: HashMap::new(), + } + } +}