feat(auth): add password hashing, token generation, and admin bootstrap

Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-25 09:50:25 +00:00 committed by Jon Seager
parent ca5d25ea10
commit d96f2c27e0
No known key found for this signature in database
7 changed files with 211 additions and 2 deletions

35
Cargo.lock generated
View file

@ -95,6 +95,18 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 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]] [[package]]
name = "askama" name = "askama"
version = "0.12.1" version = "0.12.1"
@ -284,6 +296,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -309,9 +330,11 @@ name = "brewlog"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2",
"askama", "askama",
"async-trait", "async-trait",
"axum", "axum",
"base64 0.22.1",
"block-id", "block-id",
"chrono", "chrono",
"clap", "clap",
@ -320,6 +343,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx", "sqlx",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
@ -1518,6 +1542,17 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"

View file

@ -10,9 +10,11 @@ postgres = ["sqlx/postgres"]
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
argon2 = "0.5"
async-trait = "0.1" async-trait = "0.1"
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros"] }
askama = "0.12" askama = "0.12"
base64 = "0.22"
block-id = "0.2.1" block-id = "0.2.1"
chrono = { version = "0.4", features = ["serde", "clock"] } chrono = { version = "0.4", features = ["serde", "clock"] }
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
@ -21,6 +23,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
once_cell = "1.19" once_cell = "1.19"
rand = "0.8" rand = "0.8"
sha2 = "0.10"
sqlx = { version = "0.7", default-features = false, features = [ sqlx = { version = "0.7", default-features = false, features = [
"runtime-tokio", "runtime-tokio",
"macros", "macros",

View file

@ -61,6 +61,9 @@ pub struct ServeCommand {
#[arg(long, env = "BREWLOG_BIND_ADDRESS", default_value = "127.0.0.1:3000")] #[arg(long, env = "BREWLOG_BIND_ADDRESS", default_value = "127.0.0.1:3000")]
pub bind_address: SocketAddr, pub bind_address: SocketAddr,
#[arg(long, env = "BREWLOG_ADMIN_PASSWORD")]
pub admin_password: Option<String>,
} }
pub(crate) fn print_json<T>(value: &T) -> anyhow::Result<()> pub(crate) fn print_json<T>(value: &T) -> anyhow::Result<()>

106
src/infrastructure/auth.rs Normal file
View file

@ -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<String> {
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<bool> {
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<String> {
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);
}
}

View file

@ -1,2 +1,3 @@
pub mod auth;
pub mod database; pub mod database;
pub mod repositories; pub mod repositories;

View file

@ -40,6 +40,7 @@ async fn run_server(command: ServeCommand) -> Result<()> {
let config = ServerConfig { let config = ServerConfig {
bind_address: command.bind_address, bind_address: command.bind_address,
database_url: command.database_url, database_url: command.database_url,
admin_password: command.admin_password,
}; };
serve(config).await serve(config).await

View file

@ -3,20 +3,27 @@ use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use axum::Router; use axum::Router;
use chrono::Utc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::signal; use tokio::signal;
use tracing::info; 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::database::Database;
use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
use crate::infrastructure::repositories::roasts::SqlRoastRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository;
use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; 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; use crate::server::routes::app_router;
pub struct ServerConfig { pub struct ServerConfig {
pub bind_address: SocketAddr, pub bind_address: SocketAddr,
pub database_url: String, pub database_url: String,
pub admin_password: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -24,6 +31,8 @@ pub struct AppState {
pub roaster_repo: Arc<dyn RoasterRepository>, pub roaster_repo: Arc<dyn RoasterRepository>,
pub roast_repo: Arc<dyn RoastRepository>, pub roast_repo: Arc<dyn RoastRepository>,
pub timeline_repo: Arc<dyn TimelineEventRepository>, pub timeline_repo: Arc<dyn TimelineEventRepository>,
pub user_repo: Arc<dyn UserRepository>,
pub token_repo: Arc<dyn TokenRepository>,
} }
impl AppState { impl AppState {
@ -31,11 +40,15 @@ impl AppState {
roaster_repo: Arc<dyn RoasterRepository>, roaster_repo: Arc<dyn RoasterRepository>,
roast_repo: Arc<dyn RoastRepository>, roast_repo: Arc<dyn RoastRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>, timeline_repo: Arc<dyn TimelineEventRepository>,
user_repo: Arc<dyn UserRepository>,
token_repo: Arc<dyn TokenRepository>,
) -> Self { ) -> Self {
Self { Self {
roaster_repo, roaster_repo,
roast_repo, roast_repo,
timeline_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 roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
let roast_repo = Arc::new(SqlRoastRepository::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 timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
let state = AppState::new(roaster_repo, roast_repo, timeline_repo); let user_repo: Arc<dyn UserRepository> = Arc::new(SqlUserRepository::new(database.clone_pool()));
let token_repo: Arc<dyn TokenRepository> = 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) let listener = TcpListener::bind(config.bind_address)
.await .await
@ -69,6 +88,47 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn bootstrap_admin_user(
user_repo: &Arc<dyn UserRepository>,
admin_password: Option<String>,
) -> 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() { async fn shutdown_signal() {
let ctrl_c = async { let ctrl_c = async {
signal::ctrl_c() signal::ctrl_c()