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
+1 -1
View File
@@ -30,7 +30,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[
- `config.toml` (gitignored) is read from the working directory, or from `-c <path>`. Copy `config_sample.toml`. The bind address comes from `[network]` there, not from `LEPTOS_SITE_ADDR`. - `config.toml` (gitignored) is read from the working directory, or from `-c <path>`. Copy `config_sample.toml`. The bind address comes from `[network]` there, not from `LEPTOS_SITE_ADDR`.
- The config file holds secrets and must stay outside the site root (`target/site`), which is served as static files; the server refuses to start otherwise (`is_publicly_served` in `src/config.rs`). In Docker it is mounted to `/app/config/config.toml`. - The config file holds secrets and must stay outside the site root (`target/site`), which is served as static files; the server refuses to start otherwise (`is_publicly_served` in `src/config.rs`). In Docker it is mounted to `/app/config/config.toml`.
- Migrations in `migrations/` are embedded by `sqlx::migrate!()` and run on startup. - Migrations in `migrations/` are embedded by `sqlx::migrate!()` and run on startup.
- First start seeds an `admin` / `admin` user, a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`). - First start creates the `admin` user with the password from `REZERVATOR_ADMIN_PASSWORD` or `[admin] initial_password` in the config (no default; the server refuses to start when no admin exists and no password is given). It also seeds a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`).
- With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent. - With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent.
- `assets/` is copied to `target/site` on build and served as static files. - `assets/` is copied to `target/site` on build and served as static files.
- Files created at runtime (uploaded banner, generated banner CSS) go to the data directory: `data/` in the working directory by default (gitignored), `[storage] data_dir` in the config, `/app/data` volume in Docker. It is served under `/data` and survives rebuilds. On start the server copies the `banner.css` template there from the site root and regenerates a missing banner style. Server code gets the path with `get_data_dir()`; never write into `target/site`. - Files created at runtime (uploaded banner, generated banner CSS) go to the data directory: `data/` in the working directory by default (gitignored), `[storage] data_dir` in the config, `/app/data` volume in Docker. It is served under `/data` and survives rebuilds. On start the server copies the `banner.css` template there from the site root and regenerates a missing banner style. Server code gets the path with `get_data_dir()`; never write into `target/site`.
+11
View File
@@ -27,6 +27,17 @@ The server reads `config.toml` from the working directory, or from the path give
The file contains the database and SMTP passwords and the session key. Keep it outside the site directory (`target/site`): everything in there is served as static files. The server refuses to start when the config file is inside it. The file contains the database and SMTP passwords and the session key. Keep it outside the site directory (`target/site`): everything in there is served as static files. The server refuses to start when the config file is inside it.
## First admin account
On the first start, when the database has no admin account, the server creates the user `admin`. Its password must be given, at least 8 characters, in one of these ways:
- the `REZERVATOR_ADMIN_PASSWORD` environment variable, or
- `initial_password` in the `[admin]` section of the config.
Without it the server refuses to start. Once an admin account exists the value is not used any more and should be removed.
Older versions created `admin` with the password `admin`. The server logs a warning on start while that password is still in use.
## Data directory ## Data directory
Files created while the application runs (the uploaded banner and the styles generated for it) are stored in the data directory and served under `/data`. By default it is `data` in the working directory; change it with `data_dir` in the `[storage]` section of the config. Keep this directory when you upgrade the application and include it in backups. Never put the config file into it. Files created while the application runs (the uploaded banner and the styles generated for it) are stored in the data directory and served under `/data`. By default it is `data` in the working directory; change it with `data_dir` in the `[storage]` section of the config. Keep this directory when you upgrade the application and include it in backups. Never put the config file into it.
+6
View File
@@ -24,6 +24,12 @@ path = "Maildir"
#tls = true #tls = true
#accept_all_certs = true #accept_all_certs = true
# Password of the "admin" account, which is created on the first start when no admin exists.
# At least 8 characters. Can be given in the REZERVATOR_ADMIN_PASSWORD environment variable instead.
# It is not used once an admin account exists; remove it then.
#[admin]
#initial_password = ""
# Directory for uploaded files (banner) and generated styles, served under /data. # Directory for uploaded files (banner) and generated styles, served under /data.
# Default is "data" in the working directory. Must survive application upgrades. # Default is "data" in the working directory. Must survive application upgrades.
#[storage] #[storage]
+13 -3
View File
@@ -11,6 +11,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use log::{info, warn}; use log::{info, warn};
use crate::error::AppError; use crate::error::AppError;
use crate::backend::get_pool; use crate::backend::get_pool;
use crate::config::Admin;
pub async fn has_admin_user(pool: &PgPool) -> Result<bool, Error> { 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"#) 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) 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? { 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)"#) query(r#"INSERT INTO "user"(login, password, full_name, admin) VALUES($1, $2, $3, $4)"#)
.bind("admin") .bind("admin")
.bind(pwd.unwrap()) .bind(pwd)
.bind("Admin User") .bind("Admin User")
.bind(true) .bind(true)
.execute(pool).await?; .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(()) 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")] #[cfg(feature = "ssr")]
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Configuration { pub struct Configuration {
@@ -146,7 +182,9 @@ pub struct Configuration {
mailing: Mailing, mailing: Mailing,
logging: Logging, logging: Logging,
#[serde(default)] #[serde(default)]
storage: Storage storage: Storage,
#[serde(default)]
admin: Admin
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -169,6 +207,9 @@ impl Configuration {
pub fn storage(&self) -> &Storage { pub fn storage(&self) -> &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 /// 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(); 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] #[test]
fn data_dir_defaults_to_data() { fn data_dir_defaults_to_data() {
// The sample config has the storage section commented out // 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()); let mut mailer = Mailer::new(srv_conf.mailing());
mailer.start_sender(); mailer.start_sender();
if let Err(e) = create_admin(&pool).await { // Without an admin account nobody can log in, and it must not get a known password
error!("Error while checking admin user: {:?}", e); 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 { if let Err(e) = check_company(&pool).await {
error!("Error while checking company: {:?}", e); error!("Error while checking company: {:?}", e);