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:
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user