openclaw_gateway/auth/
setup.rs1use std::time::{Duration, Instant};
4
5use serde::{Deserialize, Serialize};
6
7use super::AuthError;
8use super::users::{User, UserRole, UserStore};
9
10const BOOTSTRAP_TOKEN_VALIDITY: Duration = Duration::from_secs(3600); #[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SetupStatus {
16 pub initialized: bool,
18 pub user_count: usize,
20 pub bootstrap_active: bool,
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub setup_url: Option<String>,
25}
26
27pub struct BootstrapManager {
29 token: Option<BootstrapToken>,
31 created_at: Instant,
33}
34
35struct BootstrapToken {
37 value: String,
39 created_at: Instant,
41 expires_at: Instant,
43}
44
45impl BootstrapManager {
46 #[must_use]
48 pub fn new() -> Self {
49 Self {
50 token: None,
51 created_at: Instant::now(),
52 }
53 }
54
55 pub fn check_and_generate(&mut self, user_store: &UserStore) -> Option<String> {
59 if !user_store.is_empty() {
60 self.token = None;
62 return None;
63 }
64
65 if let Some(ref token) = self.token {
67 if Instant::now() < token.expires_at {
68 return Some(token.value.clone());
69 }
70 }
71
72 let token_value = Self::generate_token();
74 let now = Instant::now();
75
76 self.token = Some(BootstrapToken {
77 value: token_value.clone(),
78 created_at: now,
79 expires_at: now + BOOTSTRAP_TOKEN_VALIDITY,
80 });
81
82 Some(token_value)
83 }
84
85 pub fn validate_token(&self, token: &str) -> Result<(), AuthError> {
87 match &self.token {
88 Some(bt) if bt.value == token && Instant::now() < bt.expires_at => Ok(()),
89 Some(_) => Err(AuthError::InvalidBootstrapToken),
90 None => Err(AuthError::InvalidBootstrapToken),
91 }
92 }
93
94 pub fn complete_setup(
102 &mut self,
103 user_store: &UserStore,
104 token: &str,
105 username: &str,
106 password: &str,
107 email: Option<String>,
108 ) -> Result<User, AuthError> {
109 self.validate_token(token)?;
111
112 if !user_store.is_empty() {
114 return Err(AuthError::Config("System already initialized".to_string()));
115 }
116
117 let mut admin = User::new(username, password, UserRole::Admin)?;
119 admin.email = email;
120
121 user_store.create(&admin)?;
122
123 self.token = None;
125
126 tracing::info!(
127 username = %admin.username,
128 "Initial admin user created via bootstrap"
129 );
130
131 Ok(admin)
132 }
133
134 #[must_use]
136 pub fn status(&self, user_store: &UserStore, base_url: Option<&str>) -> SetupStatus {
137 let initialized = !user_store.is_empty();
138 let bootstrap_active = self
139 .token
140 .as_ref()
141 .is_some_and(|t| Instant::now() < t.expires_at);
142
143 let setup_url = if !initialized && bootstrap_active {
144 self.token
145 .as_ref()
146 .and_then(|t| base_url.map(|url| format!("{url}/setup?token={}", t.value)))
147 } else {
148 None
149 };
150
151 SetupStatus {
152 initialized,
153 user_count: user_store.count(),
154 bootstrap_active,
155 setup_url,
156 }
157 }
158
159 #[must_use]
161 pub fn is_required(user_store: &UserStore) -> bool {
162 user_store.is_empty()
163 }
164
165 #[must_use]
167 pub fn token_time_remaining(&self) -> Option<Duration> {
168 self.token.as_ref().and_then(|t| {
169 let now = Instant::now();
170 if now < t.expires_at {
171 Some(t.expires_at - now)
172 } else {
173 None
174 }
175 })
176 }
177
178 pub fn invalidate_token(&mut self) {
180 self.token = None;
181 }
182
183 fn generate_token() -> String {
185 use rand::RngCore;
186 let mut bytes = [0u8; 48];
187 rand::thread_rng().fill_bytes(&mut bytes);
188 base64_url_encode(&bytes)
190 }
191
192 pub fn print_bootstrap_info(&self, base_url: &str) {
194 if let Some(ref token) = self.token {
195 let remaining = self.token_time_remaining().unwrap_or_default();
196 let minutes = remaining.as_secs() / 60;
197
198 println!();
199 println!("┌─────────────────────────────────────────────────────────┐");
200 println!("│ OpenClaw Gateway Started │");
201 println!("│ │");
202 println!("│ No admin user configured. Complete setup at: │");
203 println!("│ {}/setup?token={}...", base_url, &token.value[..16]);
204 println!("│ │");
205 println!("│ Or via CLI: │");
206 println!("│ openclaw admin create --username admin --password ... │");
207 println!("│ │");
208 println!("│ Bootstrap token expires in {minutes} minutes. │");
209 println!("└─────────────────────────────────────────────────────────┘");
210 println!();
211 }
212 }
213}
214
215impl Default for BootstrapManager {
216 fn default() -> Self {
217 Self::new()
218 }
219}
220
221pub fn auto_setup_from_env(user_store: &UserStore) -> Result<Option<User>, AuthError> {
230 if !user_store.is_empty() {
232 return Ok(None);
233 }
234
235 let username = match std::env::var("OPENCLAW_ADMIN_USERNAME") {
236 Ok(u) if !u.is_empty() => u,
237 _ => return Ok(None),
238 };
239
240 let password = match std::env::var("OPENCLAW_ADMIN_PASSWORD") {
241 Ok(p) if !p.is_empty() => p,
242 _ => return Ok(None),
243 };
244
245 let admin = User::new(&username, &password, UserRole::Admin)?;
246 user_store.create(&admin)?;
247
248 tracing::info!(
249 username = %admin.username,
250 "Admin user created from environment variables"
251 );
252
253 Ok(Some(admin))
254}
255
256#[must_use]
258pub fn generate_password(length: usize) -> String {
259 use rand::RngCore;
260 const CHARSET: &[u8] =
261 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
262 let mut rng = rand::thread_rng();
263
264 (0..length)
265 .map(|_| {
266 let idx = (rng.next_u32() as usize) % CHARSET.len();
267 CHARSET[idx] as char
268 })
269 .collect()
270}
271
272fn base64_url_encode(data: &[u8]) -> String {
274 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
275
276 let mut result = String::with_capacity((data.len() * 4).div_ceil(3));
277
278 for chunk in data.chunks(3) {
279 let b0 = chunk[0] as usize;
280 let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
281 let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
282
283 result.push(ALPHABET[b0 >> 2] as char);
284 result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
285
286 if chunk.len() > 1 {
287 result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char);
288 }
289 if chunk.len() > 2 {
290 result.push(ALPHABET[b2 & 0x3f] as char);
291 }
292 }
293
294 result
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use tempfile::TempDir;
301
302 #[test]
303 fn test_bootstrap_manager_new() {
304 let manager = BootstrapManager::new();
305 assert!(manager.token.is_none());
306 }
307
308 #[test]
309 fn test_generate_token() {
310 let token1 = BootstrapManager::generate_token();
311 let token2 = BootstrapManager::generate_token();
312
313 assert!(!token1.is_empty());
314 assert_ne!(token1, token2);
315 assert!(!token1.contains('+'));
317 assert!(!token1.contains('/'));
318 }
319
320 #[test]
321 fn test_check_and_generate() {
322 let temp_dir = TempDir::new().unwrap();
323 let store = UserStore::open(temp_dir.path()).unwrap();
324 let mut manager = BootstrapManager::new();
325
326 let token = manager.check_and_generate(&store);
328 assert!(token.is_some());
329
330 let token2 = manager.check_and_generate(&store);
332 assert_eq!(token, token2);
333 }
334
335 #[test]
336 fn test_no_bootstrap_when_users_exist() {
337 let temp_dir = TempDir::new().unwrap();
338 let store = UserStore::open(temp_dir.path()).unwrap();
339
340 let user = User::new("admin", "password", UserRole::Admin).unwrap();
342 store.create(&user).unwrap();
343
344 let mut manager = BootstrapManager::new();
345 let token = manager.check_and_generate(&store);
346 assert!(token.is_none());
347 }
348
349 #[test]
350 fn test_complete_setup() {
351 let temp_dir = TempDir::new().unwrap();
352 let store = UserStore::open(temp_dir.path()).unwrap();
353 let mut manager = BootstrapManager::new();
354
355 let token = manager.check_and_generate(&store).unwrap();
356
357 let admin = manager
358 .complete_setup(&store, &token, "admin", "secret123", None)
359 .unwrap();
360
361 assert_eq!(admin.username, "admin");
362 assert_eq!(admin.role, UserRole::Admin);
363 assert!(!store.is_empty());
364
365 assert!(manager.token.is_none());
367 }
368
369 #[test]
370 fn test_invalid_bootstrap_token() {
371 let temp_dir = TempDir::new().unwrap();
372 let store = UserStore::open(temp_dir.path()).unwrap();
373 let mut manager = BootstrapManager::new();
374
375 let _token = manager.check_and_generate(&store).unwrap();
376
377 let result = manager.complete_setup(&store, "wrong_token", "admin", "secret", None);
378 assert!(matches!(result, Err(AuthError::InvalidBootstrapToken)));
379 }
380
381 #[test]
382 fn test_setup_status() {
383 let temp_dir = TempDir::new().unwrap();
384 let store = UserStore::open(temp_dir.path()).unwrap();
385 let mut manager = BootstrapManager::new();
386
387 let status = manager.status(&store, Some("http://localhost:18789"));
389 assert!(!status.initialized);
390 assert!(!status.bootstrap_active);
391
392 manager.check_and_generate(&store);
394 let status = manager.status(&store, Some("http://localhost:18789"));
395 assert!(!status.initialized);
396 assert!(status.bootstrap_active);
397 assert!(status.setup_url.is_some());
398 }
399
400 #[test]
401 fn test_generate_password() {
402 let pwd1 = generate_password(16);
403 let pwd2 = generate_password(16);
404
405 assert_eq!(pwd1.len(), 16);
406 assert_ne!(pwd1, pwd2);
407 }
408}