From d96f2c27e001675177b6ae09b679887fd802fd51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 09:50:25 +0000 Subject: [PATCH] feat(auth): add password hashing, token generation, and admin bootstrap Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com> --- Cargo.lock | 35 ++++++++++++ Cargo.toml | 3 ++ src/cli/mod.rs | 3 ++ src/infrastructure/auth.rs | 106 +++++++++++++++++++++++++++++++++++++ src/infrastructure/mod.rs | 1 + src/main.rs | 1 + src/server/server.rs | 64 +++++++++++++++++++++- 7 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 src/infrastructure/auth.rs diff --git a/Cargo.lock b/Cargo.lock index f55c62f..4acb31d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,18 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[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 = "askama" version = "0.12.1" @@ -284,6 +296,15 @@ dependencies = [ "serde_core", ] +[[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" @@ -309,9 +330,11 @@ name = "brewlog" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "askama", "async-trait", "axum", + "base64 0.22.1", "block-id", "chrono", "clap", @@ -320,6 +343,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "sqlx", "thiserror 1.0.69", "tokio", @@ -1518,6 +1542,17 @@ dependencies = [ "windows-link", ] +[[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 = "paste" version = "1.0.15" diff --git a/Cargo.toml b/Cargo.toml index 9827c75..1b9388f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,11 @@ postgres = ["sqlx/postgres"] [dependencies] anyhow = "1.0" +argon2 = "0.5" async-trait = "0.1" axum = { version = "0.7", features = ["macros"] } askama = "0.12" +base64 = "0.22" block-id = "0.2.1" chrono = { version = "0.4", features = ["serde", "clock"] } clap = { version = "4.5", features = ["derive", "env"] } @@ -21,6 +23,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" once_cell = "1.19" rand = "0.8" +sha2 = "0.10" sqlx = { version = "0.7", default-features = false, features = [ "runtime-tokio", "macros", diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c159727..75be470 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -61,6 +61,9 @@ pub struct ServeCommand { #[arg(long, env = "BREWLOG_BIND_ADDRESS", default_value = "127.0.0.1:3000")] pub bind_address: SocketAddr, + + #[arg(long, env = "BREWLOG_ADMIN_PASSWORD")] + pub admin_password: Option, } pub(crate) fn print_json(value: &T) -> anyhow::Result<()> diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs new file mode 100644 index 0000000..af2cb56 --- /dev/null +++ b/src/infrastructure/auth.rs @@ -0,0 +1,106 @@ +use anyhow::Result; +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use base64::{Engine as _, engine::general_purpose}; +use rand::{RngCore, rngs::OsRng}; +use sha2::{Digest, Sha256}; + +/// Hashes a password using Argon2id with secure defaults +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("failed to hash password: {}", e))? + .to_string(); + + Ok(password_hash) +} + +/// Verifies a password against a hash +pub fn verify_password(password: &str, password_hash: &str) -> Result { + let parsed_hash = PasswordHash::new(password_hash) + .map_err(|e| anyhow::anyhow!("failed to parse password hash: {}", e))?; + + let argon2 = Argon2::default(); + + match argon2.verify_password(password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } +} + +/// Generates a cryptographically secure random token +/// Returns a base64-encoded token string +pub fn generate_token() -> Result { + let mut token_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut token_bytes); + Ok(general_purpose::STANDARD.encode(&token_bytes)) +} + +/// Hashes a token for storage using SHA-256 +pub fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let result = hasher.finalize(); + general_purpose::STANDARD.encode(&result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_password_hashing() { + let password = "test_password_123"; + let hash = hash_password(password).unwrap(); + + assert!(verify_password(password, &hash).unwrap()); + assert!(!verify_password("wrong_password", &hash).unwrap()); + } + + #[test] + fn test_password_hashing_different_salts() { + let password = "test_password_123"; + let hash1 = hash_password(password).unwrap(); + let hash2 = hash_password(password).unwrap(); + + // Different salts should produce different hashes + assert_ne!(hash1, hash2); + + // But both should verify the same password + assert!(verify_password(password, &hash1).unwrap()); + assert!(verify_password(password, &hash2).unwrap()); + } + + #[test] + fn test_token_generation() { + let token1 = generate_token().unwrap(); + let token2 = generate_token().unwrap(); + + // Tokens should be different + assert_ne!(token1, token2); + + // Tokens should be base64 encoded (at least 40 chars for 32 bytes) + assert!(token1.len() >= 40); + assert!(token2.len() >= 40); + } + + #[test] + fn test_token_hashing() { + let token = "test_token_12345"; + let hash1 = hash_token(token); + let hash2 = hash_token(token); + + // Same token should produce same hash + assert_eq!(hash1, hash2); + + // Different token should produce different hash + let different_token = "different_token"; + let hash3 = hash_token(different_token); + assert_ne!(hash1, hash3); + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index e607629..e8850de 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,2 +1,3 @@ +pub mod auth; pub mod database; pub mod repositories; diff --git a/src/main.rs b/src/main.rs index 394e1a7..3184a72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,7 @@ async fn run_server(command: ServeCommand) -> Result<()> { let config = ServerConfig { bind_address: command.bind_address, database_url: command.database_url, + admin_password: command.admin_password, }; serve(config).await diff --git a/src/server/server.rs b/src/server/server.rs index a68207f..a4255fd 100644 --- a/src/server/server.rs +++ b/src/server/server.rs @@ -3,20 +3,27 @@ use std::sync::Arc; use anyhow::Context; use axum::Router; +use chrono::Utc; use tokio::net::TcpListener; use tokio::signal; use tracing::info; -use crate::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository}; +use crate::domain::ids::generate_id; +use crate::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository, TokenRepository, UserRepository}; +use crate::domain::users::User; +use crate::infrastructure::auth::hash_password; use crate::infrastructure::database::Database; use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository; use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; +use crate::infrastructure::repositories::tokens::SqlTokenRepository; +use crate::infrastructure::repositories::users::SqlUserRepository; use crate::server::routes::app_router; pub struct ServerConfig { pub bind_address: SocketAddr, pub database_url: String, + pub admin_password: Option, } #[derive(Clone)] @@ -24,6 +31,8 @@ pub struct AppState { pub roaster_repo: Arc, pub roast_repo: Arc, pub timeline_repo: Arc, + pub user_repo: Arc, + pub token_repo: Arc, } impl AppState { @@ -31,11 +40,15 @@ impl AppState { roaster_repo: Arc, roast_repo: Arc, timeline_repo: Arc, + user_repo: Arc, + token_repo: Arc, ) -> Self { Self { roaster_repo, roast_repo, timeline_repo, + user_repo, + token_repo, } } } @@ -49,7 +62,13 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); - let state = AppState::new(roaster_repo, roast_repo, timeline_repo); + let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); + let token_repo: Arc = Arc::new(SqlTokenRepository::new(database.clone_pool())); + + // Bootstrap admin user if no users exist + bootstrap_admin_user(&user_repo, config.admin_password).await?; + + let state = AppState::new(roaster_repo, roast_repo, timeline_repo, user_repo, token_repo); let listener = TcpListener::bind(config.bind_address) .await @@ -69,6 +88,47 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { Ok(()) } +async fn bootstrap_admin_user( + user_repo: &Arc, + admin_password: Option, +) -> anyhow::Result<()> { + // Check if any users exist + let users_exist = user_repo.exists().await + .context("failed to check if users exist")?; + + if users_exist { + // Users already exist, no need to bootstrap + return Ok(()); + } + + // No users exist - we need to create the admin user + let password = admin_password.ok_or_else(|| { + anyhow::anyhow!( + "No users exist in the database. Please provide BREWLOG_ADMIN_PASSWORD \ + environment variable to create the admin user." + ) + })?; + + info!("No users found. Creating admin user..."); + + let password_hash = hash_password(&password) + .context("failed to hash admin password")?; + + let admin_user = User::new( + generate_id(), + "admin".to_string(), + password_hash, + Utc::now(), + ); + + user_repo.insert(admin_user).await + .context("failed to create admin user")?; + + info!("Admin user created successfully"); + + Ok(()) +} + async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c()