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