Skip to main content

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    println!(
59        "{}",
60        style("These are Linux system credentials created inside the container. The web").dim()
61    );
62    println!(
63        "{}",
64        style("interface uses PAM to authenticate against them, and they also work for SSH").dim()
65    );
66    println!(
67        "{}",
68        style("or any PAM-enabled service. Passwords are SHA-512 hashed via chpasswd and").dim()
69    );
70    println!(
71        "{}",
72        style("stored in /etc/shadow. Your config file only stores usernames, never passwords.")
73            .dim()
74    );
75    println!();
76
77    loop {
78        // Ask how user wants to set credentials
79        let options = vec![
80            "Generate secure random credentials",
81            "Enter my own username and password",
82        ];
83
84        let selection = Select::new()
85            .with_prompt("How would you like to set credentials?")
86            .items(&options)
87            .default(0)
88            .interact()
89            .map_err(|_| handle_interrupt())?;
90
91        match selection {
92            0 => {
93                // Random generation
94                let password = generate_random_password();
95
96                println!();
97                println!("{}", style("Generated credentials:").green());
98                println!("  Username: {}", style("admin").cyan());
99                println!("  Password: {}", style(&password).cyan());
100                println!();
101                println!(
102                    "{}",
103                    style("Save these credentials securely - the password won't be shown again.")
104                        .yellow()
105                );
106                println!();
107
108                let use_these = Confirm::new()
109                    .with_prompt("Use these credentials?")
110                    .default(true)
111                    .interact()
112                    .map_err(|_| handle_interrupt())?;
113
114                if use_these {
115                    return Ok(("admin".to_string(), password));
116                }
117                // If not accepted, loop back to selection
118                println!();
119            }
120            1 => {
121                // Manual entry
122                println!();
123
124                let username: String = Input::new()
125                    .with_prompt("Username")
126                    .validate_with(|input: &String| validate_username(input))
127                    .interact_text()
128                    .map_err(|_| handle_interrupt())?;
129
130                let password = Password::new()
131                    .with_prompt("Password")
132                    .with_confirmation("Confirm password", "Passwords do not match")
133                    .interact()
134                    .map_err(|_| handle_interrupt())?;
135
136                if password.is_empty() {
137                    println!("{}", style("Password cannot be empty").red());
138                    println!();
139                    continue;
140                }
141
142                return Ok((username, password));
143            }
144            _ => unreachable!(),
145        }
146    }
147}
148
149/// Create a user in the container with the given password
150///
151/// If the user already exists, updates their password instead.
152/// Uses PAM-based authentication (chpasswd for secure password setting).
153pub async fn create_container_user(
154    client: &DockerClient,
155    username: &str,
156    password: &str,
157) -> Result<()> {
158    // Check if user already exists
159    if user_exists(client, CONTAINER_NAME, username).await? {
160        // User exists, just update password
161        println!("  User '{username}' exists, updating password...");
162    } else {
163        // Create new user
164        println!("  Creating user '{username}' in container...");
165        create_user(client, CONTAINER_NAME, username).await?;
166    }
167
168    // Set password
169    set_user_password(client, CONTAINER_NAME, username, password).await?;
170    println!("  Password set for '{username}'");
171
172    Ok(())
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn test_validate_username_valid() {
181        assert!(validate_username("admin").is_ok());
182        assert!(validate_username("user_123").is_ok());
183        assert!(validate_username("ABC").is_ok());
184        assert!(validate_username("a_b_c_d_e_f_g_h_i_j_k_l_m_n_").is_ok()); // 32 chars
185    }
186
187    #[test]
188    fn test_validate_username_empty() {
189        assert!(validate_username("").is_err());
190    }
191
192    #[test]
193    fn test_validate_username_too_short() {
194        assert!(validate_username("ab").is_err());
195    }
196
197    #[test]
198    fn test_validate_username_too_long() {
199        let long = "a".repeat(33);
200        assert!(validate_username(&long).is_err());
201    }
202
203    #[test]
204    fn test_validate_username_invalid_chars() {
205        assert!(validate_username("user@name").is_err());
206        assert!(validate_username("user-name").is_err());
207        assert!(validate_username("user name").is_err());
208    }
209
210    #[test]
211    fn test_generate_random_password_length() {
212        let password = generate_random_password();
213        assert_eq!(password.len(), 24);
214    }
215
216    #[test]
217    fn test_generate_random_password_alphanumeric() {
218        let password = generate_random_password();
219        assert!(password.chars().all(|c| c.is_alphanumeric()));
220    }
221
222    #[test]
223    fn test_generate_random_password_uniqueness() {
224        // Generate multiple passwords and ensure they're different
225        let p1 = generate_random_password();
226        let p2 = generate_random_password();
227        let p3 = generate_random_password();
228        assert_ne!(p1, p2);
229        assert_ne!(p2, p3);
230        assert_ne!(p1, p3);
231    }
232}