Skip to content

Commit a8aba2c

Browse files
committed
feat: add first-run admin setup with db credentials and env fallback
1 parent c8442e4 commit a8aba2c

8 files changed

Lines changed: 546 additions & 94 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ stdout = true
9797

9898
### Runtime Environment
9999

100-
- `ADMIN_USER` (required)
101-
- `ADMIN_PASSWORD_HASH` (required)
100+
- `ADMIN_USER` (optional fallback login)
101+
- `ADMIN_PASSWORD_HASH` (optional fallback login, requires `ADMIN_USER`)
102102
- `IMGFLOP_API_TOP_N` (`max` by default, or integer `>= 1`)
103103
- `IMGFLOP_HISTORY_TOP_N` (`100` by default, integer `>= 1`)
104104
- `IMGFLOP_POLL_INTERVAL_SECS` (`300` by default, integer `>= 1`)
@@ -111,6 +111,11 @@ stdout = true
111111
- API ingest candidate count is controlled by `IMGFLOP_API_TOP_N`.
112112
- Persisted top-state/events are controlled by `IMGFLOP_HISTORY_TOP_N`.
113113

114+
Admin auth behavior:
115+
- If no admin account exists in DB, `/admin/login` shows a first-run setup form.
116+
- Setup stores one local admin credential in SQLite using Argon2id hash.
117+
- If `ADMIN_USER` + `ADMIN_PASSWORD_HASH` are provided, they remain usable as an emergency fallback login.
118+
114119
### Prerequisites
115120
- Rust toolchain (stable)
116121
- Docker Desktop (for `rust.testcontainers` scenarios)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
CREATE TABLE IF NOT EXISTS admin_credentials (
2+
id INTEGER PRIMARY KEY CHECK (id = 1),
3+
username TEXT NOT NULL,
4+
password_hash TEXT NOT NULL,
5+
created_at_utc TEXT NOT NULL,
6+
updated_at_utc TEXT NOT NULL
7+
);
8+
9+
CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_credentials_username
10+
ON admin_credentials(username);

src/auth/mod.rs

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,19 @@ pub enum AuthError {
2222
}
2323

2424
pub struct AuthService {
25-
admin_user: String,
26-
admin_password_hash: String,
25+
fallback_admin: Option<FallbackAdmin>,
2726
sessions: Mutex<HashSet<SessionRecord>>,
2827
session_seq: AtomicU64,
2928
session_ttl_secs: u64,
3029
secure_cookie: bool,
3130
}
3231

32+
#[derive(Debug, Clone)]
33+
struct FallbackAdmin {
34+
username: String,
35+
password_hash: String,
36+
}
37+
3338
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
3439
struct SessionRecord {
3540
token: String,
@@ -43,18 +48,42 @@ impl AuthService {
4348
session_ttl_secs: u64,
4449
secure_cookie: bool,
4550
) -> Result<Self, String> {
46-
if admin_user.trim().is_empty() {
47-
return Err("admin username must not be empty".to_string());
48-
}
49-
PasswordHash::new(&admin_password_hash)
50-
.map_err(|_| "admin password hash is invalid".to_string())?;
51+
Self::new_with_fallback(
52+
Some(admin_user),
53+
Some(admin_password_hash),
54+
session_ttl_secs,
55+
secure_cookie,
56+
)
57+
}
58+
59+
pub fn new_with_fallback(
60+
admin_user: Option<String>,
61+
admin_password_hash: Option<String>,
62+
session_ttl_secs: u64,
63+
secure_cookie: bool,
64+
) -> Result<Self, String> {
5165
if session_ttl_secs == 0 {
5266
return Err("session TTL must be >= 1 second".to_string());
5367
}
5468

69+
let fallback_admin = match (admin_user, admin_password_hash) {
70+
(Some(username), Some(password_hash)) => {
71+
if username.trim().is_empty() {
72+
return Err("admin username must not be empty".to_string());
73+
}
74+
PasswordHash::new(&password_hash)
75+
.map_err(|_| "admin password hash is invalid".to_string())?;
76+
Some(FallbackAdmin {
77+
username,
78+
password_hash,
79+
})
80+
}
81+
(None, None) => None,
82+
_ => return Err("ADMIN_USER and ADMIN_PASSWORD_HASH must both be set".to_string()),
83+
};
84+
5585
Ok(Self {
56-
admin_user,
57-
admin_password_hash,
86+
fallback_admin,
5887
sessions: Mutex::new(HashSet::new()),
5988
session_seq: AtomicU64::new(1),
6089
session_ttl_secs,
@@ -63,30 +92,18 @@ impl AuthService {
6392
}
6493

6594
pub fn dev_default() -> Self {
66-
let salt = SaltString::generate(&mut OsRng);
67-
let password_hash = Argon2::default()
68-
.hash_password(b"admin", &salt)
69-
.expect("default dev password hash should build")
70-
.to_string();
95+
let password_hash = hash_password("admin").expect("default dev password hash should build");
7196

72-
Self::new("admin".to_string(), password_hash, 3600, false)
97+
Self::new_with_fallback(Some("admin".to_string()), Some(password_hash), 3600, false)
7398
.expect("dev default auth should be valid")
7499
}
75100

76101
pub fn login(&self, username: &str, password: &str) -> Result<String, AuthError> {
77-
if !self.verify_credentials(username, password) {
102+
if !self.verify_fallback_credentials(username, password) {
78103
return Err(AuthError::InvalidCredentials);
79104
}
80105

81-
let token = format!("s-{}", self.session_seq.fetch_add(1, Ordering::Relaxed));
82-
let issued_at_utc = now_epoch_seconds();
83-
if let Ok(mut sessions) = self.sessions.lock() {
84-
sessions.insert(SessionRecord {
85-
token: token.clone(),
86-
issued_at_utc,
87-
});
88-
}
89-
Ok(token)
106+
Ok(self.issue_session_token())
90107
}
91108

92109
pub fn logout_token(&self, token: &str) {
@@ -118,18 +135,30 @@ impl AuthService {
118135
self.secure_cookie
119136
}
120137

121-
fn verify_credentials(&self, username: &str, password: &str) -> bool {
122-
if username != self.admin_user {
123-
return false;
138+
pub fn has_fallback_credentials(&self) -> bool {
139+
self.fallback_admin.is_some()
140+
}
141+
142+
pub fn issue_session_token(&self) -> String {
143+
let token = format!("s-{}", self.session_seq.fetch_add(1, Ordering::Relaxed));
144+
let issued_at_utc = now_epoch_seconds();
145+
if let Ok(mut sessions) = self.sessions.lock() {
146+
sessions.insert(SessionRecord {
147+
token: token.clone(),
148+
issued_at_utc,
149+
});
124150
}
151+
token
152+
}
125153

126-
let Ok(parsed_hash) = PasswordHash::new(&self.admin_password_hash) else {
154+
pub fn verify_fallback_credentials(&self, username: &str, password: &str) -> bool {
155+
let Some(fallback) = self.fallback_admin.as_ref() else {
127156
return false;
128157
};
129-
130-
Argon2::default()
131-
.verify_password(password.as_bytes(), &parsed_hash)
132-
.is_ok()
158+
if username != fallback.username {
159+
return false;
160+
}
161+
verify_password(&fallback.password_hash, password)
133162
}
134163

135164
fn is_expired(&self, now_utc: i64, issued_at_utc: i64) -> bool {
@@ -143,3 +172,23 @@ fn now_epoch_seconds() -> i64 {
143172
.map(|duration| duration.as_secs() as i64)
144173
.unwrap_or_default()
145174
}
175+
176+
pub fn hash_password(password: &str) -> Result<String, String> {
177+
if password.is_empty() {
178+
return Err("password must not be empty".to_string());
179+
}
180+
let salt = SaltString::generate(&mut OsRng);
181+
Argon2::default()
182+
.hash_password(password.as_bytes(), &salt)
183+
.map(|hash| hash.to_string())
184+
.map_err(|err| err.to_string())
185+
}
186+
187+
pub fn verify_password(password_hash: &str, password: &str) -> bool {
188+
let Ok(parsed_hash) = PasswordHash::new(password_hash) else {
189+
return false;
190+
};
191+
Argon2::default()
192+
.verify_password(password.as_bytes(), &parsed_hash)
193+
.is_ok()
194+
}

src/config.rs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::{
77
time::{SystemTime, UNIX_EPOCH},
88
};
99

10+
use argon2::password_hash::PasswordHash;
1011
use serde::Deserialize;
1112
use sqlx::sqlite::SqliteConnectOptions;
1213

@@ -35,8 +36,8 @@ pub struct Config {
3536

3637
#[derive(Debug, Clone, PartialEq, Eq)]
3738
pub struct RuntimeAuthConfig {
38-
pub admin_user: String,
39-
pub admin_password_hash: String,
39+
pub fallback_admin_user: Option<String>,
40+
pub fallback_admin_password_hash: Option<String>,
4041
pub session_ttl_secs: u64,
4142
pub secure_cookie: bool,
4243
}
@@ -76,12 +77,16 @@ impl RuntimeConfig {
7677
300,
7778
)?;
7879

79-
let admin_user = get("ADMIN_USER")
80-
.filter(|value| !value.trim().is_empty())
81-
.ok_or_else(|| "ADMIN_USER must be set".to_string())?;
82-
let admin_password_hash = get("ADMIN_PASSWORD_HASH")
83-
.filter(|value| !value.trim().is_empty())
84-
.ok_or_else(|| "ADMIN_PASSWORD_HASH must be set".to_string())?;
80+
let fallback_admin_user = get("ADMIN_USER").filter(|value| !value.trim().is_empty());
81+
let fallback_admin_password_hash =
82+
get("ADMIN_PASSWORD_HASH").filter(|value| !value.trim().is_empty());
83+
if fallback_admin_user.is_some() != fallback_admin_password_hash.is_some() {
84+
return Err("ADMIN_USER and ADMIN_PASSWORD_HASH must both be set".to_string());
85+
}
86+
if let Some(hash) = fallback_admin_password_hash.as_deref() {
87+
PasswordHash::new(hash)
88+
.map_err(|_| "ADMIN_PASSWORD_HASH must be a valid argon2 hash".to_string())?;
89+
}
8590

8691
let session_ttl_secs = parse_u64_at_least_one(
8792
get("IMGFLOP_SESSION_TTL_SECS"),
@@ -103,8 +108,8 @@ impl RuntimeConfig {
103108
poll_interval_secs,
104109
api_endpoint,
105110
auth: RuntimeAuthConfig {
106-
admin_user,
107-
admin_password_hash,
111+
fallback_admin_user,
112+
fallback_admin_password_hash,
108113
session_ttl_secs,
109114
secure_cookie,
110115
},

src/main.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ async fn main() {
4040
));
4141
let designer = DesignerService::new(pool.clone(), assets_root);
4242
let auth = Arc::new(
43-
AuthService::new(
44-
config.auth.admin_user.clone(),
45-
config.auth.admin_password_hash.clone(),
43+
AuthService::new_with_fallback(
44+
config.auth.fallback_admin_user.clone(),
45+
config.auth.fallback_admin_password_hash.clone(),
4646
config.auth.session_ttl_secs,
4747
config.auth.secure_cookie,
4848
)

0 commit comments

Comments
 (0)