opencode_cloud/wizard/
auth.rs

1//! Auth credential prompts
2//!
3//! Handles username and password collection with random generation option.
4//! Also handles creating container users via PAM-based authentication.
5
6use anyhow::{Result, anyhow};
7use console::{Term, style};
8use dialoguer::{Confirm, Input, Password, Select};
9use opencode_cloud_core::docker::{
10    CONTAINER_NAME, DockerClient, create_user, set_user_password, user_exists,
11};
12use rand::Rng;
13use rand::distr::Alphanumeric;
14
15/// Handle Ctrl+C by restoring cursor and returning error
16fn handle_interrupt() -> anyhow::Error {
17    let _ = Term::stdout().show_cursor();
18    anyhow!("Setup cancelled")
19}
20
21/// Validate username according to rules
22fn validate_username(input: &str) -> Result<(), String> {
23    if input.is_empty() {
24        return Err("Username cannot be empty".to_string());
25    }
26    if input.len() < 3 {
27        return Err("Username must be at least 3 characters".to_string());
28    }
29    if input.len() > 32 {
30        return Err("Username must be at most 32 characters".to_string());
31    }
32    if !input.chars().all(|c| c.is_alphanumeric() || c == '_') {
33        return Err("Username must contain only letters, numbers, and underscores".to_string());
34    }
35    Ok(())
36}
37
38/// Generate a secure random password
39fn generate_random_password() -> String {
40    rand::rng()
41        .sample_iter(Alphanumeric)
42        .take(24)
43        .map(char::from)
44        .collect()
45}
46
47/// Prompt for authentication credentials
48///
49/// Offers choice between random generation and manual entry.
50/// Returns (username, password) tuple.
51pub fn prompt_auth(step: usize, total: usize) -> Result<(String, String)> {
52    println!(
53        "{} {}",
54        style(format!("[{step}/{total}]")).dim(),
55        style("Authentication").bold()
56    );
57    println!();
58
59    loop {
60        // Ask how user wants to set credentials
61        let options = vec![
62            "Generate secure random credentials",
63            "Enter my own username and password",
64        ];
65
66        let selection = Select::new()
67            .with_prompt("How would you like to set credentials?")
68            .items(&options)
69            .default(0)
70            .interact()
71            .map_err(|_| handle_interrupt())?;
72
73        match selection {
74            0 => {
75                // Random generation
76                let password = generate_random_password();
77
78                println!();
79                println!("{}", style("Generated credentials:").green());
80                println!("  Username: {}", style("admin").cyan());
81                println!("  Password: {}", style(&password).cyan());
82                println!();
83                println!(
84                    "{}",
85                    style("Save these credentials securely - the password won't be shown again.")
86                        .yellow()
87                );
88                println!();
89
90                let use_these = Confirm::new()
91                    .with_prompt("Use these credentials?")
92                    .default(true)
93                    .interact()
94                    .map_err(|_| handle_interrupt())?;
95
96                if use_these {
97                    return Ok(("admin".to_string(), password));
98                }
99                // If not accepted, loop back to selection
100                println!();
101            }
102            1 => {
103                // Manual entry
104                println!();
105
106                let username: String = Input::new()
107                    .with_prompt("Username")
108                    .validate_with(|input: &String| validate_username(input))
109                    .interact_text()
110                    .map_err(|_| handle_interrupt())?;
111
112                let password = Password::new()
113                    .with_prompt("Password")
114                    .with_confirmation("Confirm password", "Passwords do not match")
115                    .interact()
116                    .map_err(|_| handle_interrupt())?;
117
118                if password.is_empty() {
119                    println!("{}", style("Password cannot be empty").red());
120                    println!();
121                    continue;
122                }
123
124                return Ok((username, password));
125            }
126            _ => unreachable!(),
127        }
128    }
129}
130
131/// Create a user in the container with the given password
132///
133/// If the user already exists, updates their password instead.
134/// Uses PAM-based authentication (chpasswd for secure password setting).
135pub async fn create_container_user(
136    client: &DockerClient,
137    username: &str,
138    password: &str,
139) -> Result<()> {
140    // Check if user already exists
141    if user_exists(client, CONTAINER_NAME, username).await? {
142        // User exists, just update password
143        println!("  User '{username}' exists, updating password...");
144    } else {
145        // Create new user
146        println!("  Creating user '{username}' in container...");
147        create_user(client, CONTAINER_NAME, username).await?;
148    }
149
150    // Set password
151    set_user_password(client, CONTAINER_NAME, username, password).await?;
152    println!("  Password set for '{username}'");
153
154    Ok(())
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_validate_username_valid() {
163        assert!(validate_username("admin").is_ok());
164        assert!(validate_username("user_123").is_ok());
165        assert!(validate_username("ABC").is_ok());
166        assert!(validate_username("a_b_c_d_e_f_g_h_i_j_k_l_m_n_").is_ok()); // 32 chars
167    }
168
169    #[test]
170    fn test_validate_username_empty() {
171        assert!(validate_username("").is_err());
172    }
173
174    #[test]
175    fn test_validate_username_too_short() {
176        assert!(validate_username("ab").is_err());
177    }
178
179    #[test]
180    fn test_validate_username_too_long() {
181        let long = "a".repeat(33);
182        assert!(validate_username(&long).is_err());
183    }
184
185    #[test]
186    fn test_validate_username_invalid_chars() {
187        assert!(validate_username("user@name").is_err());
188        assert!(validate_username("user-name").is_err());
189        assert!(validate_username("user name").is_err());
190    }
191
192    #[test]
193    fn test_generate_random_password_length() {
194        let password = generate_random_password();
195        assert_eq!(password.len(), 24);
196    }
197
198    #[test]
199    fn test_generate_random_password_alphanumeric() {
200        let password = generate_random_password();
201        assert!(password.chars().all(|c| c.is_alphanumeric()));
202    }
203
204    #[test]
205    fn test_generate_random_password_uniqueness() {
206        // Generate multiple passwords and ensure they're different
207        let p1 = generate_random_password();
208        let p2 = generate_random_password();
209        let p3 = generate_random_password();
210        assert_ne!(p1, p2);
211        assert_ne!(p2, p3);
212        assert_ne!(p1, p3);
213    }
214}