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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
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 database;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue