feat(server): allow for random admin password generation

This commit is contained in:
perf3ct 2025-11-02 14:29:12 -08:00
parent 63aa7347a9
commit 3486f0fdf8
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
9 changed files with 586 additions and 28 deletions

View File

@ -17,6 +17,25 @@ SERVER_ADDRESS=0.0.0.0:8000
# When disabled, only OIDC authentication is available # When disabled, only OIDC authentication is available
ALLOW_LOCAL_AUTH=true ALLOW_LOCAL_AUTH=true
# Admin User Configuration (optional)
# IMPORTANT: These variables are only used when creating the initial admin user.
# If not set, the system will auto-generate a secure password on first startup.
#
# ADMIN_USERNAME: Custom admin username (default: "admin")
# ADMIN_PASSWORD: Set admin password (min 8 chars). If not set, a secure 24-character
# password will be auto-generated and displayed once at startup.
#
# SECURITY BEST PRACTICES:
# - For production: Leave ADMIN_PASSWORD unset to auto-generate secure password
# - For development: You can set a simple password for convenience
# - For automation: Use secrets management to inject ADMIN_PASSWORD securely
#
# Examples:
# ADMIN_USERNAME=superadmin
# ADMIN_PASSWORD=MySecureP@ssw0rd123!
#
# To reset admin password later, run: readur reset-admin-password
# OIDC Configuration (optional - see docs/oidc-setup.md for details) # OIDC Configuration (optional - see docs/oidc-setup.md for details)
# OIDC_ENABLED=true # OIDC_ENABLED=true
# OIDC_CLIENT_ID=your-client-id # OIDC_CLIENT_ID=your-client-id

3
src/commands/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod reset_admin;
pub use reset_admin::reset_admin_password;

View File

@ -0,0 +1,72 @@
use anyhow::{Context, Result};
use std::env;
use crate::db::Database;
use crate::utils::security::generate_secure_password;
/// Reset the admin user's password
///
/// This command resets the admin user's password to either:
/// 1. A value specified via the ADMIN_PASSWORD environment variable, or
/// 2. A newly generated secure random password (24 characters)
///
/// The admin username defaults to "admin" but can be customized via ADMIN_USERNAME env var.
///
/// # Arguments
/// * `db` - Database connection
///
/// # Returns
/// Result indicating success or failure
pub async fn reset_admin_password(db: &Database) -> Result<()> {
// Get admin username from env var or use default
let admin_username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string());
// Check if admin user exists
let admin_user = db
.get_user_by_username(&admin_username)
.await
.context("Failed to query database for admin user")?;
if admin_user.is_none() {
anyhow::bail!(
"Admin user '{}' not found. Please ensure the user exists before resetting password.",
admin_username
);
}
// Get new password from env var or generate one
let new_password = if let Ok(pwd) = env::var("ADMIN_PASSWORD") {
if pwd.len() < 8 {
anyhow::bail!("ADMIN_PASSWORD must be at least 8 characters long");
}
pwd
} else {
generate_secure_password(24)
};
// Reset the password
db.reset_user_password(&admin_username, &new_password)
.await
.context("Failed to reset admin password")?;
// Display success message with credentials
print_success_message(&admin_username, &new_password);
Ok(())
}
fn print_success_message(username: &str, password: &str) {
println!();
println!("==============================================");
println!(" ADMIN PASSWORD RESET SUCCESSFUL");
println!("==============================================");
println!();
println!("Username: {}", username);
println!("Password: {}", password);
println!();
println!("⚠️ SAVE THESE CREDENTIALS IMMEDIATELY!");
println!("⚠️ This password will not be shown again.");
println!();
println!("==============================================");
println!();
}

View File

@ -319,4 +319,48 @@ impl Database {
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Oidc), auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Oidc),
}) })
} }
/// Reset a user's password by username
///
/// This function is useful for password recovery and admin password resets.
/// The password is automatically hashed with bcrypt before storing.
///
/// # Arguments
/// * `username` - The username of the user whose password should be reset
/// * `new_password` - The new plaintext password (will be hashed)
///
/// # Returns
/// The updated User object, or an error if the user doesn't exist
pub async fn reset_user_password(&self, username: &str, new_password: &str) -> Result<User> {
let password_hash = bcrypt::hash(new_password, 12)?;
let row = sqlx::query(
r#"
UPDATE users
SET password_hash = $2,
updated_at = NOW()
WHERE username = $1
RETURNING id, username, email, password_hash, role, created_at, updated_at,
oidc_subject, oidc_issuer, oidc_email, auth_provider
"#
)
.bind(username)
.bind(&password_hash)
.fetch_one(&self.pool)
.await?;
Ok(User {
id: row.get("id"),
username: row.get("username"),
email: row.get("email"),
password_hash: row.get("password_hash"),
role: row.get::<String, _>("role").try_into().unwrap_or(crate::models::UserRole::User),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
oidc_subject: row.get("oidc_subject"),
oidc_issuer: row.get("oidc_issuer"),
oidc_email: row.get("oidc_email"),
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Local),
})
}
} }

View File

@ -1,4 +1,5 @@
pub mod auth; pub mod auth;
pub mod commands;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod db_guardrails_simple; pub mod db_guardrails_simple;

View File

@ -7,13 +7,33 @@ use std::sync::Arc;
use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}}; use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}};
use tracing::{info, error, warn}; use tracing::{info, error, warn};
use anyhow; use anyhow;
use sqlx::{Row, Column}; use sqlx::Column;
use clap::{Parser, Subcommand};
use readur::{config::Config, db::Database, AppState, *}; use readur::{config::Config, db::Database, AppState, *};
mod commands;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
/// Readur - Document Management System
#[derive(Parser)]
#[command(name = "readur")]
#[command(about = "Readur document management system", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Start the Readur web server (default)
Serve,
/// Reset the admin user's password
ResetAdminPassword,
}
/// Determines the correct path for static files based on the environment /// Determines the correct path for static files based on the environment
/// Checks multiple possible locations in order of preference /// Checks multiple possible locations in order of preference
fn determine_static_files_path() -> std::path::PathBuf { fn determine_static_files_path() -> std::path::PathBuf {
@ -53,6 +73,9 @@ fn determine_static_files_path() -> std::path::PathBuf {
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// Parse CLI arguments
let cli = Cli::parse();
// Initialize logging with custom filters to reduce spam from noisy crates // Initialize logging with custom filters to reduce spam from noisy crates
// Users can override with RUST_LOG environment variable, e.g.: // Users can override with RUST_LOG environment variable, e.g.:
// RUST_LOG=debug cargo run (enable debug for all) // RUST_LOG=debug cargo run (enable debug for all)
@ -71,6 +94,30 @@ async fn main() -> anyhow::Result<()> {
.with_env_filter(env_filter) .with_env_filter(env_filter)
.init(); .init();
// Handle CLI commands
match cli.command {
Some(Commands::ResetAdminPassword) => {
// For reset-admin-password command, we only need database connection
println!("\n🔑 RESET ADMIN PASSWORD");
println!("{}", "=".repeat(60));
// Load minimal config (we only need database URL)
let config = Config::from_env()?;
// Connect to database
let db = Database::new(&config.database_url).await?;
// Run reset command
commands::reset_admin_password(&db).await?;
return Ok(());
}
Some(Commands::Serve) | None => {
// Default: Start the web server
// Continue with normal server startup below
}
}
println!("\n🚀 READUR APPLICATION STARTUP"); println!("\n🚀 READUR APPLICATION STARTUP");
println!("{}", "=".repeat(60)); println!("{}", "=".repeat(60));

View File

@ -1,49 +1,72 @@
use anyhow::Result; use anyhow::Result;
use tracing::info; use tracing::info;
use std::env;
use crate::db::Database; use crate::db::Database;
use crate::models::CreateUser; use crate::models::CreateUser;
use crate::utils::security::generate_secure_password;
pub async fn seed_admin_user(db: &Database) -> Result<()> { pub async fn seed_admin_user(db: &Database) -> Result<()> {
let admin_username = "admin"; // Get admin username from env var or use default
let admin_email = "admin@readur.com"; let admin_username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string());
let admin_password = "readur2024";
// Generate default email based on username
let admin_email = format!("{}@readur.com", admin_username);
// Check if admin user already exists // Check if admin user already exists
match db.get_user_by_username(admin_username).await { match db.get_user_by_username(&admin_username).await {
Ok(Some(_)) => { Ok(Some(_)) => {
info!("✅ ADMIN USER ALREADY EXISTS!"); info!("✅ Admin user '{}' already exists", admin_username);
info!("📧 Email: {}", admin_email); info!("🚀 You can login at http://localhost:8000");
info!("👤 Username: {}", admin_username); info!("💡 To reset the admin password, run: readur reset-admin-password");
info!("🔑 Password: {}", admin_password);
info!("🚀 You can now login to the application at http://localhost:8000");
return Ok(()); return Ok(());
} }
Ok(None) => { Ok(None) => {
// User doesn't exist, create it // User doesn't exist, create it
} }
Err(e) => { Err(e) => {
info!("Error checking for admin user: {}", e); info!("⚠️ Error checking for admin user: {}", e);
} }
} }
// Get password from env var or generate a secure one
let admin_password = if let Ok(pwd) = env::var("ADMIN_PASSWORD") {
if pwd.len() < 8 {
anyhow::bail!("ADMIN_PASSWORD must be at least 8 characters long");
}
pwd
} else {
generate_secure_password(24)
};
let create_user = CreateUser { let create_user = CreateUser {
username: admin_username.to_string(), username: admin_username.clone(),
email: admin_email.to_string(), email: admin_email.clone(),
password: admin_password.to_string(), password: admin_password.clone(),
role: Some(crate::models::UserRole::Admin), role: Some(crate::models::UserRole::Admin),
}; };
match db.create_user(create_user).await { match db.create_user(create_user).await {
Ok(user) => { Ok(user) => {
info!("✅ ADMIN USER CREATED SUCCESSFULLY!"); println!();
info!("📧 Email: {}", admin_email); println!("==============================================");
info!("👤 Username: {}", admin_username); println!(" READUR ADMIN USER CREATED");
info!("🔑 Password: {}", admin_password); println!("==============================================");
info!("🆔 User ID: {}", user.id); println!();
info!("🚀 You can now login to the application at http://localhost:8000"); println!("Username: {}", admin_username);
println!("Email: {}", admin_email);
println!("Password: {}", admin_password);
println!("User ID: {}", user.id);
println!();
println!("⚠️ SAVE THESE CREDENTIALS IMMEDIATELY!");
println!("⚠️ This password will not be shown again.");
println!();
println!("==============================================");
println!();
info!("🚀 You can now login at http://localhost:8000");
info!("💡 To reset the admin password later, run: readur reset-admin-password");
} }
Err(e) => { Err(e) => {
info!("Failed to create admin user: {}", e); info!("Failed to create admin user: {}", e);
} }
} }

View File

@ -3,6 +3,7 @@
use anyhow::Result; use anyhow::Result;
use std::path::{Path, PathBuf, Component}; use std::path::{Path, PathBuf, Component};
use tracing::{warn, debug}; use tracing::{warn, debug};
use rand::Rng;
/// Validate and sanitize file paths to prevent path traversal attacks /// Validate and sanitize file paths to prevent path traversal attacks
pub fn validate_and_sanitize_path(input_path: &str) -> Result<String> { pub fn validate_and_sanitize_path(input_path: &str) -> Result<String> {
@ -225,6 +226,35 @@ pub fn validate_path_within_base(path: &str, base_dir: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Generate a cryptographically secure random password
///
/// Generates a password with the specified length containing:
/// - Uppercase letters (A-Z)
/// - Lowercase letters (a-z)
/// - Numbers (0-9)
/// - Special characters (!@#$%^&*-_=+)
///
/// # Arguments
/// * `length` - The desired password length (minimum 12, recommended 24+)
///
/// # Returns
/// A randomly generated password string
pub fn generate_secure_password(length: usize) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
abcdefghijklmnopqrstuvwxyz\
0123456789\
!@#$%^&*-_=+";
let mut rng = rand::thread_rng();
(0..length)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -349,4 +379,46 @@ mod tests {
// Clean up // Clean up
fs::remove_dir_all(test_base).unwrap_or(()); fs::remove_dir_all(test_base).unwrap_or(());
} }
#[test]
fn test_generate_secure_password_length() {
// Test default length
let password = generate_secure_password(24);
assert_eq!(password.len(), 24, "Password should be exactly 24 characters");
// Test different lengths
let password_12 = generate_secure_password(12);
assert_eq!(password_12.len(), 12, "Password should be exactly 12 characters");
let password_32 = generate_secure_password(32);
assert_eq!(password_32.len(), 32, "Password should be exactly 32 characters");
}
#[test]
fn test_generate_secure_password_character_composition() {
let password = generate_secure_password(100); // Use longer password for better test coverage
// Check for uppercase letters
let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase());
assert!(has_uppercase, "Password should contain at least one uppercase letter");
// Check for lowercase letters
let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase());
assert!(has_lowercase, "Password should contain at least one lowercase letter");
// Check for digits
let has_digit = password.chars().any(|c| c.is_ascii_digit());
assert!(has_digit, "Password should contain at least one digit");
// Check for special characters
let special_chars = "!@#$%^&*-_=+";
let has_special = password.chars().any(|c| special_chars.contains(c));
assert!(has_special, "Password should contain at least one special character");
// Ensure all characters are from the allowed charset
let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+";
for ch in password.chars() {
assert!(charset.contains(ch), "Password contains invalid character: {}", ch);
}
}
} }

View File

@ -0,0 +1,277 @@
use anyhow::Result;
use readur::test_utils::{TestContext, TestAuthHelper};
use readur::{seed, commands, models::{CreateUser, UserRole}};
/// Test that admin user is created with auto-generated password on first run
#[tokio::test]
async fn test_admin_seed_creates_user_with_auto_password() {
let ctx = TestContext::new().await;
let result: Result<()> = async {
// Clear any env vars to test auto-generation
std::env::remove_var("ADMIN_PASSWORD");
std::env::remove_var("ADMIN_USERNAME");
// Run seed
seed::seed_admin_user(&ctx.state.db).await?;
// Verify admin user exists
let admin = ctx.state.db.get_user_by_username("admin").await?
.expect("Admin user should exist");
// Verify role is Admin
assert_eq!(admin.role, UserRole::Admin, "User should have Admin role");
// Verify password is hashed (bcrypt format)
assert!(admin.password_hash.is_some(), "Password hash should exist");
let hash = admin.password_hash.unwrap();
assert!(
hash.starts_with("$2b$") || hash.starts_with("$2a$"),
"Password should be bcrypt hashed"
);
// Verify email format
assert_eq!(admin.email, "admin@readur.com", "Email should use default format");
Ok(())
}.await;
if let Err(e) = ctx.cleanup_and_close().await {
eprintln!("Warning: Test cleanup failed: {}", e);
}
result.unwrap();
}
/// Test that admin user is created with provided ADMIN_PASSWORD
#[tokio::test]
async fn test_admin_seed_uses_env_password() {
let ctx = TestContext::new().await;
let result: Result<()> = async {
// Set password via environment variable
std::env::set_var("ADMIN_PASSWORD", "testpass123");
std::env::remove_var("ADMIN_USERNAME");
// Run seed
seed::seed_admin_user(&ctx.state.db).await?;
// Verify admin user exists
let admin = ctx.state.db.get_user_by_username("admin").await?
.expect("Admin user should exist");
// Verify we can login with the provided password
let auth_helper = TestAuthHelper::new(ctx.app.clone());
let token = auth_helper.login_user("admin", "testpass123").await;
assert!(!token.is_empty(), "Should be able to login with provided password");
// Clean up env var
std::env::remove_var("ADMIN_PASSWORD");
Ok(())
}.await;
std::env::remove_var("ADMIN_PASSWORD");
if let Err(e) = ctx.cleanup_and_close().await {
eprintln!("Warning: Test cleanup failed: {}", e);
}
result.unwrap();
}
/// Test that subsequent runs don't duplicate admin user
#[tokio::test]
async fn test_admin_seed_does_not_duplicate_user() {
let ctx = TestContext::new().await;
let result: Result<()> = async {
std::env::remove_var("ADMIN_PASSWORD");
std::env::remove_var("ADMIN_USERNAME");
// Run seed first time
seed::seed_admin_user(&ctx.state.db).await?;
let first_admin = ctx.state.db.get_user_by_username("admin").await?
.expect("Admin should exist after first seed");
// Run seed second time
seed::seed_admin_user(&ctx.state.db).await?;
let second_admin = ctx.state.db.get_user_by_username("admin").await?
.expect("Admin should still exist after second seed");
// Verify same user (same ID and hash)
assert_eq!(first_admin.id, second_admin.id, "Should be the same user");
assert_eq!(
first_admin.password_hash, second_admin.password_hash,
"Password should not have changed"
);
// Verify only one admin exists
let all_users = ctx.state.db.get_all_users().await?;
let admin_count = all_users.iter().filter(|u| u.username == "admin").count();
assert_eq!(admin_count, 1, "Should only have one admin user");
Ok(())
}.await;
if let Err(e) = ctx.cleanup_and_close().await {
eprintln!("Warning: Test cleanup failed: {}", e);
}
result.unwrap();
}
/// Test that user can successfully login with generated credentials
#[tokio::test]
async fn test_admin_seed_allows_login() {
let ctx = TestContext::new().await;
let result: Result<()> = async {
// Set known password for testing
std::env::set_var("ADMIN_PASSWORD", "logintest123");
std::env::remove_var("ADMIN_USERNAME");
// Run seed
seed::seed_admin_user(&ctx.state.db).await?;
// Attempt login
let auth_helper = TestAuthHelper::new(ctx.app.clone());
let token = auth_helper.login_user("admin", "logintest123").await;
// Verify token is not empty
assert!(!token.is_empty(), "Should receive valid JWT token");
// Verify token is valid by making authenticated request
let _response = auth_helper
.make_authenticated_request("GET", "/api/auth/me", None, &token)
.await;
// If we get here without panicking, the authenticated request succeeded
// Clean up env var
std::env::remove_var("ADMIN_PASSWORD");
Ok(())
}.await;
std::env::remove_var("ADMIN_PASSWORD");
if let Err(e) = ctx.cleanup_and_close().await {
eprintln!("Warning: Test cleanup failed: {}", e);
}
result.unwrap();
}
/// Test that reset command generates new password and invalidates old one
#[tokio::test]
async fn test_reset_command_changes_password() {
let ctx = TestContext::new().await;
let result: Result<()> = async {
// Create admin with known password
let _ = ctx.state.db.create_user(CreateUser {
username: "admin".to_string(),
email: "admin@readur.com".to_string(),
password: "oldpass123".to_string(),
role: Some(UserRole::Admin),
}).await?;
// Get original password hash
let old_hash = ctx.state.db.get_user_by_username("admin")
.await?
.unwrap()
.password_hash;
// Reset password with new one
std::env::set_var("ADMIN_PASSWORD", "newpass456");
commands::reset_admin_password(&ctx.state.db).await?;
std::env::remove_var("ADMIN_PASSWORD");
// Get new password hash
let new_hash = ctx.state.db.get_user_by_username("admin")
.await?
.unwrap()
.password_hash;
// Verify password changed
assert_ne!(old_hash, new_hash, "Password hash should have changed");
// Verify new password works
let auth_helper = TestAuthHelper::new(ctx.app.clone());
let token = auth_helper.login_user("admin", "newpass456").await;
assert!(!token.is_empty(), "Should be able to login with new password");
// Note: We don't explicitly test that old password doesn't work because
// the auth helper panics on login failure. The fact that new password works
// and the hash changed is sufficient verification.
Ok(())
}.await;
std::env::remove_var("ADMIN_PASSWORD");
if let Err(e) = ctx.cleanup_and_close().await {
eprintln!("Warning: Test cleanup failed: {}", e);
}
result.unwrap();
}
/// Test that reset command uses provided ADMIN_PASSWORD
#[tokio::test]
async fn test_reset_command_uses_env_password() {
let ctx = TestContext::new().await;
let result: Result<()> = async {
// Create admin
let _ = ctx.state.db.create_user(CreateUser {
username: "admin".to_string(),
email: "admin@readur.com".to_string(),
password: "initial123".to_string(),
role: Some(UserRole::Admin),
}).await?;
// Reset with specific password
std::env::set_var("ADMIN_PASSWORD", "specific789");
commands::reset_admin_password(&ctx.state.db).await?;
std::env::remove_var("ADMIN_PASSWORD");
// Verify can login with the specific password
let auth_helper = TestAuthHelper::new(ctx.app.clone());
let token = auth_helper.login_user("admin", "specific789").await;
assert!(!token.is_empty(), "Should login with environment-specified password");
Ok(())
}.await;
std::env::remove_var("ADMIN_PASSWORD");
if let Err(e) = ctx.cleanup_and_close().await {
eprintln!("Warning: Test cleanup failed: {}", e);
}
result.unwrap();
}
/// Test that reset command returns error for non-existent user
#[tokio::test]
async fn test_reset_command_fails_for_nonexistent_user() {
let ctx = TestContext::new().await;
let result: Result<()> = async {
// Don't create any admin user
// Try to reset password for non-existent admin
std::env::remove_var("ADMIN_USERNAME"); // Use default "admin"
std::env::set_var("ADMIN_PASSWORD", "testpass123");
let reset_result = commands::reset_admin_password(&ctx.state.db).await;
// Should return an error
assert!(
reset_result.is_err(),
"Reset should fail when user doesn't exist"
);
let error_message = reset_result.unwrap_err().to_string();
assert!(
error_message.contains("not found") || error_message.contains("Admin user"),
"Error should indicate user not found, got: {}",
error_message
);
std::env::remove_var("ADMIN_PASSWORD");
Ok(())
}.await;
std::env::remove_var("ADMIN_PASSWORD");
if let Err(e) = ctx.cleanup_and_close().await {
eprintln!("Warning: Test cleanup failed: {}", e);
}
result.unwrap();
}