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:
parent
ca5d25ea10
commit
d96f2c27e0
7 changed files with 211 additions and 2 deletions
35
Cargo.lock
generated
35
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn print_json<T>(value: &T) -> anyhow::Result<()>
|
||||
|
|
|
|||
106
src/infrastructure/auth.rs
Normal file
106
src/infrastructure/auth.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod auth;
|
||||
pub mod database;
|
||||
pub mod repositories;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -24,6 +31,8 @@ pub struct AppState {
|
|||
pub roaster_repo: Arc<dyn RoasterRepository>,
|
||||
pub roast_repo: Arc<dyn RoastRepository>,
|
||||
pub timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
pub user_repo: Arc<dyn UserRepository>,
|
||||
pub token_repo: Arc<dyn TokenRepository>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -31,11 +40,15 @@ impl AppState {
|
|||
roaster_repo: Arc<dyn RoasterRepository>,
|
||||
roast_repo: Arc<dyn RoastRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
token_repo: Arc<dyn TokenRepository>,
|
||||
) -> 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<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)
|
||||
.await
|
||||
|
|
@ -69,6 +88,47 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
|||
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() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
|
|
|
|||
Loading…
Reference in a new issue