Removed default admin password, the first admin account needs a configured one. Fixes #10

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 15:43:37 +02:00
co-authored by Claude Opus 5.5
parent 21b7007d28
commit e097d8ba51
6 changed files with 112 additions and 7 deletions
+13 -3
View File
@@ -11,6 +11,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use log::{info, warn};
use crate::error::AppError;
use crate::backend::get_pool;
use crate::config::Admin;
pub async fn has_admin_user(pool: &PgPool) -> Result<bool, Error> {
let count: (i64,) = query_as(r#"SELECT COUNT(id) FROM "user" WHERE admin = $1"#)
@@ -21,15 +22,24 @@ cfg_if! { if #[cfg(feature = "ssr")] {
Ok(count.0 > 0)
}
pub async fn create_admin(pool: &PgPool) -> Result<(), AppError> {
/// Creates the first admin account when there is none. The password comes from the
/// configuration, there is no default one.
pub async fn create_admin(pool: &PgPool, admin_conf: &Admin) -> Result<(), AppError> {
if !has_admin_user(pool).await? {
let pwd = pwhash::bcrypt::hash("admin");
let password = admin_conf.initial_password().map_err(AppError::FatalError)?;
let pwd = pwhash::bcrypt::hash(password).map_err(|e| AppError::FatalError(e.to_string()))?;
query(r#"INSERT INTO "user"(login, password, full_name, admin) VALUES($1, $2, $3, $4)"#)
.bind("admin")
.bind(pwd.unwrap())
.bind(pwd)
.bind("Admin User")
.bind(true)
.execute(pool).await?;
info!("Created admin account with the configured initial password");
} else if let Ok(admin) = user_from_login(pool, "admin").await {
// Installations created by older versions started with a known password
if pwhash::bcrypt::verify("admin", &admin.password) {
warn!("Account admin still uses the default password, change it!");
}
}
Ok(())
+77 -1
View File
@@ -137,6 +137,42 @@ impl Storage {
}
}
/// Environment variable with the password of the first admin account. Takes precedence over the config file.
#[cfg(feature = "ssr")]
pub const ADMIN_PASSWORD_ENV: &str = "REZERVATOR_ADMIN_PASSWORD";
#[cfg(feature = "ssr")]
pub const ADMIN_PASSWORD_MIN_LEN: usize = 8;
/// Setup of the first admin account
#[cfg(feature = "ssr")]
#[derive(Deserialize, Default)]
pub struct Admin {
initial_password: Option<String>
}
#[cfg(feature = "ssr")]
impl Admin {
/// Password for the admin account created on first start: the environment variable, or the
/// `initial_password` config key. There is no default, a known password must never be used.
pub fn initial_password(&self) -> Result<String, String> {
resolve_initial_password(std::env::var(ADMIN_PASSWORD_ENV).ok(), self.initial_password.clone())
}
}
#[cfg(feature = "ssr")]
fn resolve_initial_password(env: Option<String>, config: Option<String>) -> Result<String, String> {
let password = env.filter(|p| !p.is_empty()).or(config.filter(|p| !p.is_empty()))
.ok_or(format!(
"No admin account exists and no initial password is set. Set the {} environment variable or initial_password in the [admin] section of the config.",
ADMIN_PASSWORD_ENV))?;
if password.chars().count() < ADMIN_PASSWORD_MIN_LEN {
return Err(format!("Initial admin password must have at least {} characters.", ADMIN_PASSWORD_MIN_LEN))
}
Ok(password)
}
#[cfg(feature = "ssr")]
#[derive(Deserialize)]
pub struct Configuration {
@@ -146,7 +182,9 @@ pub struct Configuration {
mailing: Mailing,
logging: Logging,
#[serde(default)]
storage: Storage
storage: Storage,
#[serde(default)]
admin: Admin
}
#[cfg(feature = "ssr")]
@@ -169,6 +207,9 @@ impl Configuration {
pub fn storage(&self) -> &Storage {
&self.storage
}
pub fn admin(&self) -> &Admin {
&self.admin
}
}
/// The config file holds passwords and the session key, so it must never lie in a directory
@@ -244,6 +285,41 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn initial_password_is_required() {
assert!(resolve_initial_password(None, None).is_err());
assert!(resolve_initial_password(Some("".to_string()), Some("".to_string())).is_err());
// The sample config must not ship a usable password
assert!(resolve_initial_password(None, sample().admin.initial_password).is_err());
}
#[test]
fn initial_password_from_config() {
let admin: Admin = toml::from_str("initial_password = 'correct horse'").unwrap();
assert_eq!(resolve_initial_password(None, admin.initial_password), Ok("correct horse".to_string()));
}
#[test]
fn initial_password_env_takes_precedence() {
assert_eq!(
resolve_initial_password(Some("from-environment".to_string()), Some("from-config".to_string())),
Ok("from-environment".to_string()));
// An empty variable does not hide the config value
assert_eq!(
resolve_initial_password(Some("".to_string()), Some("from-config".to_string())),
Ok("from-config".to_string()));
}
#[test]
fn initial_password_must_not_be_short() {
assert!(resolve_initial_password(Some("admin".to_string()), None).is_err());
assert!(resolve_initial_password(None, Some("1234567".to_string())).is_err());
assert!(resolve_initial_password(None, Some("12345678".to_string())).is_ok());
// Length is counted in characters, not bytes
assert!(resolve_initial_password(None, Some("čšřžýáí".to_string())).is_err());
}
#[test]
fn data_dir_defaults_to_data() {
// The sample config has the storage section commented out
+4 -2
View File
@@ -84,8 +84,10 @@ async fn main() -> std::io::Result<()> {
let mut mailer = Mailer::new(srv_conf.mailing());
mailer.start_sender();
if let Err(e) = create_admin(&pool).await {
error!("Error while checking admin user: {:?}", e);
// Without an admin account nobody can log in, and it must not get a known password
if let Err(e) = create_admin(&pool, srv_conf.admin()).await {
error!("Cannot set up admin account: {}", e);
return Err(std::io::Error::other(e));
}
if let Err(e) = check_company(&pool).await {
error!("Error while checking company: {:?}", e);