diff --git a/CLAUDE.md b/CLAUDE.md index 06d382f..aae71ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `. 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`. - 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. - `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`. diff --git a/README.md b/README.md index 716cc83..38150b7 100644 --- a/README.md +++ b/README.md @@ -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. +## 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 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. diff --git a/config_sample.toml b/config_sample.toml index 4210291..a5ecfd3 100644 --- a/config_sample.toml +++ b/config_sample.toml @@ -24,6 +24,12 @@ path = "Maildir" #tls = 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. # Default is "data" in the working directory. Must survive application upgrades. #[storage] diff --git a/src/backend/user.rs b/src/backend/user.rs index 2c0f711..3ddff18 100644 --- a/src/backend/user.rs +++ b/src/backend/user.rs @@ -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 { 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(()) diff --git a/src/config.rs b/src/config.rs index f3b822a..fef9cad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 +} + +#[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 { + resolve_initial_password(std::env::var(ADMIN_PASSWORD_ENV).ok(), self.initial_password.clone()) + } +} + +#[cfg(feature = "ssr")] +fn resolve_initial_password(env: Option, config: Option) -> Result { + 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 diff --git a/src/main.rs b/src/main.rs index c1e926f..cac7556 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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);